浏览代码

chore: sync with gin-template

JustSong 2 年之前
父节点
当前提交
5913266969

+ 5 - 0
common/constants.go

@@ -27,6 +27,8 @@ var PasswordRegisterEnabled = true
 var EmailVerificationEnabled = false
 var GitHubOAuthEnabled = false
 var WeChatAuthEnabled = false
+var TurnstileCheckEnabled = false
+var RegisterEnabled = true
 
 var SMTPServer = ""
 var SMTPAccount = ""
@@ -39,6 +41,9 @@ var WeChatServerAddress = ""
 var WeChatServerToken = ""
 var WeChatAccountQRCodeImageURL = ""
 
+var TurnstileSiteKey = ""
+var TurnstileSecretKey = ""
+
 const (
 	RoleGuestUser  = 0
 	RoleCommonUser = 1

+ 1 - 3
common/email.go

@@ -1,8 +1,6 @@
 package common
 
-import (
-	"gopkg.in/gomail.v2"
-)
+import "gopkg.in/gomail.v2"
 
 func SendEmail(subject string, receiver string, content string) error {
 	m := gomail.NewMessage()

+ 1 - 1
common/embed-file-system.go

@@ -2,7 +2,7 @@ package common
 
 import (
 	"embed"
-	"github.com/gin-gonic/contrib/static"
+	"github.com/gin-contrib/static"
 	"io/fs"
 	"net/http"
 )

+ 3 - 3
common/init.go

@@ -12,9 +12,9 @@ var (
 	Port         = flag.Int("port", 3000, "the listening port")
 	PrintVersion = flag.Bool("version", false, "print version and exit")
 	LogDir       = flag.String("log-dir", "", "specify the log directory")
-	//Host         = flag.Key("host", "localhost", "the server's ip address or domain")
-	//Path         = flag.Key("path", "", "specify a local path to public")
-	//VideoPath    = flag.Key("video", "", "specify a video folder to public")
+	//Host         = flag.String("host", "localhost", "the server's ip address or domain")
+	//Path         = flag.String("path", "", "specify a local path to public")
+	//VideoPath    = flag.String("video", "", "specify a video folder to public")
 	//NoBrowser    = flag.Bool("no-browser", false, "open browser or not")
 )
 

+ 1 - 1
common/verification.go

@@ -60,7 +60,7 @@ func DeleteKey(key string, purpose string) {
 	delete(verificationMap, purpose+key)
 }
 
-// no lock inside!
+// no lock inside, so the caller must lock the verificationMap before calling!
 func removeExpiredPairs() {
 	now := time.Now()
 	for key := range verificationMap {

+ 0 - 131
controller/file.go

@@ -1,131 +0,0 @@
-package controller
-
-import (
-	"encoding/json"
-	"fmt"
-	"github.com/gin-gonic/gin"
-	"message-pusher/common"
-	"message-pusher/model"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-)
-
-type FileDeleteRequest struct {
-	Id   int
-	Link string
-	//Token string
-}
-
-func UploadFile(c *gin.Context) {
-	uploadPath := common.UploadPath
-	//saveToDatabase := true
-	//path := c.PostForm("path")
-	//if path != "" { // Upload to explorer's path
-	//	uploadPath = filepath.Join(common.ExplorerRootPath, path)
-	//	if !strings.HasPrefix(uploadPath, common.ExplorerRootPath) {
-	//		// In this case the given path is not valid, so we reset it to ExplorerRootPath.
-	//		uploadPath = common.ExplorerRootPath
-	//	}
-	//	saveToDatabase = false
-	//}
-
-	description := c.PostForm("description")
-	if description == "" {
-		description = "无描述信息"
-	}
-	uploader := c.GetString("username")
-	if uploader == "" {
-		uploader = "匿名用户"
-	}
-	currentTime := time.Now().Format("2006-01-02 15:04:05")
-	form, err := c.MultipartForm()
-	if err != nil {
-		c.String(http.StatusBadRequest, fmt.Sprintf("get form err: %s", err.Error()))
-		return
-	}
-	files := form.File["file"]
-	for _, file := range files {
-		// In case someone wants to upload to other folders.
-		filename := filepath.Base(file.Filename)
-		link := filename
-		savePath := filepath.Join(uploadPath, filename)
-		if _, err := os.Stat(savePath); err == nil {
-			// File already existed.
-			t := time.Now()
-			timestamp := t.Format("_2006-01-02_15-04-05")
-			ext := filepath.Ext(filename)
-			if ext == "" {
-				link += timestamp
-			} else {
-				link = filename[:len(filename)-len(ext)] + timestamp + ext
-			}
-			savePath = filepath.Join(uploadPath, link)
-		}
-		if err := c.SaveUploadedFile(file, savePath); err != nil {
-			c.String(http.StatusBadRequest, fmt.Sprintf("upload file err: %s", err.Error()))
-			return
-		}
-		// save to database
-		fileObj := &model.File{
-			Description: description,
-			Uploader:    uploader,
-			Time:        currentTime,
-			Link:        link,
-			Filename:    filename,
-		}
-		err = fileObj.Insert()
-		if err != nil {
-			_ = fmt.Errorf(err.Error())
-		}
-	}
-	c.Redirect(http.StatusSeeOther, "./")
-}
-
-func DeleteFile(c *gin.Context) {
-	var deleteRequest FileDeleteRequest
-	err := json.NewDecoder(c.Request.Body).Decode(&deleteRequest)
-	if err != nil {
-		c.JSON(http.StatusBadRequest, gin.H{
-			"success": false,
-			"message": "无效的参数",
-		})
-		return
-	}
-
-	fileObj := &model.File{
-		Id: deleteRequest.Id,
-	}
-	model.DB.Where("id = ?", deleteRequest.Id).First(&fileObj)
-	err = fileObj.Delete()
-	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": true,
-			"message": err.Error(),
-		})
-	} else {
-		message := "文件删除成功"
-		c.JSON(http.StatusOK, gin.H{
-			"success": true,
-			"message": message,
-		})
-	}
-
-}
-
-func DownloadFile(c *gin.Context) {
-	path := c.Param("file")
-	fullPath := filepath.Join(common.UploadPath, path)
-	if !strings.HasPrefix(fullPath, common.UploadPath) {
-		// We may being attacked!
-		c.Status(403)
-		return
-	}
-	c.File(fullPath)
-	// Update download counter
-	go func() {
-		model.UpdateDownloadCounter(path)
-	}()
-}

+ 15 - 7
controller/github.go

@@ -107,16 +107,24 @@ func GitHubOAuth(c *gin.Context) {
 	if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
 		user.FillUserByGitHubId()
 	} else {
-		user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
-		user.DisplayName = githubUser.Name
-		user.Email = githubUser.Email
-		user.Role = common.RoleCommonUser
-		user.Status = common.UserStatusEnabled
+		if common.RegisterEnabled {
+			user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
+			user.DisplayName = githubUser.Name
+			user.Email = githubUser.Email
+			user.Role = common.RoleCommonUser
+			user.Status = common.UserStatusEnabled
 
-		if err := user.Insert(); err != nil {
+			if err := user.Insert(); err != nil {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				return
+			}
+		} else {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
-				"message": err.Error(),
+				"message": "管理员关闭了新用户注册",
 			})
 			return
 		}

+ 2 - 0
controller/misc.go

@@ -24,6 +24,8 @@ func GetStatus(c *gin.Context) {
 			"wechat_qrcode":      common.WeChatAccountQRCodeImageURL,
 			"wechat_login":       common.WeChatAuthEnabled,
 			"server_address":     common.ServerAddress,
+			"turnstile_check":    common.TurnstileCheckEnabled,
+			"turnstile_site_key": common.TurnstileSiteKey,
 		},
 	})
 	return

+ 6 - 0
controller/option.go

@@ -52,6 +52,12 @@ func UpdateOption(c *gin.Context) {
 			"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
 		})
 		return
+	} else if option.Key == "TurnstileCheckEnabled" && option.Value == "true" && common.TurnstileSiteKey == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
+		})
+		return
 	}
 	err = model.UpdateOption(option.Key, option.Value)
 	if err != nil {

+ 27 - 13
controller/user.go

@@ -100,13 +100,20 @@ func Logout(c *gin.Context) {
 }
 
 func Register(c *gin.Context) {
-	if !common.PasswordRegisterEnabled {
+	if !common.RegisterEnabled {
 		c.JSON(http.StatusOK, gin.H{
 			"message": "管理员关闭了新用户注册",
 			"success": false,
 		})
 		return
 	}
+	if !common.PasswordRegisterEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册",
+			"success": false,
+		})
+		return
+	}
 	var user model.User
 	err := json.NewDecoder(c.Request.Body).Decode(&user)
 	if err != nil {
@@ -134,7 +141,7 @@ func Register(c *gin.Context) {
 		if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
-				"message": "验证码错误",
+				"message": "验证码错误或已过期",
 			})
 			return
 		}
@@ -154,7 +161,6 @@ func Register(c *gin.Context) {
 		})
 		return
 	}
