diff --git a/controllers/user_upload.go b/controllers/user_upload.go
new file mode 100644
index 00000000..d937e859
--- /dev/null
+++ b/controllers/user_upload.go
@@ -0,0 +1,60 @@
+// Copyright 2021 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+ "fmt"
+ "io"
+ "mime/multipart"
+ "os"
+
+ "github.com/casbin/casdoor/object"
+ "github.com/casbin/casdoor/util"
+)
+
+func saveFile(path string, file *multipart.File) {
+ f, err := os.Create(path)
+ if err != nil {
+ panic(err)
+ }
+ defer f.Close()
+
+ _, err = io.Copy(f, *file)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func (c *ApiController) UploadUsers() {
+ userId := c.GetSessionUsername()
+ owner, user := util.GetOwnerAndNameFromId(userId)
+
+ file, header, err := c.Ctx.Request.FormFile("file")
+ if err != nil {
+ panic(err)
+ }
+ fileId := fmt.Sprintf("%s_%s_%s", owner, user, util.RemoveExt(header.Filename))
+
+ path := util.GetUploadXlsxPath(fileId)
+ util.EnsureFileFolderExists(path)
+ saveFile(path, &file)
+
+ affected := object.UploadUsers(owner, fileId)
+ if affected {
+ c.ResponseOk()
+ } else {
+ c.ResponseError("Failed to import users")
+ }
+}
diff --git a/go.mod b/go.mod
index 2a61bb5c..63b6239b 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@ require (
github.com/russellhaering/goxmldsig v1.1.1
github.com/satori/go.uuid v1.2.0 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect
+ github.com/tealeg/xlsx v1.0.5
github.com/thanhpk/randstr v1.0.4
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2
diff --git a/go.sum b/go.sum
index e0962233..ead49aa9 100644
--- a/go.sum
+++ b/go.sum
@@ -349,6 +349,8 @@ github.com/syndtr/goleveldb v0.0.0-20160425020131-cfa635847112/go.mod h1:Z4AUp2K
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
+github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
+github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
github.com/tencentcloud/tencentcloud-sdk-go v1.0.154 h1:THBgwGwUQtsw6L53cSSA2wwL3sLrm+HJ3Dk+ye/lMCI=
github.com/tencentcloud/tencentcloud-sdk-go v1.0.154/go.mod h1:asUz5BPXxgoPGaRgZaVm1iGcUAuHyYUo1nXqKa83cvI=
github.com/thanhpk/randstr v1.0.4 h1:IN78qu/bR+My+gHCvMEXhR/i5oriVHcTB/BJJIRTsNo=
diff --git a/object/user_upload.go b/object/user_upload.go
new file mode 100644
index 00000000..91dca324
--- /dev/null
+++ b/object/user_upload.go
@@ -0,0 +1,114 @@
+// Copyright 2021 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+ "github.com/casbin/casdoor/util"
+ "github.com/casbin/casdoor/xlsx"
+)
+
+func getUserMap(owner string) map[string]*User {
+ m := map[string]*User{}
+
+ users := GetUsers(owner)
+ for _, user := range users {
+ m[user.GetId()] = user
+ }
+
+ return m
+}
+
+func parseLineItem(line *[]string, i int) string {
+ if i >= len(*line) {
+ return ""
+ } else {
+ return (*line)[i]
+ }
+}
+
+func parseLineItemInt(line *[]string, i int) int {
+ s := parseLineItem(line, i)
+ return util.ParseInt(s)
+}
+
+func parseLineItemBool(line *[]string, i int) bool {
+ return parseLineItemInt(line, i) != 0
+}
+
+func UploadUsers(owner string, fileId string) bool {
+ table := xlsx.ReadXlsxFile(fileId)
+
+ oldUserMap := getUserMap(owner)
+ newUsers := []*User{}
+ for _, line := range table {
+ if parseLineItem(&line, 0) == "" {
+ continue
+ }
+
+ user := &User{
+ Owner: parseLineItem(&line, 0),
+ Name: parseLineItem(&line, 1),
+ CreatedTime: parseLineItem(&line, 2),
+ UpdatedTime: parseLineItem(&line, 3),
+ Id: parseLineItem(&line, 4),
+ Type: parseLineItem(&line, 5),
+ Password: parseLineItem(&line, 6),
+ PasswordSalt: parseLineItem(&line, 7),
+ DisplayName: parseLineItem(&line, 8),
+ Avatar: parseLineItem(&line, 9),
+ PermanentAvatar: "",
+ Email: parseLineItem(&line, 10),
+ Phone: parseLineItem(&line, 11),
+ Location: parseLineItem(&line, 12),
+ Address: []string{parseLineItem(&line, 13)},
+ Affiliation: parseLineItem(&line, 14),
+ Title: parseLineItem(&line, 15),
+ IdCardType: parseLineItem(&line, 16),
+ IdCard: parseLineItem(&line, 17),
+ Homepage: parseLineItem(&line, 18),
+ Bio: parseLineItem(&line, 19),
+ Tag: parseLineItem(&line, 20),
+ Region: parseLineItem(&line, 21),
+ Language: parseLineItem(&line, 22),
+ Gender: parseLineItem(&line, 23),
+ Birthday: parseLineItem(&line, 24),
+ Education: parseLineItem(&line, 25),
+ Score: parseLineItemInt(&line, 26),
+ Ranking: parseLineItemInt(&line, 27),
+ IsDefaultAvatar: false,
+ IsOnline: parseLineItemBool(&line, 28),
+ IsAdmin: parseLineItemBool(&line, 29),
+ IsGlobalAdmin: parseLineItemBool(&line, 30),
+ IsForbidden: parseLineItemBool(&line, 31),
+ IsDeleted: parseLineItemBool(&line, 32),
+ SignupApplication: parseLineItem(&line, 33),
+ Hash: "",
+ PreHash: "",
+ CreatedIp: parseLineItem(&line, 34),
+ LastSigninTime: parseLineItem(&line, 35),
+ LastSigninIp: parseLineItem(&line, 36),
+ Properties: map[string]string{},
+ }
+
+ if _, ok := oldUserMap[user.GetId()]; !ok {
+ newUsers = append(newUsers, user)
+ }
+ }
+
+ if len(newUsers) == 0 {
+ return false
+ }
+ return AddUsersInBatch(newUsers)
+}
diff --git a/routers/router.go b/routers/router.go
index d0cf384b..598c51c0 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -68,6 +68,7 @@ func initAPI() {
beego.Router("/api/update-user", &controllers.ApiController{}, "POST:UpdateUser")
beego.Router("/api/add-user", &controllers.ApiController{}, "POST:AddUser")
beego.Router("/api/delete-user", &controllers.ApiController{}, "POST:DeleteUser")
+ beego.Router("/api/upload-users", &controllers.ApiController{}, "POST:UploadUsers")
beego.Router("/api/set-password", &controllers.ApiController{}, "POST:SetPassword")
beego.Router("/api/check-user-password", &controllers.ApiController{}, "POST:CheckUserPassword")
diff --git a/util/path.go b/util/path.go
index ce3f83d8..eead5bfd 100644
--- a/util/path.go
+++ b/util/path.go
@@ -18,6 +18,7 @@ import (
"fmt"
"net/url"
"os"
+ "path/filepath"
"strings"
)
@@ -28,6 +29,24 @@ func FileExist(path string) bool {
return true
}
+func GetPath(path string) string {
+ return filepath.Dir(path)
+}
+
+func EnsureFileFolderExists(path string) {
+ p := GetPath(path)
+ if !FileExist(p) {
+ err := os.MkdirAll(p, os.ModePerm)
+ if err != nil {
+ panic(err)
+ }
+ }
+}
+
+func RemoveExt(filename string) string {
+ return filename[:len(filename)-len(filepath.Ext(filename))]
+}
+
func UrlJoin(base string, path string) string {
res := fmt.Sprintf("%s/%s", strings.TrimRight(base, "/"), strings.TrimLeft(path, "/"))
return res
diff --git a/util/setting.go b/util/setting.go
new file mode 100644
index 00000000..844be1f4
--- /dev/null
+++ b/util/setting.go
@@ -0,0 +1,21 @@
+// Copyright 2021 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package util
+
+import "fmt"
+
+func GetUploadXlsxPath(fileId string) string {
+ return fmt.Sprintf("tmpFiles/%s.xlsx", fileId)
+}
diff --git a/web/src/UserListPage.js b/web/src/UserListPage.js
index 2967a08b..725f9097 100644
--- a/web/src/UserListPage.js
+++ b/web/src/UserListPage.js
@@ -14,7 +14,8 @@
import React from "react";
import {Link} from "react-router-dom";
-import {Button, Popconfirm, Switch, Table} from 'antd';
+import {Button, Popconfirm, Switch, Table, Upload} from 'antd';
+import {UploadOutlined} from "@ant-design/icons";
import moment from "moment";
import * as Setting from "./Setting";
import * as UserBackend from "./backend/UserBackend";
@@ -92,6 +93,43 @@ class UserListPage extends BaseListPage {
});
}
+ uploadFile(info) {
+ const { status, response: res } = info.file;
+ if (status === 'done') {
+ if (res.status === 'ok') {
+ Setting.showMessage("success", `Users uploaded successfully, refreshing the page`);
+
+ const { pagination } = this.state;
+ this.fetch({ pagination });
+ } else {
+ Setting.showMessage("error", `Users failed to upload: ${res.msg}`);
+ }
+ } else if (status === 'error') {
+ Setting.showMessage("error", `File failed to upload`);
+ }
+ }
+
+ renderUpload() {
+ const props = {
+ name: 'file',
+ accept: '.xlsx',
+ method: 'post',
+ action: `${Setting.ServerUrl}/api/upload-users`,
+ withCredentials: true,
+ onChange: (info) => {
+ this.uploadFile(info);
+ },
+ };
+
+ return (
+