-
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -163,7 +169,11 @@ func Register(c *gin.Context) {
 }
 
 func GetAllUsers(c *gin.Context) {
-	users, err := model.GetAllUsers()
+	p, _ := strconv.Atoi(c.Query("p"))
+	if p < 0 {
+		p = 0
+	}
+	users, err := model.GetAllUsers(p*common.ItemsPerPage, common.ItemsPerPage)
 	if err != nil {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
@@ -372,10 +382,6 @@ func UpdateSelf(c *gin.Context) {
 		Username:    user.Username,
 		Password:    user.Password,
 		DisplayName: user.DisplayName,
-		Token:       user.Token,
-	}
-	if cleanUser.Token == "" {
-		cleanUser.Token = " " // this is because gorm will ignore zero value
 	}
 	if user.Password == "$I_LOVE_U" {
 		user.Password = "" // rollback to what it should be
@@ -449,7 +455,6 @@ func DeleteSelf(c *gin.Context) {
 	return
 }
 
-// CreateUser Only admin user can call this, so we can trust it
 func CreateUser(c *gin.Context) {
 	var user model.User
 	err := json.NewDecoder(c.Request.Body).Decode(&user)
@@ -471,8 +476,13 @@ func CreateUser(c *gin.Context) {
 		})
 		return
 	}
-
-	if err := user.Insert(); err != nil {
+	// Even for admin users, we cannot fully trust them!
+	cleanUser := model.User{
+		Username:    user.Username,
+		Password:    user.Password,
+		DisplayName: user.DisplayName,
+	}
+	if err := cleanUser.Insert(); err != nil {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
 			"message": err.Error(),
@@ -557,10 +567,14 @@ func ManageUser(c *gin.Context) {
 		})
 		return
 	}
-
+	clearUser := model.User{
+		Role:   user.Role,
+		Status: user.Status,
+	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
+		"data":    clearUser,
 	})
 	return
 }
@@ -571,7 +585,7 @@ func EmailBind(c *gin.Context) {
 	if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
-			"message": "验证码错误",
+			"message": "验证码错误或已过期",
 		})
 		return
 	}

+ 14 - 6
controller/wechat.go

@@ -72,15 +72,23 @@ func WeChatAuth(c *gin.Context) {
 	if model.IsWeChatIdAlreadyTaken(wechatId) {
 		user.FillUserByWeChatId()
 	} else {
-		user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1)
-		user.DisplayName = "WeChat User"
-		user.Role = common.RoleCommonUser
-		user.Status = common.UserStatusEnabled
+		if common.RegisterEnabled {
+			user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1)
+			user.DisplayName = "WeChat User"
+			user.Role = common.RoleCommonUser
+			user.Status = common.UserStatusEnabled
 
-		if err := user.Insert(); err != nil {
+			if err := user.Insert(); err != nil {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				return
+			}
+		} else {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
-				"message": err.Error(),
+				"message": "管理员关闭了新用户注册",
 			})
 			return
 		}

+ 4 - 3
go.mod

@@ -4,12 +4,15 @@ module message-pusher
 go 1.18
 
 require (
+	github.com/gin-contrib/cors v1.4.0
+	github.com/gin-contrib/gzip v0.0.6
 	github.com/gin-contrib/sessions v0.0.5
-	github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19
+	github.com/gin-contrib/static v0.0.1
 	github.com/gin-gonic/gin v1.8.1
 	github.com/go-playground/validator/v10 v10.11.1
 	github.com/go-redis/redis/v8 v8.11.5
 	github.com/google/uuid v1.3.0
+	github.com/yuin/goldmark v1.5.3
 	golang.org/x/crypto v0.1.0
 	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
 	gorm.io/driver/mysql v1.4.3
@@ -39,9 +42,7 @@ require (
 	github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pelletier/go-toml/v2 v2.0.1 // indirect
-	github.com/stretchr/testify v1.8.0 // indirect
 	github.com/ugorji/go/codec v1.2.7 // indirect
-	github.com/yuin/goldmark v1.5.2 // indirect
 	golang.org/x/net v0.1.0 // indirect
 	golang.org/x/sys v0.1.0 // indirect
 	golang.org/x/text v0.4.0 // indirect

+ 28 - 4
go.sum

@@ -9,20 +9,29 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
+github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
+github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
+github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
 github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
 github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
 github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 h1:J2LPEOcQmWaooBnBtUDV9KHFEnP5LYTZY03GiQ0oQBw=
-github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg=
+github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
+github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
 github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
 github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
 github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
 github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
 github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
 github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
 github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
+github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
 github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
 github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
 github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
@@ -31,6 +40,7 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
 github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
 github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
@@ -51,6 +61,7 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
 github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
 github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -61,8 +72,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
 github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
 github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
@@ -70,6 +83,7 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJK
 github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
@@ -86,22 +100,28 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
 github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
 github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
 github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
-github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
-github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
+github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -110,6 +130,8 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
@@ -130,6 +152,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 2 - 10
main.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"embed"
+	"github.com/gin-contrib/gzip"
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-contrib/sessions/cookie"
 	"github.com/gin-contrib/sessions/redis"
@@ -54,6 +55,7 @@ func main() {
 
 	// Initialize HTTP server
 	server := gin.Default()
+	server.Use(gzip.Gzip(gzip.DefaultCompression))
 	server.Use(middleware.CORS())
 
 	// Initialize session store
@@ -71,16 +73,6 @@ func main() {
 	if port == "" {
 		port = strconv.Itoa(*common.Port)
 	}
-	//if *common.Host == "localhost" {
-	//	ip := common.GetIp()
-	//	if ip != "" {
-	//		*common.Host = ip
-	//	}
-	//}
-	//serverUrl := "http://" + *common.Host + ":" + port + "/"
-	//if !*common.NoBrowser {
-	//	common.OpenBrowser(serverUrl)
-	//}
 	err = server.Run(":" + port)
 	if err != nil {
 		log.Println(err)

+ 62 - 7
middleware/auth.go

@@ -4,6 +4,7 @@ import (
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-gonic/gin"
 	"message-pusher/common"
+	"message-pusher/model"
 	"net/http"
 )
 
@@ -13,13 +14,34 @@ func authHelper(c *gin.Context, minRole int) {
 	role := session.Get("role")
 	id := session.Get("id")
 	status := session.Get("status")
+	authByToken := false
 	if username == nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": "无权进行此操作,用户未登录",
-		})
-		c.Abort()
-		return
+		// Check token
+		token := c.Request.Header.Get("Authorization")
+		if token == "" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无权进行此操作,未登录或 token 无效",
+			})
+			c.Abort()
+			return
+		}
+		user := model.ValidateUserToken(token)
+		if user != nil && user.Username != "" {
+			// Token is valid
+			username = user.Username
+			role = user.Role
+			id = user.Id
+			status = user.Status
+		} else {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无权进行此操作,token 无效",
+			})
+			c.Abort()
+			return
+		}
+		authByToken = true
 	}
 	if status.(int) == common.UserStatusDisabled {
 		c.JSON(http.StatusOK, gin.H{
@@ -32,7 +54,7 @@ func authHelper(c *gin.Context, minRole int) {
 	if role.(int) < minRole {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
-			"message": "无权进行此操作,用户未登录或没有权限",
+			"message": "无权进行此操作,权限不足",
 		})
 		c.Abort()
 		return
@@ -40,6 +62,7 @@ func authHelper(c *gin.Context, minRole int) {
 	c.Set("username", username)
 	c.Set("role", role)
 	c.Set("id", id)
+	c.Set("authByToken", authByToken)
 	c.Next()
 }
 
@@ -60,3 +83,35 @@ func RootAuth() func(c *gin.Context) {
 		authHelper(c, common.RoleRootUser)
 	}
 }
+
+// NoTokenAuth You should always use this after normal auth middlewares.
+func NoTokenAuth() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		authByToken := c.GetBool("authByToken")
+		if authByToken {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "本接口不支持使用 token 进行验证",
+			})
+			c.Abort()
+			return
+		}
+		c.Next()
+	}
+}
+
+// TokenOnlyAuth You should always use this after normal auth middlewares.
+func TokenOnlyAuth() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		authByToken := c.GetBool("authByToken")
+		if !authByToken {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "本接口仅支持使用 token 进行验证",
+			})
+			c.Abort()
+			return
+		}
+		c.Next()
+	}
+}

+ 12 - 0
middleware/cache.go

@@ -0,0 +1,12 @@
+package middleware
+
+import (
+	"github.com/gin-gonic/gin"
+)
+
+func Cache() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		c.Header("Cache-Control", "max-age=604800") // one week
+		c.Next()
+	}
+}

+ 2 - 10
middleware/cors.go

@@ -1,20 +1,12 @@
 package middleware
 
 import (
-	"github.com/gin-gonic/contrib/cors"
+	"github.com/gin-contrib/cors"
 	"github.com/gin-gonic/gin"
-	"time"
 )
 
 func CORS() gin.HandlerFunc {
 	config := cors.DefaultConfig()
-	config.AllowedHeaders = []string{"Authorization", "Content-Type", "Origin",
-		"Connection", "Accept-Encoding", "Accept-Language", "Host"}
-	config.AllowedMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}
-	config.AllowCredentials = true
-	config.MaxAge = 12 * time.Hour
-	// if you want to allow all origins, comment the following two lines
-	config.AllowAllOrigins = false
-	config.AllowedOrigins = []string{"https://message-pusher.vercel.app"}
+	config.AllowOrigins = []string{"https://gin-template.vercel.app", "http://localhost:3000/"}
 	return cors.New(config)
 }

+ 80 - 0
middleware/turnstile-check.go

@@ -0,0 +1,80 @@
+package middleware
+
+import (
+	"encoding/json"
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+	"message-pusher/common"
+	"net/http"
+	"net/url"
+)
+
+type turnstileCheckResponse struct {
+	Success bool `json:"success"`
+}
+
+func TurnstileCheck() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		if common.TurnstileCheckEnabled {
+			session := sessions.Default(c)
+			turnstileChecked := session.Get("turnstile")
+			if turnstileChecked != nil {
+				c.Next()
+				return
+			}
+			response := c.Query("turnstile")
+			if response == "" {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": "Turnstile token 为空",
+				})
+				c.Abort()
+				return
+			}
+			rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
+				"secret":   {common.TurnstileSecretKey},
+				"response": {response},
+				"remoteip": {c.ClientIP()},
+			})
+			if err != nil {
+				common.SysError(err.Error())
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				c.Abort()
+				return
+			}
+			defer rawRes.Body.Close()
+			var res turnstileCheckResponse
+			err = json.NewDecoder(rawRes.Body).Decode(&res)
+			if err != nil {
+				common.SysError(err.Error())
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				c.Abort()
+				return
+			}
+			if !res.Success {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": "Turnstile 校验失败,请刷新重试!",
+				})
+				c.Abort()
+				return
+			}
+			session.Set("turnstile", true)
+			err = session.Save()
+			if err != nil {
+				c.JSON(http.StatusOK, gin.H{
+					"message": "无法保存会话信息,请重试",
+					"success": false,
+				})
+				return
+			}
+		}
+		c.Next()
+	}
+}

+ 0 - 53
model/file.go

@@ -1,53 +0,0 @@
-package model
-
-import (
-	_ "gorm.io/driver/sqlite"
-	"gorm.io/gorm"
-	"message-pusher/common"
-	"os"
-	"path"
-	"strings"
-)
-
-type File struct {
-	Id              int    `json:"id"`
-	Filename        string `json:"filename"`
-	Description     string `json:"description"`
-	Uploader        string `json:"uploader"`
-	Link            string `json:"link" gorm:"unique"`
-	Time            string `json:"time"`
-	DownloadCounter int    `json:"download_counter"`
-}
-
-func GetAllFiles() ([]*File, error) {
-	var files []*File
-	var err error
-	err = DB.Find(&files).Error
-	return files, err
-}
-
-func QueryFiles(query string, startIdx int) ([]*File, error) {
-	var files []*File
-	var err error
-	query = strings.ToLower(query)
-	err = DB.Limit(common.ItemsPerPage).Offset(startIdx).Where("filename LIKE ? or description LIKE ? or uploader LIKE ? or time LIKE ?", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%").Order("id desc").Find(&files).Error
-	return files, err
-}
-
-func (file *File) Insert() error {
-	var err error
-	err = DB.Create(file).Error
-	return err
-}
-
-// Delete Make sure link is valid! Because we will use os.Remove to delete it!
-func (file *File) Delete() error {
-	var err error
-	err = DB.Delete(file).Error
-	err = os.Remove(path.Join(common.UploadPath, file.Link))
-	return err
-}
-
-func UpdateDownloadCounter(link string) {
-	DB.Model(&File{}).Where("link = ?", link).UpdateColumn("download_counter", gorm.Expr("download_counter + 1"))
-}

+ 2 - 5
model/main.go

@@ -14,6 +14,7 @@ func createRootAccountIfNeed() error {
 	var user User
 	//if user.Status != common.UserStatusEnabled {
 	if err := DB.First(&user).Error; err != nil {
+		common.SysLog("no user exists, create a root user for you: username is root, password is 123456")
 		hashedPassword, err := common.Password2Hash("123456")
 		if err != nil {
 			return err
@@ -51,11 +52,7 @@ func InitDB() (err error) {
 	}
 	if err == nil {
 		DB = db
-		err := db.AutoMigrate(&File{})
-		if err != nil {
-			return err
-		}
-		err = db.AutoMigrate(&User{})
+		err := db.AutoMigrate(&User{})
 		if err != nil {
 			return err
 		}

+ 27 - 11
model/option.go

@@ -31,6 +31,8 @@ func InitOptionMap() {
 	common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
 	common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
 	common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
+	common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
+	common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
 	common.OptionMap["SMTPServer"] = ""
 	common.OptionMap["SMTPAccount"] = ""
 	common.OptionMap["SMTPToken"] = ""
@@ -43,6 +45,8 @@ func InitOptionMap() {
 	common.OptionMap["WeChatServerAddress"] = ""
 	common.OptionMap["WeChatServerToken"] = ""
 	common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
+	common.OptionMap["TurnstileSiteKey"] = ""
+	common.OptionMap["TurnstileSecretKey"] = ""
 	common.OptionMapRWMutex.Unlock()
 	options, _ := AllOption()
 	for _, option := range options {
@@ -87,16 +91,26 @@ func updateOptionMap(key string, value string) {
 			common.ImageDownloadPermission = intValue
 		}
 	}
-	boolValue := value == "true"
+	if strings.HasSuffix(key, "Enabled") {
+		boolValue := value == "true"
+		switch key {
+		case "PasswordRegisterEnabled":
+			common.PasswordRegisterEnabled = boolValue
+		case "PasswordLoginEnabled":
+			common.PasswordLoginEnabled = boolValue
+		case "EmailVerificationEnabled":
+			common.EmailVerificationEnabled = boolValue
+		case "GitHubOAuthEnabled":
+			common.GitHubOAuthEnabled = boolValue
+		case "WeChatAuthEnabled":
+			common.WeChatAuthEnabled = boolValue
+		case "TurnstileCheckEnabled":
+			common.TurnstileCheckEnabled = boolValue
+		case "RegisterEnabled":
+			common.RegisterEnabled = boolValue
+		}
+	}
 	switch key {
-	case "PasswordRegisterEnabled":
-		common.PasswordRegisterEnabled = boolValue
-	case "PasswordLoginEnabled":
-		common.PasswordLoginEnabled = boolValue
-	case "EmailVerificationEnabled":
-		common.EmailVerificationEnabled = boolValue
-	case "GitHubOAuthEnabled":
-		common.GitHubOAuthEnabled = boolValue
 	case "SMTPServer":
 		common.SMTPServer = value
 	case "SMTPAccount":
@@ -117,7 +131,9 @@ func updateOptionMap(key string, value string) {
 		common.WeChatServerToken = value
 	case "WeChatAccountQRCodeImageURL":
 		common.WeChatAccountQRCodeImageURL = value
-	case "WeChatAuthEnabled":
-		common.WeChatAuthEnabled = boolValue
+	case "TurnstileSiteKey":
+		common.TurnstileSiteKey = value
+	case "TurnstileSecretKey":
+		common.TurnstileSecretKey = value
 	}
 }

+ 24 - 6
model/user.go

@@ -3,8 +3,11 @@ package model
 import (
 	"errors"
 	"message-pusher/common"
+	"strings"
 )
 
+// User if you add sensitive fields, don't forget to clean them in setupLogin function.
+// Otherwise, the sensitive information will be saved on local storage in plain text!
 type User struct {
 	Id                                 int    `json:"id"`
 	Username                           string `json:"username" gorm:"unique;index" validate:"max=12"`
@@ -12,12 +15,12 @@ type User struct {
 	DisplayName                        string `json:"display_name" gorm:"index" validate:"max=20"`
 	Role                               int    `json:"role" gorm:"type:int;default:1"`   // admin, common
 	Status                             int    `json:"status" gorm:"type:int;default:1"` // enabled, disabled
-	Token                              string `json:"token"`
+	Token                              string `json:"token" gorm:"index"`
 	Email                              string `json:"email" gorm:"index" validate:"max=50"`
 	GitHubId                           string `json:"github_id" gorm:"column:github_id;index"`
 	WeChatId                           string `json:"wechat_id" gorm:"column:wechat_id;index"`
+	VerificationCode                   string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
 	Channel                            string `json:"channel"`
-	VerificationCode                   string `json:"verification_code" gorm:"-:all"`
 	WeChatTestAccountId                string `json:"wechat_test_account_id" gorm:"column:wechat_test_account_id"`
 	WeChatTestAccountSecret            string `json:"wechat_test_account_secret" gorm:"column:wechat_test_account_secret"`
 	WeChatTestAccountTemplateId        string `json:"wechat_test_account_template_id" gorm:"column:wechat_test_account_template_id"`
@@ -40,8 +43,8 @@ func GetMaxUserId() int {
 	return user.Id
 }
 
-func GetAllUsers() (users []*User, err error) {
-	err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email"}).Find(&users).Error
+func GetAllUsers(startIdx int, num int) (users []*User, err error) {
+	err = DB.Order("id desc").Limit(num).Offset(startIdx).Select([]string{"id", "username", "display_name", "role", "status", "email"}).Find(&users).Error
 	return users, err
 }
 
@@ -61,7 +64,7 @@ func GetUserById(id int, selectAll bool) (*User, error) {
 	if selectAll {
 		err = DB.First(&user, "id = ?", id).Error
 	} else {
-		err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email", "wechat_id", "github_id", "token"}).First(&user, "id = ?", id).Error
+		err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email", "wechat_id", "github_id"}).First(&user, "id = ?", id).Error
 	}
 	return &user, err
 }
@@ -108,10 +111,13 @@ func (user *User) ValidateAndFill() (err error) {
 	// that means if your field’s value is 0, '', false or other zero values,
 	// it won’t be used to build query conditions
 	password := user.Password
+	if password == "" {
+		return errors.New("密码为空")
+	}
 	DB.Where(User{Username: user.Username}).First(user)
 	okay := common.ValidatePasswordAndHash(password, user.Password)
 	if !okay || user.Status != common.UserStatusEnabled {
-		return errors.New("用户名或密码错误,或者该用户已被封禁")
+		return errors.New("用户名或密码错误,或用户已被封禁")
 	}
 	return nil
 }
@@ -136,6 +142,18 @@ func (user *User) FillUserByUsername() {
 	DB.Where(User{Username: user.Username}).First(user)
 }
 
+func ValidateUserToken(token string) (user *User) {
+	if token == "" {
+		return nil
+	}
+	token = strings.Replace(token, "Bearer ", "", 1)
+	user = &User{}
+	if DB.Where("token = ?", token).First(user).RowsAffected == 1 {
+		return user
+	}
+	return nil
+}
+
 func IsEmailAlreadyTaken(email string) bool {
 	return DB.Where("email = ?", email).Find(&User{}).RowsAffected == 1
 }

+ 6 - 12
router/api-router.go

@@ -13,8 +13,8 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/status", controller.GetStatus)
 		apiRouter.GET("/notice", controller.GetNotice)
 		apiRouter.GET("/about", controller.GetAbout)
-		apiRouter.GET("/verification", middleware.CriticalRateLimit(), controller.SendEmailVerification)
-		apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), controller.SendPasswordResetEmail)
+		apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
+		apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
 		apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
 		apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
 		apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
@@ -23,12 +23,12 @@ func SetApiRouter(router *gin.Engine) {
 
 		userRoute := apiRouter.Group("/user")
 		{
-			userRoute.POST("/register", middleware.CriticalRateLimit(), controller.Register)
+			userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
 			userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login)
 			userRoute.GET("/logout", controller.Logout)
 
 			selfRoute := userRoute.Group("/")
-			selfRoute.Use(middleware.UserAuth())
+			selfRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
 			{
 				selfRoute.GET("/self", controller.GetSelf)
 				selfRoute.PUT("/self", controller.UpdateSelf)
@@ -37,7 +37,7 @@ func SetApiRouter(router *gin.Engine) {
 			}
 
 			adminRoute := userRoute.Group("/")
-			adminRoute.Use(middleware.AdminAuth())
+			adminRoute.Use(middleware.AdminAuth(), middleware.NoTokenAuth())
 			{
 				adminRoute.GET("/", controller.GetAllUsers)
 				adminRoute.GET("/search", controller.SearchUsers)
@@ -49,17 +49,11 @@ func SetApiRouter(router *gin.Engine) {
 			}
 		}
 		optionRoute := apiRouter.Group("/option")
-		optionRoute.Use(middleware.RootAuth())
+		optionRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
 		{
 			optionRoute.GET("/", controller.GetOptions)
 			optionRoute.PUT("/", controller.UpdateOption)
 		}
-		fileRoute := apiRouter.Group("/file")
-		{
-			fileRoute.GET("/:id", middleware.DownloadRateLimit(), controller.DownloadFile)
-			fileRoute.POST("/", middleware.UserAuth(), middleware.UploadRateLimit(), controller.UploadFile)
-			fileRoute.DELETE("/:id", middleware.UserAuth(), controller.DeleteFile)
-		}
 	}
 	pushRouter := router.Group("/push")
 	pushRouter.Use(middleware.GlobalAPIRateLimit())

+ 2 - 1
router/web-router.go

@@ -2,7 +2,7 @@ package router
 
 import (
 	"embed"
-	"github.com/gin-gonic/contrib/static"
+	"github.com/gin-contrib/static"
 	"github.com/gin-gonic/gin"
 	"message-pusher/common"
 	"message-pusher/middleware"
@@ -11,6 +11,7 @@ import (
 
 func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
 	router.Use(middleware.GlobalWebRateLimit())
+	router.Use(middleware.Cache())
 	router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
 	router.NoRoute(func(c *gin.Context) {
 		c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)

+ 1 - 0
web/package.json

@@ -11,6 +11,7 @@
     "react-router-dom": "^6.3.0",
     "react-scripts": "5.0.1",
     "react-toastify": "^9.0.8",
+    "react-turnstile": "^1.0.5",
     "semantic-ui-css": "^2.5.0",
     "semantic-ui-react": "^2.1.3"
   },

+ 33 - 15
web/src/App.js

@@ -1,4 +1,4 @@
-import React, { lazy, Suspense, useEffect } from 'react';
+import React, { lazy, Suspense, useContext, useEffect } from 'react';
 import { Route, Routes } from 'react-router-dom';
 import Loading from './components/Loading';
 import User from './pages/User';
@@ -9,34 +9,52 @@ import NotFound from './pages/NotFound';
 import Setting from './pages/Setting';
 import EditUser from './pages/User/EditUser';
 import AddUser from './pages/User/AddUser';
-import { API, showError } from './helpers';
+import { API, showError, showNotice } from './helpers';
 import PasswordResetForm from './components/PasswordResetForm';
 import GitHubOAuth from './components/GitHubOAuth';
 import PasswordResetConfirm from './components/PasswordResetConfirm';
+import { UserContext } from './context/User';
 
 const Home = lazy(() => import('./pages/Home'));
 const About = lazy(() => import('./pages/About'));
 
 function App() {
+  const [userState, userDispatch] = useContext(UserContext);
+
+  const loadUser = () => {
+    let user = localStorage.getItem('user');
+    if (user) {
+      let data = JSON.parse(user);
+      userDispatch({ type: 'login', payload: data });
+    }
+  };
   const loadStatus = async () => {
     const res = await API.get('/api/status');
     const { success, data } = res.data;
     if (success) {
       localStorage.setItem('status', JSON.stringify(data));
       localStorage.setItem('footer_html', data.footer_html);
+      let currentVersion = localStorage.getItem('version');
+      if (currentVersion && currentVersion !== data.version) {
+        localStorage.setItem('version', data.version);
+        showNotice(
+          `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
+        );
+      }
     } else {
       showError('无法正常连接至服务器!');
     }
   };
 
   useEffect(() => {
+    loadUser();
     loadStatus().then();
   }, []);
 
   return (
     <Routes>
       <Route
-        path="/"
+        path='/'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <Home />
@@ -44,7 +62,7 @@ function App() {
         }
       />
       <Route
-        path="/user"
+        path='/user'
         element={
           <PrivateRoute>
             <User />
@@ -52,7 +70,7 @@ function App() {
         }
       />
       <Route
-        path="/user/edit/:id"
+        path='/user/edit/:id'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <EditUser />
@@ -60,7 +78,7 @@ function App() {
         }
       />
       <Route
-        path="/user/edit"
+        path='/user/edit'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <EditUser />
@@ -68,7 +86,7 @@ function App() {
         }
       />
       <Route
-        path="/user/add"
+        path='/user/add'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <AddUser />
@@ -76,7 +94,7 @@ function App() {
         }
       />
       <Route
-        path="/user/reset"
+        path='/user/reset'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <PasswordResetConfirm />
@@ -84,7 +102,7 @@ function App() {
         }
       />
       <Route
-        path="/login"
+        path='/login'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <LoginForm />
@@ -92,7 +110,7 @@ function App() {
         }
       />
       <Route
-        path="/register"
+        path='/register'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <RegisterForm />
@@ -100,7 +118,7 @@ function App() {
         }
       />
       <Route
-        path="/reset"
+        path='/reset'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <PasswordResetForm />
@@ -108,7 +126,7 @@ function App() {
         }
       />
       <Route
-        path="/oauth/github"
+        path='/oauth/github'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <GitHubOAuth />
@@ -116,7 +134,7 @@ function App() {
         }
       />
       <Route
-        path="/setting"
+        path='/setting'
         element={
           <PrivateRoute>
             <Suspense fallback={<Loading></Loading>}>
@@ -126,14 +144,14 @@ function App() {
         }
       />
       <Route
-        path="/about"
+        path='/about'
         element={
           <Suspense fallback={<Loading></Loading>}>
             <About />
           </Suspense>
         }
       />
-      <Route path="*" element={NotFound} />
+      <Route path='*' element={NotFound} />
     </Routes>
   );
 }

+ 7 - 7
web/src/components/Footer.js

@@ -12,27 +12,27 @@ const Footer = () => {
 
   return (
     <Segment vertical>
-      <Container textAlign="center">
+      <Container textAlign='center'>
         {Footer === '' ? (
-          <div className="custom-footer">
+          <div className='custom-footer'>
             <a
-              href="https://github.com/songquanpeng/message-pusher"
-              target="_blank"
+              href='https://github.com/songquanpeng/message-pusher'
+              target='_blank'
             >
               消息推送服务 {process.env.REACT_APP_VERSION}{' '}
             </a>
             由{' '}
-            <a href="https://github.com/songquanpeng" target="_blank">
+            <a href='https://github.com/songquanpeng' target='_blank'>
               JustSong
             </a>{' '}
             构建,源代码遵循{' '}
-            <a href="https://opensource.org/licenses/mit-license.php">
+            <a href='https://opensource.org/licenses/mit-license.php'>
               MIT 协议
             </a>
           </div>
         ) : (
           <div
-            className="custom-footer"
+            className='custom-footer'
             dangerouslySetInnerHTML={{ __html: Footer }}
           ></div>
         )}

+ 22 - 19
web/src/components/Header.js

@@ -2,7 +2,7 @@ import React, { useContext, useState } from 'react';
 import { Link, useNavigate } from 'react-router-dom';
 import { UserContext } from '../context/User';
 
-import { Button, Container, Icon, Menu, Segment } from 'semantic-ui-react';
+import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
 import { API, isAdmin, isMobile, showSuccess } from '../helpers';
 import '../index.css';
 
@@ -34,7 +34,6 @@ const headerButtons = [
 const Header = () => {
   const [userState, userDispatch] = useContext(UserContext);
   let navigate = useNavigate();
-  let size = isMobile() ? 'large' : '';
 
   const [showSidebar, setShowSidebar] = useState(false);
 
@@ -80,7 +79,7 @@ const Header = () => {
       <>
         <Menu
           borderless
-          size={size}
+          size='large'
           style={
             showSidebar
               ? {
@@ -93,17 +92,17 @@ const Header = () => {
           }
         >
           <Container>
-            <Menu.Item as={Link} to="/">
+            <Menu.Item as={Link} to='/'>
               <img
-                src="/logo.png"
-                alt="logo"
+                src='/logo.png'
+                alt='logo'
                 style={{ marginRight: '0.75em' }}
               />
               <div style={{ fontSize: '20px' }}>
                 <b>消息推送服务</b>
               </div>
             </Menu.Item>
-            <Menu.Menu position="right">
+            <Menu.Menu position='right'>
               <Menu.Item onClick={toggleSidebar}>
                 <Icon name={showSidebar ? 'close' : 'sidebar'} />
               </Menu.Item>
@@ -149,28 +148,32 @@ const Header = () => {
 
   return (
     <>
-      <Menu borderless size={size} style={{ borderTop: 'none' }}>
+      <Menu borderless style={{ borderTop: 'none' }}>
         <Container>
-          <Menu.Item as={Link} to="/" className={'hide-on-mobile'}>
-            <img src="/logo.png" alt="logo" style={{ marginRight: '0.75em' }} />
+          <Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
+            <img src='/logo.png' alt='logo' style={{ marginRight: '0.75em' }} />
             <div style={{ fontSize: '20px' }}>
               <b>消息推送服务</b>
             </div>
           </Menu.Item>
           {renderButtons(false)}
-          <Menu.Menu position="right">
+          <Menu.Menu position='right'>
             {userState.user ? (
-              <Menu.Item
-                name="注销"
-                onClick={logout}
-                className="btn btn-link"
-              />
+              <Dropdown
+                text={userState.user.username}
+                pointing
+                className='link item'
+              >
+                <Dropdown.Menu>
+                  <Dropdown.Item onClick={logout}>注销</Dropdown.Item>
+                </Dropdown.Menu>
+              </Dropdown>
             ) : (
               <Menu.Item
-                name="登录"
+                name='登录'
                 as={Link}
-                to="/login"
-                className="btn btn-link"
+                to='/login'
+                className='btn btn-link'
               />
             )}
           </Menu.Menu>

+ 2 - 2
web/src/components/LoginForm.js

@@ -114,7 +114,7 @@ const LoginForm = () => {
               value={password}
               onChange={handleChange}
             />
-            <Button color='teal' fluid size='large' onClick={handleSubmit}>
+            <Button color='telegram' fluid size='large' onClick={handleSubmit}>
               登录
             </Button>
           </Segment>
@@ -179,7 +179,7 @@ const LoginForm = () => {
                   onChange={handleChange}
                 />
                 <Button
-                  color='teal'
+                  color='telegram'
                   fluid
                   size='large'
                   onClick={onSubmitWeChatVerificationCode}

+ 10 - 11
web/src/components/OtherSetting.js

@@ -1,5 +1,5 @@
 import React, { useEffect, useState } from 'react';
-import { Button, Form, Grid, Modal } from 'semantic-ui-react';
+import { Button, Divider, Form, Grid, Header, Modal } from 'semantic-ui-react';
 import { API, showError, showSuccess } from '../helpers';
 import { marked } from 'marked';
 
@@ -7,7 +7,7 @@ const OtherSetting = () => {
   let [inputs, setInputs] = useState({
     Footer: '',
     Notice: '',
-    About: ''
+    About: '',
   });
   let originInputs = {};
   let [loading, setLoading] = useState(false);
@@ -42,7 +42,7 @@ const OtherSetting = () => {
     setLoading(true);
     const res = await API.put('/api/option', {
       key,
-      value
+      value,
     });
     const { success, message } = res.data;
     if (success) {
@@ -76,7 +76,7 @@ const OtherSetting = () => {
 
   const checkUpdate = async () => {
     const res = await API.get(
-      'https://api.github.com/repos/songquanpeng/message-pusher/releases/latest'
+      'https://api.github.com/repos/songquanpeng/gin-template/releases/latest'
     );
     const { tag_name, body } = res.data;
     if (tag_name === process.env.REACT_APP_VERSION) {
@@ -94,6 +94,7 @@ const OtherSetting = () => {
     <Grid columns={1}>
       <Grid.Column>
         <Form loading={loading}>
+          <Header as='h3'>通用设置</Header>
           <Form.Button onClick={checkUpdate}>检查更新</Form.Button>
           <Form.Group widths='equal'>
             <Form.TextArea
@@ -106,6 +107,8 @@ const OtherSetting = () => {
             />
           </Form.Group>
           <Form.Button onClick={submitNotice}>保存公告</Form.Button>
+          <Divider />
+          <Header as='h3'>个性化设置</Header>
           <Form.Group widths='equal'>
             <Form.TextArea
               label='关于'
@@ -126,9 +129,7 @@ const OtherSetting = () => {
               onChange={handleInputChange}
             />
           </Form.Group>
-          <Form.Button onClick={submitFooter}>
-            设置页脚
-          </Form.Button>
+          <Form.Button onClick={submitFooter}>设置页脚</Form.Button>
         </Form>
       </Grid.Column>
       <Modal
@@ -139,15 +140,13 @@ const OtherSetting = () => {
         <Modal.Header>新版本:{updateData.tag_name}</Modal.Header>
         <Modal.Content>
           <Modal.Description>
-            <div
-              dangerouslySetInnerHTML={{ __html: updateData.content }}
-            ></div>
+            <div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>
           </Modal.Description>
         </Modal.Content>
         <Modal.Actions>
           <Button onClick={() => setShowUpdateModal(false)}>关闭</Button>
           <Button
-            content="详情"
+            content='详情'
             onClick={() => {
               setShowUpdateModal(false);
               openGitHubRelease();

+ 2 - 2
web/src/components/PasswordResetConfirm.js

@@ -33,7 +33,7 @@ const PasswordResetConfirm = () => {
     if (success) {
       let password = res.data.data;
       await copy(password);
-      showSuccess(`密码已重置并已复制到剪板:${password}`);
+      showSuccess(`密码已重置并已复制到剪板:${password}`);
     } else {
       showError(message);
     }
@@ -58,7 +58,7 @@ const PasswordResetConfirm = () => {
               readOnly
             />
             <Button
-              color='teal'
+              color='telegram'
               fluid
               size='large'
               onClick={handleSubmit}

+ 35 - 4
web/src/components/PasswordResetForm.js

@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
-import { API, showError, showSuccess } from '../helpers';
+import { API, showError, showInfo, showSuccess } from '../helpers';
+import Turnstile from 'react-turnstile';
 
 const PasswordResetForm = () => {
   const [inputs, setInputs] = useState({
@@ -9,6 +10,20 @@ const PasswordResetForm = () => {
   const { email } = inputs;
 
   const [loading, setLoading] = useState(false);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+
+  useEffect(() => {
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
+    }
+  }, []);
 
   function handleChange(e) {
     const { name, value } = e.target;
@@ -17,8 +32,14 @@ const PasswordResetForm = () => {
 
   async function handleSubmit(e) {
     if (!email) return;
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
     setLoading(true);
-    const res = await API.get(`/api/reset_password?email=${email}`);
+    const res = await API.get(
+      `/api/reset_password?email=${email}&turnstile=${turnstileToken}`
+    );
     const { success, message } = res.data;
     if (success) {
       showSuccess('重置邮件发送成功,请检查邮箱!');
@@ -46,8 +67,18 @@ const PasswordResetForm = () => {
               value={email}
               onChange={handleChange}
             />
+            {turnstileEnabled ? (
+              <Turnstile
+                sitekey={turnstileSiteKey}
+                onVerify={(token) => {
+                  setTurnstileToken(token);
+                }}
+              />
+            ) : (
+              <></>
+            )}
             <Button
-              color='teal'
+              color='telegram'
               fluid
               size='large'
               onClick={handleSubmit}

+ 61 - 8
web/src/components/PersonalSetting.js

@@ -1,7 +1,8 @@
 import React, { useEffect, useState } from 'react';
-import { Button, Form, Image, Modal } from 'semantic-ui-react';
+import { Button, Divider, Form, Header, Image, Modal } from 'semantic-ui-react';
 import { Link } from 'react-router-dom';
-import { API, showError, showSuccess } from '../helpers';
+import { API, copy, showError, showInfo, showSuccess } from '../helpers';
+import Turnstile from 'react-turnstile';
 
 const PersonalSetting = () => {
   const [inputs, setInputs] = useState({
@@ -12,12 +13,20 @@ const PersonalSetting = () => {
   const [status, setStatus] = useState({});
   const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
   const [showEmailBindModal, setShowEmailBindModal] = useState(false);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  const [loading, setLoading] = useState(false);
 
   useEffect(() => {
     let status = localStorage.getItem('status');
     if (status) {
       status = JSON.parse(status);
       setStatus(status);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
     }
   }, []);
 
@@ -25,6 +34,17 @@ const PersonalSetting = () => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
 
+  const generateToken = async () => {
+    const res = await API.get('/api/user/token');
+    const { success, message, data } = res.data;
+    if (success) {
+      await copy(data);
+      showSuccess(`令牌已重置并已复制到剪贴板:${data}`);
+    } else {
+      showError(message);
+    }
+  };
+
   const bindWeChat = async () => {
     if (inputs.wechat_verification_code === '') return;
     const res = await API.get(
@@ -47,17 +67,26 @@ const PersonalSetting = () => {
 
   const sendVerificationCode = async () => {
     if (inputs.email === '') return;
-    const res = await API.get(`/api/verification?email=${inputs.email}`);
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setLoading(true);
+    const res = await API.get(
+      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
+    );
     const { success, message } = res.data;
     if (success) {
-      showSuccess('验证码发送成功,请检查你的邮箱!');
+      showSuccess('验证码发送成功,请检查邮箱!');
     } else {
       showError(message);
     }
+    setLoading(false);
   };
 
   const bindEmail = async () => {
     if (inputs.email_verification_code === '') return;
+    setLoading(true);
     const res = await API.get(
       `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
     );
@@ -68,13 +97,18 @@ const PersonalSetting = () => {
     } else {
       showError(message);
     }
+    setLoading(false);
   };
 
   return (
     <div style={{ lineHeight: '40px' }}>
+      <Header as='h3'>通用设置</Header>
       <Button as={Link} to={`/user/edit/`}>
         更新个人信息
       </Button>
+      <Button onClick={generateToken}>生成访问令牌</Button>
+      <Divider />
+      <Header as='h3'>账号绑定</Header>
       <Button
         onClick={() => {
           setShowWeChatBindModal(true);
@@ -104,7 +138,7 @@ const PersonalSetting = () => {
                 value={inputs.wechat_verification_code}
                 onChange={handleInputChange}
               />
-              <Button color='teal' fluid size='large' onClick={bindWeChat}>
+              <Button color='telegram' fluid size='large' onClick={bindWeChat}>
                 绑定
               </Button>
             </Form>
@@ -123,7 +157,8 @@ const PersonalSetting = () => {
         onClose={() => setShowEmailBindModal(false)}
         onOpen={() => setShowEmailBindModal(true)}
         open={showEmailBindModal}
-        size={'mini'}
+        size={'tiny'}
+        style={{ maxWidth: '450px' }}
       >
         <Modal.Header>绑定邮箱地址</Modal.Header>
         <Modal.Content>
@@ -136,7 +171,9 @@ const PersonalSetting = () => {
                 name='email'
                 type='email'
                 action={
-                  <Button onClick={sendVerificationCode}>获取验证码</Button>
+                  <Button onClick={sendVerificationCode} disabled={loading}>
+                    获取验证码
+                  </Button>
                 }
               />
               <Form.Input
@@ -146,7 +183,23 @@ const PersonalSetting = () => {
                 value={inputs.email_verification_code}
                 onChange={handleInputChange}
               />
-              <Button color='teal' fluid size='large' onClick={bindEmail}>
+              {turnstileEnabled ? (
+                <Turnstile
+                  sitekey={turnstileSiteKey}
+                  onVerify={(token) => {
+                    setTurnstileToken(token);
+                  }}
+                />
+              ) : (
+                <></>
+              )}
+              <Button
+                color='telegram'
+                fluid
+                size='large'
+                onClick={bindEmail}
+                loading={loading}
+              >
                 绑定
               </Button>
             </Form>

+ 48 - 5
web/src/components/RegisterForm.js

@@ -10,6 +10,7 @@ import {
 } from 'semantic-ui-react';
 import { Link, useNavigate } from 'react-router-dom';
 import { API, showError, showInfo, showSuccess } from '../helpers';
+import Turnstile from 'react-turnstile';
 
 const RegisterForm = () => {
   const [inputs, setInputs] = useState({
@@ -20,14 +21,21 @@ const RegisterForm = () => {
     verification_code: '',
   });
   const { username, password, password2 } = inputs;
-
   const [showEmailVerification, setShowEmailVerification] = useState(false);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  const [loading, setLoading] = useState(false);
 
   useEffect(() => {
     let status = localStorage.getItem('status');
     if (status) {
       status = JSON.parse(status);
       setShowEmailVerification(status.email_verification);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
     }
   });
 
@@ -49,7 +57,15 @@ const RegisterForm = () => {
       return;
     }
     if (username && password) {
-      const res = await API.post('/api/user/register', inputs);
+      if (turnstileEnabled && turnstileToken === '') {
+        showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+        return;
+      }
+      setLoading(true);
+      const res = await API.post(
+        `/api/user/register?turnstile=${turnstileToken}`,
+        inputs
+      );
       const { success, message } = res.data;
       if (success) {
         navigate('/login');
@@ -57,18 +73,27 @@ const RegisterForm = () => {
       } else {
         showError(message);
       }
+      setLoading(false);
     }
   }
 
   const sendVerificationCode = async () => {
     if (inputs.email === '') return;
-    const res = await API.get(`/api/verification?email=${inputs.email}`);
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setLoading(true);
+    const res = await API.get(
+      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
+    );
     const { success, message } = res.data;
     if (success) {
       showSuccess('验证码发送成功,请检查你的邮箱!');
     } else {
       showError(message);
     }
+    setLoading(false);
   };
 
   return (
@@ -116,7 +141,9 @@ const RegisterForm = () => {
                   name='email'
                   type='email'
                   action={
-                    <Button onClick={sendVerificationCode}>获取验证码</Button>
+                    <Button onClick={sendVerificationCode} disabled={loading}>
+                      获取验证码
+                    </Button>
                   }
                 />
                 <Form.Input
@@ -131,7 +158,23 @@ const RegisterForm = () => {
             ) : (
               <></>
             )}
-            <Button color='teal' fluid size='large' onClick={handleSubmit}>
+            {turnstileEnabled ? (
+              <Turnstile
+                sitekey={turnstileSiteKey}
+                onVerify={(token) => {
+                  setTurnstileToken(token);
+                }}
+              />
+            ) : (
+              <></>
+            )}
+            <Button
+              color='telegram'
+              fluid
+              size='large'
+              onClick={handleSubmit}
+              loading={loading}
+            >
               注册
             </Button>
           </Segment>

+ 154 - 45
web/src/components/SystemSetting.js

@@ -1,5 +1,5 @@
 import React, { useEffect, useState } from 'react';
-import { Form, Grid } from 'semantic-ui-react';
+import { Divider, Form, Grid, Header } from 'semantic-ui-react';
 import { API, showError } from '../helpers';
 
 const SystemSetting = () => {
@@ -20,6 +20,10 @@ const SystemSetting = () => {
     WeChatServerAddress: '',
     WeChatServerToken: '',
     WeChatAccountQRCodeImageURL: '',
+    TurnstileCheckEnabled: '',
+    TurnstileSiteKey: '',
+    TurnstileSecretKey: '',
+    RegisterEnabled: '',
   });
   let originInputs = {};
   let [loading, setLoading] = useState(false);
@@ -51,6 +55,8 @@ const SystemSetting = () => {
       case 'EmailVerificationEnabled':
       case 'GitHubOAuthEnabled':
       case 'WeChatAuthEnabled':
+      case 'TurnstileCheckEnabled':
+      case 'RegisterEnabled':
         value = inputs[key] === 'true' ? 'false' : 'true';
         break;
       default:
@@ -78,7 +84,9 @@ const SystemSetting = () => {
       name === 'GitHubClientSecret' ||
       name === 'WeChatServerAddress' ||
       name === 'WeChatServerToken' ||
-      name === 'WeChatAccountQRCodeImageURL'
+      name === 'WeChatAccountQRCodeImageURL' ||
+      name === 'TurnstileSiteKey' ||
+      name === 'TurnstileSecretKey'
     ) {
       setInputs((inputs) => ({ ...inputs, [name]: value }));
     } else {
@@ -142,125 +150,226 @@ const SystemSetting = () => {
     }
   };
 
+  const submitTurnstile = async () => {
+    if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {
+      await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);
+    }
+    if (
+      originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey &&
+      inputs.TurnstileSecretKey !== ''
+    ) {
+      await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey);
+    }
+  };
+
   return (
     <Grid columns={1}>
       <Grid.Column>
         <Form loading={loading}>
-          <Form.Group widths="equal">
+          <Header as='h3'>通用设置</Header>
+          <Form.Group widths='equal'>
             <Form.Input
-              label="服务器地址"
-              placeholder="例如:https://yourdomain.com(注意没有最后的斜杠)"
+              label='服务器地址'
+              placeholder='例如:https://yourdomain.com(注意没有最后的斜杠)'
               value={inputs.ServerAddress}
-              name="ServerAddress"
+              name='ServerAddress'
               onChange={handleInputChange}
             />
           </Form.Group>
           <Form.Button onClick={submitServerAddress}>
             更新服务器地址
           </Form.Button>
+          <Divider />
+          <Header as='h3'>配置登录注册</Header>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.PasswordLoginEnabled === 'true'}
-              label="允许密码登录"
-              name="PasswordLoginEnabled"
+              label='允许通过密码进行登录'
+              name='PasswordLoginEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.PasswordRegisterEnabled === 'true'}
-              label="允许通过密码进行注册"
-              name="PasswordRegisterEnabled"
+              label='允许通过密码进行注册'
+              name='PasswordRegisterEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.EmailVerificationEnabled === 'true'}
-              label="强制邮箱验证"
-              name="EmailVerificationEnabled"
+              label='通过密码注册时需要进行邮箱验证'
+              name='EmailVerificationEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.GitHubOAuthEnabled === 'true'}
-              label="允许通过 GitHub 账户登录 & 注册"
-              name="GitHubOAuthEnabled"
+              label='允许通过 GitHub 账户登录 & 注册'
+              name='GitHubOAuthEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.WeChatAuthEnabled === 'true'}
-              label="允许通过微信登录 & 注册"
-              name="WeChatAuthEnabled"
+              label='允许通过微信登录 & 注册'
+              name='WeChatAuthEnabled'
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Form.Group inline>
+            <Form.Checkbox
+              checked={inputs.RegisterEnabled === 'true'}
+              label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)'
+              name='RegisterEnabled'
+              onChange={handleInputChange}
+            />
+            <Form.Checkbox
+              checked={inputs.TurnstileCheckEnabled === 'true'}
+              label='启用 Turnstile 用户校验'
+              name='TurnstileCheckEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
+          <Divider />
+          <Header as='h3'>
+            配置 SMTP
+            <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
+          </Header>
           <Form.Group widths={3}>
             <Form.Input
-              label="SMTP 服务器地址"
-              name="SMTPServer"
+              label='SMTP 服务器地址'
+              name='SMTPServer'
               onChange={handleInputChange}
-              autoComplete="off"
+              autoComplete='off'
               value={inputs.SMTPServer}
+              placeholder='例如:smtp.qq.com'
             />
             <Form.Input
-              label="SMTP 账户"
-              name="SMTPAccount"
+              label='SMTP 账户'
+              name='SMTPAccount'
               onChange={handleInputChange}
-              autoComplete="off"
+              autoComplete='off'
               value={inputs.SMTPAccount}
+              placeholder='通常是邮箱地址'
             />
             <Form.Input
-              label="SMTP 访问凭证"
-              name="SMTPToken"
+              label='SMTP 访问凭证'
+              name='SMTPToken'
               onChange={handleInputChange}
-              type="password"
-              autoComplete="off"
+              type='password'
+              autoComplete='off'
               value={inputs.SMTPToken}
+              placeholder='敏感信息不会发送到前端显示'
             />
           </Form.Group>
           <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>
+          <Divider />
+          <Header as='h3'>
+            配置 GitHub OAuth App
+            <Header.Subheader>
+              用以支持通过 GitHub 进行登录注册,
+              <a href='https://github.com/settings/developers' target='_blank'>
+                点击此处
+              </a>
+              管理你的 GitHub OAuth App
+            </Header.Subheader>
+          </Header>
           <Form.Group widths={3}>
             <Form.Input
-              label="GitHub Client ID"
-              name="GitHubClientId"
+              label='GitHub Client ID'
+              name='GitHubClientId'
               onChange={handleInputChange}
-              autoComplete="off"
+              autoComplete='off'
               value={inputs.GitHubClientId}
+              placeholder='输入你注册的 GitHub OAuth APP 的 ID'
             />
             <Form.Input
-              label="GitHub Client Secret"
-              name="GitHubClientSecret"
+              label='GitHub Client Secret'
+              name='GitHubClientSecret'
               onChange={handleInputChange}
-              type="password"
-              autoComplete="off"
+              type='password'
+              autoComplete='off'
               value={inputs.GitHubClientSecret}
+              placeholder='敏感信息不会发送到前端显示'
             />
           </Form.Group>
           <Form.Button onClick={submitGitHubOAuth}>
             保存 GitHub OAuth 设置
           </Form.Button>
+          <Divider />
+          <Header as='h3'>
+            配置 WeChat Server
+            <Header.Subheader>
+              用以支持通过微信进行登录注册,
+              <a
+                href='https://github.com/songquanpeng/wechat-server'
+                target='_blank'
+              >
+                点击此处
+              </a>
+              了解 WeChat Server
+            </Header.Subheader>
+          </Header>
           <Form.Group widths={3}>
             <Form.Input
-              label="WeChat Server 服务器地址"
-              name="WeChatServerAddress"
-              placeholder="例如:https://yourdomain.com(注意没有最后的斜杠)"
+              label='WeChat Server 服务器地址'
+              name='WeChatServerAddress'
+              placeholder='例如:https://yourdomain.com(注意没有最后的斜杠)'
               onChange={handleInputChange}
-              autoComplete="off"
+              autoComplete='off'
               value={inputs.WeChatServerAddress}
             />
             <Form.Input
-              label="WeChat Server 访问凭证"
-              name="WeChatServerToken"
-              type="password"
+              label='WeChat Server 访问凭证'
+              name='WeChatServerToken'
+              type='password'
               onChange={handleInputChange}
-              autoComplete="off"
+              autoComplete='off'
               value={inputs.WeChatServerToken}
+              placeholder='敏感信息不会发送到前端显示'
             />
             <Form.Input
-              label="微信公众号二维码图片链接"
-              name="WeChatAccountQRCodeImageURL"
+              label='微信公众号二维码图片链接'
+              name='WeChatAccountQRCodeImageURL'
               onChange={handleInputChange}
-              autoComplete="off"
+              autoComplete='off'
               value={inputs.WeChatAccountQRCodeImageURL}
+              placeholder='输入一个图片链接'
+            />
+          </Form.Group>
+          <Form.Button onClick={submitWeChat}>
+            保存 WeChat Server 设置
+          </Form.Button>
+          <Divider />
+          <Header as='h3'>
+            配置 Turnstile
+            <Header.Subheader>
+              用以支持用户校验,
+              <a href='https://dash.cloudflare.com/' target='_blank'>
+                点击此处
+              </a>
+              管理你的 Turnstile Sites,推荐选择 Invisible Widget Type
+            </Header.Subheader>
+          </Header>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='Turnstile Site Key'
+              name='TurnstileSiteKey'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.TurnstileSiteKey}
+              placeholder='输入你注册的 Turnstile Site Key'
+            />
+            <Form.Input
+              label='Turnstile Secret Key'
+              name='TurnstileSecretKey'
+              onChange={handleInputChange}
+              type='password'
+              autoComplete='off'
+              value={inputs.TurnstileSecretKey}
+              placeholder='敏感信息不会发送到前端显示'
             />
           </Form.Group>
-          <Form.Button onClick={submitWeChat}>保存微信登录设置</Form.Button>
+          <Form.Button onClick={submitTurnstile}>
+            保存 Turnstile 设置
+          </Form.Button>
         </Form>
       </Grid.Column>
     </Grid>

+ 75 - 31
web/src/components/UsersTable.js

@@ -3,18 +3,18 @@ import { Button, Form, Label, Pagination, Table } from 'semantic-ui-react';
 import { Link } from 'react-router-dom';
 import { API, showError, showSuccess } from '../helpers';
 
-const itemsPerPage = 10;
+import { ITEMS_PER_PAGE } from '../constants';
 
 function renderRole(role) {
   switch (role) {
     case 1:
       return <Label>普通用户</Label>;
     case 10:
-      return <Label color="yellow">管理员</Label>;
+      return <Label color='yellow'>管理员</Label>;
     case 100:
-      return <Label color="orange">超级管理员</Label>;
+      return <Label color='orange'>超级管理员</Label>;
     default:
-      return <Label color="red">未知身份</Label>;
+      return <Label color='red'>未知身份</Label>;
   }
 }
 
@@ -25,11 +25,17 @@ const UsersTable = () => {
   const [searchKeyword, setSearchKeyword] = useState('');
   const [searching, setSearching] = useState(false);
 
-  const loadUsers = async () => {
-    const res = await API.get('/api/user');
+  const loadUsers = async (startIdx) => {
+    const res = await API.get(`/api/user/?p=${startIdx}`);
     const { success, message, data } = res.data;
     if (success) {
-      setUsers(data);
+      if (startIdx === 0) {
+        setUsers(data);
+      } else {
+        let newUsers = users;
+        newUsers.push(...data);
+        setUsers(newUsers);
+      }
     } else {
       showError(message);
     }
@@ -37,18 +43,24 @@ const UsersTable = () => {
   };
 
   const onPaginationChange = (e, { activePage }) => {
-    setActivePage(activePage);
+    (async () => {
+      if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
+        // In this case we have to load more data and then append them.
+        await loadUsers(activePage - 1);
+      }
+      setActivePage(activePage);
+    })();
   };
 
   useEffect(() => {
-    loadUsers()
+    loadUsers(0)
       .then()
       .catch((reason) => {
         showError(reason);
       });
   }, []);
 
-  const manageUser = (username, action) => {
+  const manageUser = (username, action, idx) => {
     (async () => {
       const res = await API.post('/api/user/manage', {
         username,
@@ -57,38 +69,62 @@ const UsersTable = () => {
       const { success, message } = res.data;
       if (success) {
         showSuccess('操作成功完成!');
-        await loadUsers();
+        let user = res.data.data;
+        let newUsers = [...users];
+        let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
+        if (action === 'delete') {
+          newUsers[realIdx].deleted = true;
+        } else {
+          newUsers[realIdx].status = user.status;
+          newUsers[realIdx].role = user.role;
+        }
+        setUsers(newUsers);
       } else {
         showError(message);
       }
     })();
   };
 
-  const renderStatus = (status, id) => {
+  const renderStatus = (status) => {
     switch (status) {
       case 1:
-        return '已激活';
+        return <Label basic>已激活</Label>;
       case 2:
-        return '已封禁';
+        return (
+          <Label basic color='red'>
+            已封禁
+          </Label>
+        );
       default:
-        return '未知状态';
+        return (
+          <Label basic color='grey'>
+            未知状态
+          </Label>
+        );
     }
   };
 
   const searchUsers = async () => {
+    if (searchKeyword === '') {
+      // if keyword is blank, load files instead.
+      await loadUsers(0);
+      setActivePage(1);
+      return;
+    }
     setSearching(true);
     const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
     const { success, message, data } = res.data;
     if (success) {
       setUsers(data);
+      setActivePage(1);
     } else {
       showError(message);
     }
     setSearching(false);
   };
 
-  const handleKeywordChange = async (e, { name, value }) => {
-    setSearchKeyword(value);
+  const handleKeywordChange = async (e, { value }) => {
+    setSearchKeyword(value.trim());
   };
 
   const sortUser = (key) => {
@@ -109,10 +145,10 @@ const UsersTable = () => {
     <>
       <Form onSubmit={searchUsers}>
         <Form.Input
-          icon="search"
+          icon='search'
           fluid
-          iconPosition="left"
-          placeholder="搜索用户的 ID,用户名,显示名称,以及邮箱地址 ..."
+          iconPosition='left'
+          placeholder='搜索用户的 ID,用户名,显示名称,以及邮箱地址 ...'
           value={searchKeyword}
           loading={searching}
           onChange={handleKeywordChange}
@@ -168,22 +204,26 @@ const UsersTable = () => {
 
         <Table.Body>
           {users
-            .slice((activePage - 1) * itemsPerPage, activePage * itemsPerPage)
+            .slice(
+              (activePage - 1) * ITEMS_PER_PAGE,
+              activePage * ITEMS_PER_PAGE
+            )
             .map((user, idx) => {
+              if (user.deleted) return <></>;
               return (
                 <Table.Row key={user.id}>
                   <Table.Cell>{user.username}</Table.Cell>
                   <Table.Cell>{user.display_name}</Table.Cell>
                   <Table.Cell>{user.email ? user.email : '无'}</Table.Cell>
                   <Table.Cell>{renderRole(user.role)}</Table.Cell>
-                  <Table.Cell>{renderStatus(user.status, user.id)}</Table.Cell>
+                  <Table.Cell>{renderStatus(user.status)}</Table.Cell>
                   <Table.Cell>
                     <div>
                       <Button
                         size={'small'}
                         positive
                         onClick={() => {
-                          manageUser(user.username, 'promote');
+                          manageUser(user.username, 'promote', idx);
                         }}
                       >
                         提升
@@ -192,7 +232,7 @@ const UsersTable = () => {
                         size={'small'}
                         color={'yellow'}
                         onClick={() => {
-                          manageUser(user.username, 'demote');
+                          manageUser(user.username, 'demote', idx);
                         }}
                       >
                         降级
@@ -201,7 +241,7 @@ const UsersTable = () => {
                         size={'small'}
                         negative
                         onClick={() => {
-                          manageUser(user.username, 'delete');
+                          manageUser(user.username, 'delete', idx);
                         }}
                       >
                         删除
@@ -211,7 +251,8 @@ const UsersTable = () => {
                         onClick={() => {
                           manageUser(
                             user.username,
-                            user.status === 1 ? 'disable' : 'enable'
+                            user.status === 1 ? 'disable' : 'enable',
+                            idx
                           );
                         }}
                       >
@@ -233,17 +274,20 @@ const UsersTable = () => {
 
         <Table.Footer>
           <Table.Row>
-            <Table.HeaderCell colSpan="6">
-              <Button size="small" as={Link} to="/user/add" loading={loading}>
+            <Table.HeaderCell colSpan='6'>
+              <Button size='small' as={Link} to='/user/add' loading={loading}>
                 添加新的用户
               </Button>
               <Pagination
-                floated="right"
+                floated='right'
                 activePage={activePage}
                 onPageChange={onPaginationChange}
-                size="small"
+                size='small'
                 siblingRange={1}
-                totalPages={Math.ceil(users.length / itemsPerPage)}
+                totalPages={
+                  Math.ceil(users.length / ITEMS_PER_PAGE) +
+                  (users.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
+                }
               />
             </Table.HeaderCell>
           </Table.Row>

+ 1 - 0
web/src/constants/common.constant.js

@@ -0,0 +1 @@
+export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!

+ 2 - 1
web/src/constants/index.js

@@ -1,2 +1,3 @@
 export * from './toast.constants';
-export * from './user.constants';
+export * from './user.constants';
+export * from './common.constant';

+ 4 - 0
web/src/helpers/utils.js

@@ -85,3 +85,7 @@ export function showInfo(message) {
 export function showNotice(message) {
   toast.info(message, showNoticeOptions);
 }
+
+export function openPage(url) {
+  window.open(url);
+}

+ 8 - 6
web/src/pages/About/index.js

@@ -26,18 +26,20 @@ const About = () => {
   return (
     <>
       <Segment>
-        {
-          about === '' ? <>
+        {about === '' ? (
+          <>
             <Header as='h3'>关于</Header>
             <p>可在设置页面设置关于内容,支持 HTML & Markdown</p>
             项目仓库地址:
-            <a href="https://github.com/songquanpeng/message-pusher">
+            <a href='https://github.com/songquanpeng/message-pusher'>
               https://github.com/songquanpeng/message-pusher
             </a>
-          </> : <>
-            <div dangerouslySetInnerHTML={{ __html: about}}></div>
           </>
-        }
+        ) : (
+          <>
+            <div dangerouslySetInnerHTML={{ __html: about }}></div>
+          </>
+        )}
       </Segment>
     </>
   );

+ 22 - 47
web/src/pages/User/EditUser.js

@@ -13,18 +13,9 @@ const EditUser = () => {
     password: '',
     github_id: '',
     wechat_id: '',
-    email: '',
-    token: '',
+    email:''
   });
-  const {
-    username,
-    display_name,
-    password,
-    github_id,
-    wechat_id,
-    email,
-    token,
-  } = inputs;
+  const { username, display_name, password, github_id, wechat_id, email } = inputs;
   const handleInputChange = (e, { name, value }) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
@@ -39,9 +30,6 @@ const EditUser = () => {
     const { success, message, data } = res.data;
     if (success) {
       data.password = '';
-      if (data.token === ' ') {
-        data.token = '';
-      }
       setInputs(data);
     } else {
       showError(message);
@@ -70,77 +58,64 @@ const EditUser = () => {
   return (
     <>
       <Segment loading={loading}>
-        <Header as='h3'>更新用户信息</Header>
-        <Form autoComplete='off'>
+        <Header as="h3">更新用户信息</Header>
+        <Form autoComplete="off">
           <Form.Field>
             <Form.Input
-              label='用户名'
-              name='username'
+              label="用户名"
+              name="username"
               placeholder={'请输入新的用户名'}
               onChange={handleInputChange}
               value={username}
-              autoComplete='off'
+              autoComplete="off"
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='密码'
-              name='password'
+              label="密码"
+              name="password"
               type={'password'}
               placeholder={'请输入新的密码'}
               onChange={handleInputChange}
               value={password}
-              autoComplete='off'
+              autoComplete="off"
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='显示名称'
-              name='display_name'
+              label="显示名称"
+              name="display_name"
               placeholder={'请输入新的显示名称'}
               onChange={handleInputChange}
               value={display_name}
-              autoComplete='off'
+              autoComplete="off"
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='推送鉴权 Token'
-              name='token'
-              placeholder={'请输入新的 Token,留空则将 Token 置空'}
-              onChange={handleInputChange}
-              value={token}
-              autoComplete='off'
-            />
-          </Form.Field>
-          <Form.Field>
-            <Form.Input
-              label='已绑定的 GitHub 账户'
-              name='github_id'
+              label="已绑定的 GitHub 账户"
+              name="github_id"
               value={github_id}
-              autoComplete='off'
+              autoComplete="off"
               readOnly
-              placeholder={'如需绑定请到个人设置页面进行绑定'}
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='已绑定的微信账户'
-              name='wechat_id'
+              label="已绑定的微信账户"
+              name="wechat_id"
               value={wechat_id}
-              autoComplete='off'
+              autoComplete="off"
               readOnly
-              placeholder={'如需绑定请到个人设置页面进行绑定'}
             />
           </Form.Field>
           <Form.Field>
             <Form.Input
-              label='已绑定的邮箱账户'
-              name='email'
+              label="已绑定的邮箱账户"
+              name="email"
               value={email}
-              autoComplete='off'
+              autoComplete="off"
               readOnly
-              placeholder={'如需绑定请到个人设置页面进行绑定'}
             />
           </Form.Field>
           <Button onClick={submit}>提交</Button>