├── .github
└── workflows
│ ├── docker-image.yml
│ ├── github-pages.yml
│ ├── linux-release.yml
│ ├── macos-release.yml
│ └── windows-release.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── VERSION
├── common
├── access-token-store.go
├── constants.go
├── crypto.go
├── email.go
├── embed-file-system.go
├── init.go
├── logger.go
├── rate-limit.go
├── redis.go
├── utils.go
├── validate.go
├── verification.go
└── wechat-message.go
├── controller
├── file.go
├── misc.go
├── oauth.go
├── option.go
├── user.go
└── wechat.go
├── go.mod
├── go.sum
├── main.go
├── middleware
├── auth.go
├── cors.go
└── rate-limit.go
├── model
├── file.go
├── main.go
├── option.go
└── user.go
├── router
├── api-router.go
├── main.go
└── web-router.go
└── web
├── .gitignore
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo.png
└── robots.txt
├── src
├── App.js
├── components
│ ├── Footer.js
│ ├── GitHubOAuth.js
│ ├── Header.js
│ ├── Loading.js
│ ├── LoginForm.js
│ ├── PasswordResetForm.js
│ ├── PrivateRoute.js
│ ├── RegisterForm.js
│ ├── SystemSetting.js
│ ├── UsersTable.js
│ └── WeChatSetting.js
├── constants
│ ├── index.js
│ ├── toast.constants.js
│ └── user.constants.js
├── context
│ └── User
│ │ ├── index.js
│ │ └── reducer.js
├── helpers
│ ├── api.js
│ ├── auth-header.js
│ ├── history.js
│ ├── index.js
│ └── utils.js
├── index.css
├── index.js
└── pages
│ ├── About
│ └── index.js
│ ├── Home
│ └── index.js
│ ├── NotFound
│ └── index.js
│ ├── Setting
│ └── index.js
│ └── User
│ ├── AddUser.js
│ ├── EditUser.js
│ └── index.js
└── vercel.json
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 | inputs:
9 | name:
10 | description: 'reason'
11 | required: false
12 | jobs:
13 | push_to_registries:
14 | name: Push Docker image to multiple registries
15 | runs-on: ubuntu-latest
16 | permissions:
17 | packages: write
18 | contents: read
19 | steps:
20 | - name: Check out the repo
21 | uses: actions/checkout@v3
22 |
23 | - name: Save version info
24 | run: |
25 | git describe --tags > VERSION
26 |
27 | - name: Log in to Docker Hub
28 | uses: docker/login-action@v2
29 | with:
30 | username: ${{ secrets.DOCKERHUB_USERNAME }}
31 | password: ${{ secrets.DOCKERHUB_TOKEN }}
32 |
33 | - name: Log in to the Container registry
34 | uses: docker/login-action@v2
35 | with:
36 | registry: ghcr.io
37 | username: ${{ github.actor }}
38 | password: ${{ secrets.GITHUB_TOKEN }}
39 |
40 | - name: Extract metadata (tags, labels) for Docker
41 | id: meta
42 | uses: docker/metadata-action@v4
43 | with:
44 | images: |
45 | justsong/wechat-server
46 | ghcr.io/${{ github.repository }}
47 |
48 | - name: Build and push Docker images
49 | uses: docker/build-push-action@v3
50 | with:
51 | context: .
52 | push: true
53 | tags: ${{ steps.meta.outputs.tags }}
54 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/.github/workflows/github-pages.yml:
--------------------------------------------------------------------------------
1 | name: Build GitHub Pages
2 | on:
3 | workflow_dispatch:
4 | inputs:
5 | name:
6 | description: 'Reason'
7 | required: false
8 | jobs:
9 | build-and-deploy:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout 🛎️
13 | uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
14 | with:
15 | persist-credentials: false
16 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
17 | env:
18 | CI: ""
19 | run: |
20 | cd web
21 | npm install
22 | npm run build
23 |
24 | - name: Deploy 🚀
25 | uses: JamesIves/github-pages-deploy-action@releases/v3
26 | with:
27 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
28 | BRANCH: gh-pages # The branch the action should deploy to.
29 | FOLDER: web/build # The folder the action should deploy.
--------------------------------------------------------------------------------
/.github/workflows/linux-release.yml:
--------------------------------------------------------------------------------
1 | name: Linux Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | jobs:
8 | release:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v3
13 | with:
14 | fetch-depth: 0
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: 16
18 | - name: Build Frontend
19 | env:
20 | CI: ""
21 | run: |
22 | cd web
23 | npm install
24 | REACT_APP_VERSION=$(git describe --tags) npm run build
25 | cd ..
26 | - name: Set up Go
27 | uses: actions/setup-go@v3
28 | with:
29 | go-version: '>=1.18.0'
30 | - name: Build Backend
31 | run: |
32 | go mod download
33 | go build -ldflags "-s -w -X 'wechat-server/common.Version=$(git describe --tags)' -extldflags '-static'" -o wechat-server
34 | - name: Release
35 | uses: softprops/action-gh-release@v1
36 | if: startsWith(github.ref, 'refs/tags/')
37 | with:
38 | files: wechat-server
39 | env:
40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/macos-release.yml:
--------------------------------------------------------------------------------
1 | name: macOS Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | jobs:
8 | release:
9 | runs-on: macos-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v3
13 | with:
14 | fetch-depth: 0
15 | - uses: actions/setup-node@v3
16 | with:
17 | node-version: 16
18 | - name: Build Frontend
19 | env:
20 | CI: ""
21 | run: |
22 | cd web
23 | npm install
24 | REACT_APP_VERSION=$(git describe --tags) npm run build
25 | cd ..
26 | - name: Set up Go
27 | uses: actions/setup-go@v3
28 | with:
29 | go-version: '>=1.18.0'
30 | - name: Build Backend
31 | run: |
32 | go mod download
33 | go build -ldflags "-X 'wechat-server/common.Version=$(git describe --tags)'" -o wechat-server-macos
34 | - name: Release
35 | uses: softprops/action-gh-release@v1
36 | if: startsWith(github.ref, 'refs/tags/')
37 | with:
38 | files: wechat-server-macos
39 | env:
40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41 |
--------------------------------------------------------------------------------
/.github/workflows/windows-release.yml:
--------------------------------------------------------------------------------
1 | name: Windows Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | jobs:
8 | release:
9 | runs-on: windows-latest
10 | defaults:
11 | run:
12 | shell: bash
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v3
16 | with:
17 | fetch-depth: 0
18 | - uses: actions/setup-node@v3
19 | with:
20 | node-version: 16
21 | - name: Build Frontend
22 | env:
23 | CI: ""
24 | run: |
25 | cd web
26 | npm install
27 | REACT_APP_VERSION=$(git describe --tags) npm run build
28 | cd ..
29 | - name: Set up Go
30 | uses: actions/setup-go@v3
31 | with:
32 | go-version: '>=1.18.0'
33 | - name: Build Backend
34 | run: |
35 | go mod download
36 | go build -ldflags "-s -w -X 'wechat-server/common.Version=$(git describe --tags)'" -o wechat-server.exe
37 | - name: Release
38 | uses: softprops/action-gh-release@v1
39 | if: startsWith(github.ref, 'refs/tags/')
40 | with:
41 | files: wechat-server.exe
42 | env:
43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 | upload
4 | *.exe
5 | *.db
6 | build
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16 as builder
2 |
3 | WORKDIR /build
4 | COPY ./web .
5 | COPY ./VERSION .
6 | RUN npm install
7 | RUN REACT_APP_VERSION=$(cat VERSION) npm run build
8 |
9 | FROM golang AS builder2
10 |
11 | ENV GO111MODULE=on \
12 | CGO_ENABLED=1 \
13 | GOOS=linux \
14 | GOARCH=amd64
15 | WORKDIR /build
16 | COPY . .
17 | COPY --from=builder /build/build ./web/build
18 | RUN go mod download
19 | RUN go build -ldflags "-s -w -X 'wechat-server/common.Version=$(cat VERSION)' -extldflags '-static'" -o wechat-server
20 |
21 | FROM alpine
22 |
23 | ENV PORT=3000
24 | COPY --from=builder2 /build/wechat-server /
25 | EXPOSE 3000
26 | WORKDIR /data
27 | ENTRYPOINT ["/wechat-server"]
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 JustSong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 微信公众号服务器
2 | > 用以微信公众号的后端,提供登录验证功能
3 |
4 | ## 功能
5 | + [x] Access Token 自动刷新 & 提供外部访问接口
6 | + [x] 自定义菜单(需要你的公众号有这个权限)
7 | + [x] 登录验证
8 | + [ ] 自定义回复
9 |
10 | ## 展示
11 | 
12 | 
13 |
14 | ## 部署
15 | ### 手动部署
16 | 1. 从 [GitHub Releases](https://github.com/songquanpeng/wechat-server/releases/latest) 下载可执行文件或者从源码编译:
17 | ```shell
18 | git clone https://github.com/songquanpeng/wechat-server.git
19 | go mod download
20 | go build -ldflags "-s -w" -o wechat-server
21 | ````
22 | 2. 运行:
23 | ```shell
24 | chmod u+x wechat-server
25 | ./wechat-server --port 3000 --log-dir ./logs
26 | ```
27 | 3. 访问 [http://localhost:3000/](http://localhost:3000/) 并登录。初始账号用户名为 `root`,密码为 `123456`。
28 |
29 | 更加详细的部署教程[参见此处](https://iamazing.cn/page/how-to-deploy-a-website)。
30 |
31 | ### 基于 Docker 进行部署
32 | 执行:`docker run -d --restart always -p 3000:3000 -v /home/ubuntu/data/wechat-server:/data justsong/wechat-server`
33 |
34 | 数据将会保存在宿主机的 `/home/ubuntu/data/wechat-server` 目录。
35 |
36 | ## 配置
37 | 1. 从 [GitHub Releases](https://github.com/songquanpeng/wechat-server/releases/latest) 下载可执行文件。
38 | 2. 系统本身开箱即用,有一些环境变量可供配置:
39 | 1. `REDIS_CONN_STRING`: 设置之后,将启用 Redis。
40 | + 例如:`REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
41 | 2. `SESSION_SECRET`:设置之后,将使用给定会话密钥。
42 | + 例如:`SESSION_SECRET=random_string`
43 | 3. `SQL_DSN`: 设置之后,将使用目标数据库而非 SQLite。
44 | + 例如:`SQL_DSN=root:123456@tcp(localhost:3306)/gofile`
45 | 3. 运行:
46 | 1. `chmod u+x wechat-server`
47 | 2. `./wechat-server --port 3000`
48 | 4. 初始账户用户名为 `root`,密码为 `123456`,记得登录后立刻修改密码。
49 | 5. 前往[微信公众号配置页面 -> 设置与开发 -> 基本配置](https://mp.weixin.qq.com/)获取 AppID 和 AppSecret,并在我们的配置页面填入上述信息,另外还需要配置 IP 白名单,按照页面上的提示完成即可。
50 | 6. 前往[微信公众号配置页面 -> 设置与开发 -> 基本配置](https://mp.weixin.qq.com/)填写以下配置:
51 | 1. `URL` 填:`https:///api/wechat`
52 | 2. `Token` 首先在我们的配置页面随便填写一个 Token,然后在微信公众号的配置页面填入同一个 Token 即可。
53 | 3. `EncodingAESKey` 点随机生成,然后在我们的配置页面填入该值。
54 | 4. 消息加解密方式选择明文模式。
55 | 7. 之后保存设置并启用设置。
56 | 8. 当前版本需要重启服务才能应用配置信息,因此请重启服务。
57 |
58 | ## API
59 | ### 获取 Access Token
60 | 1. 请求方法:`GET`
61 | 2. URL:`/api/wechat/access_token`
62 | 3. 无参数,但是需要设置 HTTP 头部:`Authorization: `
63 |
64 | ### 通过验证码查询用户 ID
65 | 1. 请求方法:`GET`
66 | 2. URL:`/api/wechat/user?code=`
67 | 3. 需要设置 HTTP 头部:`Authorization: `
68 |
69 | ### 注意
70 | 需要将 `` 和 `` 替换为实际的内容。
--------------------------------------------------------------------------------
/VERSION:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/wechat-server/13d0e3264ced535f424c586dd6465f0346e38e1a/VERSION
--------------------------------------------------------------------------------
/common/access-token-store.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "sync"
8 | "time"
9 | )
10 |
11 | type accessTokenStore struct {
12 | AccessToken string
13 | Mutex sync.RWMutex
14 | ExpirationSeconds int
15 | }
16 |
17 | type response struct {
18 | AccessToken string `json:"access_token"`
19 | ExpiresIn int `json:"expires_in"`
20 | }
21 |
22 | var s accessTokenStore
23 |
24 | func InitAccessTokenStore() {
25 | go func() {
26 | for {
27 | RefreshAccessToken()
28 | s.Mutex.RLock()
29 | sleepDuration := Max(s.ExpirationSeconds, 60)
30 | s.Mutex.RUnlock()
31 | time.Sleep(time.Duration(sleepDuration) * time.Second)
32 | }
33 | }()
34 | }
35 |
36 | func RefreshAccessToken() {
37 | // https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
38 | client := http.Client{
39 | Timeout: 5 * time.Second,
40 | }
41 | req, err := http.NewRequest("GET", fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", WeChatAppID, WeChatAppSecret), nil)
42 | if err != nil {
43 | SysError(err.Error())
44 | return
45 | }
46 | responseData, err := client.Do(req)
47 | if err != nil {
48 | SysError("failed to refresh access token: " + err.Error())
49 | return
50 | }
51 | defer responseData.Body.Close()
52 | var res response
53 | err = json.NewDecoder(responseData.Body).Decode(&res)
54 | if err != nil {
55 | SysError("failed to decode response: " + err.Error())
56 | return
57 | }
58 | s.Mutex.Lock()
59 | s.AccessToken = res.AccessToken
60 | s.ExpirationSeconds = res.ExpiresIn
61 | s.Mutex.Unlock()
62 | SysLog("access token refreshed")
63 | }
64 |
65 | func GetAccessTokenAndExpirationSeconds() (string, int) {
66 | s.Mutex.RLock()
67 | defer s.Mutex.RUnlock()
68 | return s.AccessToken, s.ExpirationSeconds
69 | }
70 |
71 | func GetAccessToken() string {
72 | s.Mutex.RLock()
73 | defer s.Mutex.RUnlock()
74 | return s.AccessToken
75 | }
76 |
--------------------------------------------------------------------------------
/common/constants.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "github.com/google/uuid"
5 | "sync"
6 | "time"
7 | )
8 |
9 | var StartTime = time.Now().Unix() // unit: second
10 | var Version = "v0.0.0"
11 | var SystemName = "微信服务器"
12 | var ServerAddress = "http://localhost:3000"
13 | var FooterHTML = ""
14 |
15 | // Any options with "Secret", "Token", "Key" in its key won't be return by GetOptions
16 |
17 | var WeChatToken = ""
18 | var WeChatAppID = ""
19 | var WeChatAppSecret = ""
20 | var WeChatEncodingAESKey = ""
21 | var WeChatOwnerID = ""
22 | var WeChatMenu = `{
23 | "button": [
24 | {
25 | "type": "click",
26 | "name": "登录验证",
27 | "key": "USER_VERIFICATION"
28 | }
29 | ]
30 | }`
31 |
32 | var SessionSecret = uuid.New().String()
33 | var SQLitePath = "wechat-server.db"
34 |
35 | var OptionMap map[string]string
36 | var OptionMapRWMutex sync.RWMutex
37 |
38 | var ItemsPerPage = 10
39 |
40 | var PasswordLoginEnabled = true
41 | var RegisterEnabled = false
42 | var EmailVerificationEnabled = false
43 | var GitHubOAuthEnabled = false
44 |
45 | var SMTPServer = ""
46 | var SMTPAccount = ""
47 | var SMTPToken = ""
48 |
49 | var GitHubClientId = ""
50 | var GitHubClientSecret = ""
51 |
52 | const (
53 | RoleGuestUser = 0
54 | RoleCommonUser = 1
55 | RoleAdminUser = 10
56 | RoleRootUser = 100
57 | )
58 |
59 | var (
60 | FileUploadPermission = RoleGuestUser
61 | FileDownloadPermission = RoleGuestUser
62 | ImageUploadPermission = RoleGuestUser
63 | ImageDownloadPermission = RoleGuestUser
64 | )
65 |
66 | // All duration's unit is seconds
67 | // Shouldn't larger then RateLimitKeyExpirationDuration
68 | var (
69 | GlobalApiRateLimitNum = 180
70 | GlobalApiRateLimitDuration int64 = 60
71 |
72 | GlobalWebRateLimitNum = 60
73 | GlobalWebRateLimitDuration int64 = 3 * 60
74 |
75 | UploadRateLimitNum = 10
76 | UploadRateLimitDuration int64 = 60
77 |
78 | DownloadRateLimitNum = 10
79 | DownloadRateLimitDuration int64 = 60
80 |
81 | CriticalRateLimitNum = 5
82 | CriticalRateLimitDuration int64 = 10 * 60
83 | )
84 |
85 | var RateLimitKeyExpirationDuration = 20 * time.Minute
86 |
87 | const (
88 | UserStatusEnabled = 1 // don't use 0, 0 is the default value!
89 | UserStatusDisabled = 2 // also don't use 0
90 | )
91 |
--------------------------------------------------------------------------------
/common/crypto.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "golang.org/x/crypto/bcrypt"
4 |
5 | func Password2Hash(password string) (string, error) {
6 | passwordBytes := []byte(password)
7 | hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
8 | return string(hashedPassword), err
9 | }
10 |
11 | func ValidatePasswordAndHash(password string, hash string) bool {
12 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
13 | return err == nil
14 | }
15 |
--------------------------------------------------------------------------------
/common/email.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "gopkg.in/gomail.v2"
4 |
5 | func SendEmail(subject string, receiver string, content string) error {
6 | m := gomail.NewMessage()
7 | m.SetHeader("From", SMTPAccount)
8 | m.SetHeader("To", receiver)
9 | m.SetHeader("Subject", subject)
10 | m.SetBody("text/html", content)
11 | d := gomail.NewDialer(SMTPServer, 587, SMTPAccount, SMTPToken)
12 | err := d.DialAndSend(m)
13 | return err
14 | }
15 |
--------------------------------------------------------------------------------
/common/embed-file-system.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-gonic/contrib/static"
6 | "io/fs"
7 | "net/http"
8 | )
9 |
10 | // Credit: https://github.com/gin-contrib/static/issues/19
11 |
12 | type embedFileSystem struct {
13 | http.FileSystem
14 | }
15 |
16 | func (e embedFileSystem) Exists(prefix string, path string) bool {
17 | _, err := e.Open(path)
18 | if err != nil {
19 | return false
20 | }
21 | return true
22 | }
23 |
24 | func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
25 | efs, err := fs.Sub(fsEmbed, targetPath)
26 | if err != nil {
27 | panic(err)
28 | }
29 | return embedFileSystem{
30 | FileSystem: http.FS(efs),
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/common/init.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | )
10 |
11 | var (
12 | Port = flag.Int("port", 3000, "the listening port")
13 | PrintVersion = flag.Bool("version", false, "print version and exit")
14 | LogDir = flag.String("log-dir", "", "specify the log directory")
15 | //Host = flag.String("host", "localhost", "the server's ip address or domain")
16 | //Path = flag.String("path", "", "specify a local path to public")
17 | //VideoPath = flag.String("video", "", "specify a video folder to public")
18 | //NoBrowser = flag.Bool("no-browser", false, "open browser or not")
19 | )
20 |
21 | // UploadPath Maybe override by ENV_VAR
22 | var UploadPath = "upload"
23 |
24 | //var ExplorerRootPath = UploadPath
25 | //var ImageUploadPath = "upload/images"
26 | //var VideoServePath = "upload"
27 |
28 | func init() {
29 | flag.Parse()
30 |
31 | if *PrintVersion {
32 | fmt.Println(Version)
33 | os.Exit(0)
34 | }
35 |
36 | if os.Getenv("SESSION_SECRET") != "" {
37 | SessionSecret = os.Getenv("SESSION_SECRET")
38 | }
39 | if os.Getenv("SQLITE_PATH") != "" {
40 | SQLitePath = os.Getenv("SQLITE_PATH")
41 | }
42 | if os.Getenv("UPLOAD_PATH") != "" {
43 | UploadPath = os.Getenv("UPLOAD_PATH")
44 | //ExplorerRootPath = UploadPath
45 | //ImageUploadPath = path.Join(UploadPath, "images")
46 | //VideoServePath = UploadPath
47 | }
48 | if *LogDir != "" {
49 | var err error
50 | *LogDir, err = filepath.Abs(*LogDir)
51 | if err != nil {
52 | log.Fatal(err)
53 | }
54 | if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
55 | err = os.Mkdir(*LogDir, 0777)
56 | if err != nil {
57 | log.Fatal(err)
58 | }
59 | }
60 | }
61 | //if *Path != "" {
62 | // ExplorerRootPath = *Path
63 | //}
64 | //if *VideoPath != "" {
65 | // VideoServePath = *VideoPath
66 | //}
67 | //
68 | //ExplorerRootPath, _ = filepath.Abs(ExplorerRootPath)
69 | //VideoServePath, _ = filepath.Abs(VideoServePath)
70 | //ImageUploadPath, _ = filepath.Abs(ImageUploadPath)
71 | //
72 | if _, err := os.Stat(UploadPath); os.IsNotExist(err) {
73 | _ = os.Mkdir(UploadPath, 0777)
74 | }
75 | //if _, err := os.Stat(ImageUploadPath); os.IsNotExist(err) {
76 | // _ = os.Mkdir(ImageUploadPath, 0777)
77 | //}
78 | }
79 |
--------------------------------------------------------------------------------
/common/logger.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "fmt"
5 | "github.com/gin-gonic/gin"
6 | "io"
7 | "log"
8 | "os"
9 | "path/filepath"
10 | "time"
11 | )
12 |
13 | func SetupGinLog() {
14 | if *LogDir != "" {
15 | commonLogPath := filepath.Join(*LogDir, "common.log")
16 | errorLogPath := filepath.Join(*LogDir, "error.log")
17 | commonFd, err := os.OpenFile(commonLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
18 | if err != nil {
19 | log.Fatal("failed to open log file")
20 | }
21 | errorFd, err := os.OpenFile(errorLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
22 | if err != nil {
23 | log.Fatal("failed to open log file")
24 | }
25 | gin.DefaultWriter = io.MultiWriter(os.Stdout, commonFd)
26 | gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, errorFd)
27 | }
28 | }
29 |
30 | func SysLog(s string) {
31 | t := time.Now()
32 | _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
33 | }
34 |
35 | func SysError(s string) {
36 | t := time.Now()
37 | _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
38 | }
39 |
40 | func FatalLog(v ...any) {
41 | t := time.Now()
42 | _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
43 | os.Exit(1)
44 | }
45 |
--------------------------------------------------------------------------------
/common/rate-limit.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | type InMemoryRateLimiter struct {
9 | store map[string]*[]int64
10 | mutex sync.Mutex
11 | expirationDuration time.Duration
12 | }
13 |
14 | func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
15 | if l.store == nil {
16 | l.mutex.Lock()
17 | if l.store == nil {
18 | l.store = make(map[string]*[]int64)
19 | l.expirationDuration = expirationDuration
20 | if expirationDuration > 0 {
21 | go l.clearExpiredItems()
22 | }
23 | }
24 | l.mutex.Unlock()
25 | }
26 | }
27 |
28 | func (l *InMemoryRateLimiter) clearExpiredItems() {
29 | for {
30 | time.Sleep(l.expirationDuration)
31 | l.mutex.Lock()
32 | now := time.Now().Unix()
33 | for key := range l.store {
34 | queue := l.store[key]
35 | size := len(*queue)
36 | if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
37 | delete(l.store, key)
38 | }
39 | }
40 | l.mutex.Unlock()
41 | }
42 | }
43 |
44 | // Request parameter duration's unit is seconds
45 | func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
46 | l.mutex.Lock()
47 | defer l.mutex.Unlock()
48 | // [old <-- new]
49 | queue, ok := l.store[key]
50 | now := time.Now().Unix()
51 | if ok {
52 | if len(*queue) < maxRequestNum {
53 | *queue = append(*queue, now)
54 | return true
55 | } else {
56 | if now-(*queue)[0] >= duration {
57 | *queue = (*queue)[1:]
58 | *queue = append(*queue, now)
59 | return true
60 | } else {
61 | return false
62 | }
63 | }
64 | } else {
65 | s := make([]int64, 0, maxRequestNum)
66 | l.store[key] = &s
67 | *(l.store[key]) = append(*(l.store[key]), now)
68 | }
69 | return true
70 | }
71 |
--------------------------------------------------------------------------------
/common/redis.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "context"
5 | "github.com/go-redis/redis/v8"
6 | "os"
7 | "time"
8 | )
9 |
10 | var RDB *redis.Client
11 | var RedisEnabled = true
12 |
13 | // InitRedisClient This function is called after init()
14 | func InitRedisClient() (err error) {
15 | if os.Getenv("REDIS_CONN_STRING") == "" {
16 | RedisEnabled = false
17 | SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
18 | return nil
19 | }
20 | opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
21 | if err != nil {
22 | panic(err)
23 | }
24 | RDB = redis.NewClient(opt)
25 |
26 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
27 | defer cancel()
28 |
29 | _, err = RDB.Ping(ctx).Result()
30 | return err
31 | }
32 |
33 | func ParseRedisOption() *redis.Options {
34 | opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
35 | if err != nil {
36 | panic(err)
37 | }
38 | return opt
39 | }
40 |
--------------------------------------------------------------------------------
/common/utils.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 | "log"
7 | "net"
8 | "os/exec"
9 | "runtime"
10 | "strconv"
11 | "strings"
12 | )
13 |
14 | func OpenBrowser(url string) {
15 | var err error
16 |
17 | switch runtime.GOOS {
18 | case "linux":
19 | err = exec.Command("xdg-open", url).Start()
20 | case "windows":
21 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
22 | case "darwin":
23 | err = exec.Command("open", url).Start()
24 | }
25 | if err != nil {
26 | log.Println(err)
27 | }
28 | }
29 |
30 | func GetIp() (ip string) {
31 | ips, err := net.InterfaceAddrs()
32 | if err != nil {
33 | log.Println(err)
34 | return ip
35 | }
36 |
37 | for _, a := range ips {
38 | if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
39 | if ipNet.IP.To4() != nil {
40 | ip = ipNet.IP.String()
41 | if strings.HasPrefix(ip, "10") {
42 | return
43 | }
44 | if strings.HasPrefix(ip, "172") {
45 | return
46 | }
47 | if strings.HasPrefix(ip, "192.168") {
48 | return
49 | }
50 | ip = ""
51 | }
52 | }
53 | }
54 | return
55 | }
56 |
57 | var sizeKB = 1024
58 | var sizeMB = sizeKB * 1024
59 | var sizeGB = sizeMB * 1024
60 |
61 | func Bytes2Size(num int64) string {
62 | numStr := ""
63 | unit := "B"
64 | if num/int64(sizeGB) > 1 {
65 | numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
66 | unit = "GB"
67 | } else if num/int64(sizeMB) > 1 {
68 | numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
69 | unit = "MB"
70 | } else if num/int64(sizeKB) > 1 {
71 | numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
72 | unit = "KB"
73 | } else {
74 | numStr = fmt.Sprintf("%d", num)
75 | }
76 | return numStr + " " + unit
77 | }
78 |
79 | func Seconds2Time(num int) (time string) {
80 | if num/31104000 > 0 {
81 | time += strconv.Itoa(num/31104000) + " 年 "
82 | num %= 31104000
83 | }
84 | if num/2592000 > 0 {
85 | time += strconv.Itoa(num/2592000) + " 个月 "
86 | num %= 2592000
87 | }
88 | if num/86400 > 0 {
89 | time += strconv.Itoa(num/86400) + " 天 "
90 | num %= 86400
91 | }
92 | if num/3600 > 0 {
93 | time += strconv.Itoa(num/3600) + " 小时 "
94 | num %= 3600
95 | }
96 | if num/60 > 0 {
97 | time += strconv.Itoa(num/60) + " 分钟 "
98 | num %= 60
99 | }
100 | time += strconv.Itoa(num) + " 秒"
101 | return
102 | }
103 |
104 | func Interface2String(inter interface{}) string {
105 | switch inter.(type) {
106 | case string:
107 | return inter.(string)
108 | case int:
109 | return fmt.Sprintf("%d", inter.(int))
110 | case float64:
111 | return fmt.Sprintf("%f", inter.(float64))
112 | }
113 | return "Not Implemented"
114 | }
115 |
116 | func UnescapeHTML(x string) interface{} {
117 | return template.HTML(x)
118 | }
119 |
120 | func Max(a int, b int) int {
121 | if a >= b {
122 | return a
123 | } else {
124 | return b
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/common/validate.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "github.com/go-playground/validator/v10"
4 |
5 | var Validate *validator.Validate
6 |
7 | func init() {
8 | Validate = validator.New()
9 | }
10 |
--------------------------------------------------------------------------------
/common/verification.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "github.com/google/uuid"
5 | "math"
6 | "math/rand"
7 | "strconv"
8 | "strings"
9 | "sync"
10 | "time"
11 | )
12 |
13 | type verificationValue struct {
14 | code string
15 | time time.Time
16 | }
17 |
18 | const (
19 | EmailVerificationPurpose = "v"
20 | PasswordResetPurpose = "r"
21 | WeChatVerificationPurpose = "w"
22 | )
23 |
24 | var verificationMutex sync.Mutex
25 | var verificationMap map[string]verificationValue
26 | var verificationMapMaxSize = 20
27 | var VerificationValidMinutes = 3
28 |
29 | func GenerateVerificationCode(length int) string {
30 | code := uuid.New().String()
31 | code = strings.Replace(code, "-", "", -1)
32 | if length == 0 {
33 | return code
34 | }
35 | return code[:length]
36 | }
37 |
38 | func GenerateAllNumberVerificationCode(length int) string {
39 | min := math.Pow10(length - 1)
40 | max := math.Pow10(length) - 1
41 | code := strconv.Itoa(rand.Intn(int(max-min)) + int(min))
42 | if GetWeChatIDByCode(code) != "" {
43 | SysError("repeated verification code detected")
44 | return GenerateAllNumberVerificationCode(length + 1)
45 | }
46 | return code
47 | }
48 |
49 | func RegisterWeChatCodeAndID(code string, id string) {
50 | RegisterVerificationCodeWithKey(code, id, WeChatVerificationPurpose)
51 | }
52 |
53 | func GetWeChatIDByCode(code string) string {
54 | verificationMutex.Lock()
55 | defer verificationMutex.Unlock()
56 | value, okay := verificationMap[WeChatVerificationPurpose+code]
57 | if okay {
58 | delete(verificationMap, WeChatVerificationPurpose+code)
59 | }
60 | now := time.Now()
61 | if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
62 | return ""
63 | }
64 | return value.code
65 | }
66 |
67 | func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
68 | verificationMutex.Lock()
69 | defer verificationMutex.Unlock()
70 | verificationMap[purpose+key] = verificationValue{
71 | code: code,
72 | time: time.Now(),
73 | }
74 | if len(verificationMap) > verificationMapMaxSize {
75 | removeExpiredPairs()
76 | }
77 | }
78 |
79 | func VerifyCodeWithKey(key string, code string, purpose string) bool {
80 | verificationMutex.Lock()
81 | defer verificationMutex.Unlock()
82 | value, okay := verificationMap[purpose+key]
83 | now := time.Now()
84 | if okay {
85 | delete(verificationMap, purpose+key)
86 | }
87 | if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
88 | return false
89 | }
90 | return code == value.code
91 | }
92 |
93 | func DeleteKey(key string, purpose string) {
94 | verificationMutex.Lock()
95 | defer verificationMutex.Unlock()
96 | delete(verificationMap, purpose+key)
97 | }
98 |
99 | // no lock inside!
100 | func removeExpiredPairs() {
101 | now := time.Now()
102 | for key := range verificationMap {
103 | if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
104 | delete(verificationMap, key)
105 | }
106 | }
107 | }
108 |
109 | func init() {
110 | verificationMutex.Lock()
111 | defer verificationMutex.Unlock()
112 | verificationMap = make(map[string]verificationValue)
113 | }
114 |
--------------------------------------------------------------------------------
/common/wechat-message.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "encoding/xml"
4 |
5 | type WeChatMessageRequest struct {
6 | XMLName xml.Name `xml:"xml"`
7 | ToUserName string `xml:"ToUserName"`
8 | FromUserName string `xml:"FromUserName"`
9 | CreateTime int64 `xml:"CreateTime"`
10 | MsgType string `xml:"MsgType"`
11 | Content string `xml:"Content"`
12 | MsgId int64 `xml:"MsgId"`
13 | MsgDataId int64 `xml:"MsgDataId"`
14 | Idx int64 `xml:"Idx"`
15 | }
16 |
17 | type WeChatMessageResponse struct {
18 | XMLName xml.Name `xml:"xml"`
19 | ToUserName string `xml:"ToUserName"`
20 | FromUserName string `xml:"FromUserName"`
21 | CreateTime int64 `xml:"CreateTime"`
22 | MsgType string `xml:"MsgType"`
23 | Content string `xml:"Content"`
24 | }
25 |
26 | func ProcessWeChatMessage(req *WeChatMessageRequest, res *WeChatMessageResponse) {
27 | switch req.Content {
28 | case "验证码":
29 | code := GenerateAllNumberVerificationCode(6)
30 | RegisterWeChatCodeAndID(code, req.FromUserName)
31 | res.Content = code
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/controller/file.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "net/http"
8 | "os"
9 | "path/filepath"
10 | "strings"
11 | "time"
12 | "wechat-server/common"
13 | "wechat-server/model"
14 | )
15 |
16 | type FileDeleteRequest struct {
17 | Id int
18 | Link string
19 | //Token string
20 | }
21 |
22 | func UploadFile(c *gin.Context) {
23 | uploadPath := common.UploadPath
24 | //saveToDatabase := true
25 | //path := c.PostForm("path")
26 | //if path != "" { // Upload to explorer's path
27 | // uploadPath = filepath.Join(common.ExplorerRootPath, path)
28 | // if !strings.HasPrefix(uploadPath, common.ExplorerRootPath) {
29 | // // In this case the given path is not valid, so we reset it to ExplorerRootPath.
30 | // uploadPath = common.ExplorerRootPath
31 | // }
32 | // saveToDatabase = false
33 | //}
34 |
35 | description := c.PostForm("description")
36 | if description == "" {
37 | description = "无描述信息"
38 | }
39 | uploader := c.GetString("username")
40 | if uploader == "" {
41 | uploader = "匿名用户"
42 | }
43 | currentTime := time.Now().Format("2006-01-02 15:04:05")
44 | form, err := c.MultipartForm()
45 | if err != nil {
46 | c.String(http.StatusBadRequest, fmt.Sprintf("get form err: %s", err.Error()))
47 | return
48 | }
49 | files := form.File["file"]
50 | for _, file := range files {
51 | // In case someone wants to upload to other folders.
52 | filename := filepath.Base(file.Filename)
53 | link := filename
54 | savePath := filepath.Join(uploadPath, filename)
55 | if _, err := os.Stat(savePath); err == nil {
56 | // File already existed.
57 | t := time.Now()
58 | timestamp := t.Format("_2006-01-02_15-04-05")
59 | ext := filepath.Ext(filename)
60 | if ext == "" {
61 | link += timestamp
62 | } else {
63 | link = filename[:len(filename)-len(ext)] + timestamp + ext
64 | }
65 | savePath = filepath.Join(uploadPath, link)
66 | }
67 | if err := c.SaveUploadedFile(file, savePath); err != nil {
68 | c.String(http.StatusBadRequest, fmt.Sprintf("upload file err: %s", err.Error()))
69 | return
70 | }
71 | // save to database
72 | fileObj := &model.File{
73 | Description: description,
74 | Uploader: uploader,
75 | Time: currentTime,
76 | Link: link,
77 | Filename: filename,
78 | }
79 | err = fileObj.Insert()
80 | if err != nil {
81 | _ = fmt.Errorf(err.Error())
82 | }
83 | }
84 | c.Redirect(http.StatusSeeOther, "./")
85 | }
86 |
87 | func DeleteFile(c *gin.Context) {
88 | var deleteRequest FileDeleteRequest
89 | err := json.NewDecoder(c.Request.Body).Decode(&deleteRequest)
90 | if err != nil {
91 | c.JSON(http.StatusBadRequest, gin.H{
92 | "success": false,
93 | "message": "无效的参数",
94 | })
95 | return
96 | }
97 |
98 | fileObj := &model.File{
99 | Id: deleteRequest.Id,
100 | }
101 | model.DB.Where("id = ?", deleteRequest.Id).First(&fileObj)
102 | err = fileObj.Delete()
103 | if err != nil {
104 | c.JSON(http.StatusOK, gin.H{
105 | "success": true,
106 | "message": err.Error(),
107 | })
108 | } else {
109 | message := "文件删除成功"
110 | c.JSON(http.StatusOK, gin.H{
111 | "success": true,
112 | "message": message,
113 | })
114 | }
115 |
116 | }
117 |
118 | func DownloadFile(c *gin.Context) {
119 | path := c.Param("file")
120 | fullPath := filepath.Join(common.UploadPath, path)
121 | if !strings.HasPrefix(fullPath, common.UploadPath) {
122 | // We may being attacked!
123 | c.Status(403)
124 | return
125 | }
126 | c.File(fullPath)
127 | // Update download counter
128 | go func() {
129 | model.UpdateDownloadCounter(path)
130 | }()
131 | }
132 |
--------------------------------------------------------------------------------
/controller/misc.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "fmt"
5 | "github.com/gin-gonic/gin"
6 | "net/http"
7 | "wechat-server/common"
8 | "wechat-server/model"
9 | )
10 |
11 | func GetStatus(c *gin.Context) {
12 | c.JSON(http.StatusOK, gin.H{
13 | "success": true,
14 | "message": "",
15 | "data": gin.H{
16 | "version": common.Version,
17 | "start_time": common.StartTime,
18 | "email_verification": common.EmailVerificationEnabled,
19 | "github_oauth": common.GitHubOAuthEnabled,
20 | "github_client_id": common.GitHubClientId,
21 | "system_name": common.SystemName,
22 | "footer_html": common.FooterHTML,
23 | },
24 | })
25 | return
26 | }
27 |
28 | func GetNotice(c *gin.Context) {
29 | common.OptionMapRWMutex.RLock()
30 | defer common.OptionMapRWMutex.RUnlock()
31 | c.JSON(http.StatusOK, gin.H{
32 | "success": true,
33 | "message": "",
34 | "data": common.OptionMap["Notice"],
35 | })
36 | return
37 | }
38 |
39 | func SendEmailVerification(c *gin.Context) {
40 | email := c.Query("email")
41 | if err := common.Validate.Var(email, "required,email"); err != nil {
42 | c.JSON(http.StatusOK, gin.H{
43 | "success": false,
44 | "message": "无效的参数",
45 | })
46 | return
47 | }
48 | if model.IsEmailAlreadyTaken(email) {
49 | c.JSON(http.StatusOK, gin.H{
50 | "success": false,
51 | "message": "邮箱地址已被占用",
52 | })
53 | return
54 | }
55 | code := common.GenerateVerificationCode(6)
56 | common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
57 | subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
58 | content := fmt.Sprintf("您好,你正在进行%s邮箱验证。
"+
59 | "您的验证码为: %s
"+
60 | "验证码 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, code, common.VerificationValidMinutes)
61 | err := common.SendEmail(subject, email, content)
62 | if err != nil {
63 | c.JSON(http.StatusOK, gin.H{
64 | "success": false,
65 | "message": err.Error(),
66 | })
67 | return
68 | }
69 | c.JSON(http.StatusOK, gin.H{
70 | "success": true,
71 | "message": "",
72 | })
73 | return
74 | }
75 |
76 | func SendPasswordResetEmail(c *gin.Context) {
77 | email := c.Query("email")
78 | if err := common.Validate.Var(email, "required,email"); err != nil {
79 | c.JSON(http.StatusOK, gin.H{
80 | "success": false,
81 | "message": "无效的参数",
82 | })
83 | return
84 | }
85 | if !model.IsEmailAlreadyTaken(email) {
86 | c.JSON(http.StatusOK, gin.H{
87 | "success": false,
88 | "message": "该邮箱地址未注册",
89 | })
90 | return
91 | }
92 | code := common.GenerateVerificationCode(0)
93 | common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
94 | link := fmt.Sprintf("%s/api/user/reset?email=%s&token=%s", common.ServerAddress, email, code)
95 | subject := fmt.Sprintf("%s密码重置", common.SystemName)
96 | content := fmt.Sprintf("您好,你正在进行%s密码重置。
"+
97 | "点击此处系统后系统将为你生成一个新的密码,如不需要请勿点击。
"+
98 | "重置链接 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, link, common.VerificationValidMinutes)
99 | err := common.SendEmail(subject, email, content)
100 | if err != nil {
101 | c.JSON(http.StatusOK, gin.H{
102 | "success": false,
103 | "message": err.Error(),
104 | })
105 | return
106 | }
107 | c.JSON(http.StatusOK, gin.H{
108 | "success": true,
109 | "message": "",
110 | })
111 | return
112 | }
113 |
114 | func SendNewPasswordEmail(c *gin.Context) {
115 | email := c.Query("email")
116 | token := c.Query("token")
117 | if email == "" || token == "" {
118 | c.JSON(http.StatusOK, gin.H{
119 | "success": false,
120 | "message": "无效的参数",
121 | })
122 | return
123 | }
124 | if !common.VerifyCodeWithKey(email, token, common.PasswordResetPurpose) {
125 | c.JSON(http.StatusOK, gin.H{
126 | "success": false,
127 | "message": "重置链接非法或已过期",
128 | })
129 | return
130 | }
131 | password := common.GenerateVerificationCode(12)
132 | subject := fmt.Sprintf("%s密码已重置", common.SystemName)
133 | content := fmt.Sprintf("您好,系统已为您重置了密码。
"+
134 | "新的密码为:%s
"+
135 | "请及时登录系统修改密码。
", password)
136 | err := common.SendEmail(subject, email, content)
137 | if err != nil {
138 | c.JSON(http.StatusOK, gin.H{
139 | "success": false,
140 | "message": err.Error(),
141 | })
142 | return
143 | }
144 | err = model.ResetUserPasswordByEmail(email, password)
145 | if err != nil {
146 | c.JSON(http.StatusOK, gin.H{
147 | "success": false,
148 | "message": err.Error(),
149 | })
150 | return
151 | }
152 | common.DeleteKey(email, common.PasswordResetPurpose)
153 | c.Redirect(http.StatusSeeOther, "/")
154 | return
155 | }
156 |
--------------------------------------------------------------------------------
/controller/oauth.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "github.com/gin-gonic/gin"
8 | "net/http"
9 | "time"
10 | "wechat-server/common"
11 | "wechat-server/model"
12 | )
13 |
14 | type GitHubOAuthResponse struct {
15 | AccessToken string `json:"access_token"`
16 | Scope string `json:"scope"`
17 | TokenType string `json:"token_type"`
18 | }
19 |
20 | type GitHubUser struct {
21 | Login string `json:"login"`
22 | Name string `json:"name"`
23 | Email string `json:"email"`
24 | }
25 |
26 | func GitHubOAuth(c *gin.Context) {
27 | code := c.Query("code")
28 | if code == "" {
29 | c.JSON(http.StatusOK, gin.H{
30 | "success": false,
31 | "message": "非法的参数",
32 | })
33 | return
34 | }
35 | values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
36 | jsonData, err := json.Marshal(values)
37 | if err != nil {
38 | c.JSON(http.StatusOK, gin.H{
39 | "success": false,
40 | "message": err.Error(),
41 | })
42 | return
43 | }
44 | req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
45 | if err != nil {
46 | c.JSON(http.StatusOK, gin.H{
47 | "success": false,
48 | "message": err.Error(),
49 | })
50 | return
51 | }
52 | req.Header.Set("Content-Type", "application/json")
53 | req.Header.Set("Accept", "application/json")
54 | client := http.Client{
55 | Timeout: 5 * time.Second,
56 | }
57 | res, err := client.Do(req)
58 | if err != nil {
59 | c.JSON(http.StatusOK, gin.H{
60 | "success": false,
61 | "message": err.Error(),
62 | })
63 | return
64 | }
65 | defer res.Body.Close()
66 | var oAuthResponse GitHubOAuthResponse
67 | err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
68 | if err != nil {
69 | c.JSON(http.StatusOK, gin.H{
70 | "success": false,
71 | "message": err.Error(),
72 | })
73 | return
74 | }
75 | req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
76 | if err != nil {
77 | c.JSON(http.StatusOK, gin.H{
78 | "success": false,
79 | "message": err.Error(),
80 | })
81 | return
82 | }
83 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
84 | if err != nil {
85 | c.JSON(http.StatusOK, gin.H{
86 | "success": false,
87 | "message": err.Error(),
88 | })
89 | return
90 | }
91 | res2, err := client.Do(req)
92 | if err != nil {
93 | c.JSON(http.StatusOK, gin.H{
94 | "success": false,
95 | "message": err.Error(),
96 | })
97 | return
98 | }
99 | defer res2.Body.Close()
100 | var githubUser GitHubUser
101 | err = json.NewDecoder(res2.Body).Decode(&githubUser)
102 | if err != nil {
103 | c.JSON(http.StatusOK, gin.H{
104 | "success": false,
105 | "message": err.Error(),
106 | })
107 | return
108 | }
109 | user := model.User{
110 | Email: githubUser.Email,
111 | }
112 | if githubUser.Email != "" && model.IsEmailAlreadyTaken(githubUser.Email) {
113 | user.FillUserByEmail()
114 | } else {
115 | if githubUser.Login == "" {
116 | c.JSON(http.StatusOK, gin.H{
117 | "success": false,
118 | "message": "返回值非法,用户字段为空",
119 | })
120 | return
121 | }
122 | user.Username = "github_" + githubUser.Login
123 | if model.IsUsernameAlreadyTaken(user.Username) {
124 | user.FillUserByUsername()
125 | } else {
126 | user.DisplayName = githubUser.Name
127 | user.Role = common.RoleCommonUser
128 | user.Status = common.UserStatusEnabled
129 |
130 | if !common.RegisterEnabled {
131 | c.JSON(http.StatusOK, gin.H{
132 | "success": false,
133 | "message": "管理员关闭了新用户注册",
134 | })
135 | return
136 | }
137 |
138 | if err := user.Insert(); err != nil {
139 | c.JSON(http.StatusOK, gin.H{
140 | "success": false,
141 | "message": err.Error(),
142 | })
143 | return
144 | }
145 | }
146 | }
147 | if user.Status != common.UserStatusEnabled {
148 | c.JSON(http.StatusOK, gin.H{
149 | "message": "用户已被封禁",
150 | "success": false,
151 | })
152 | return
153 | }
154 | setupLogin(&user, c)
155 | }
156 |
--------------------------------------------------------------------------------
/controller/option.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "github.com/gin-gonic/gin"
8 | "net/http"
9 | "strings"
10 | "wechat-server/common"
11 | "wechat-server/model"
12 | )
13 |
14 | func GetOptions(c *gin.Context) {
15 | var options []*model.Option
16 | common.OptionMapRWMutex.Lock()
17 | for k, v := range common.OptionMap {
18 | if strings.Contains(k, "Token") || strings.Contains(k, "Secret") || strings.Contains(k, "Key") {
19 | continue
20 | }
21 | options = append(options, &model.Option{
22 | Key: k,
23 | Value: common.Interface2String(v),
24 | })
25 | }
26 | common.OptionMapRWMutex.Unlock()
27 | c.JSON(http.StatusOK, gin.H{
28 | "success": true,
29 | "message": "",
30 | "data": options,
31 | })
32 | return
33 | }
34 |
35 | func UpdateOption(c *gin.Context) {
36 | var option model.Option
37 | err := json.NewDecoder(c.Request.Body).Decode(&option)
38 | if err != nil {
39 | c.JSON(http.StatusBadRequest, gin.H{
40 | "success": false,
41 | "message": "无效的参数",
42 | })
43 | return
44 | }
45 | if option.Key == "GitHubOAuthEnabled" && option.Value == "true" && common.GitHubClientId == "" {
46 | c.JSON(http.StatusOK, gin.H{
47 | "success": false,
48 | "message": "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!",
49 | })
50 | return
51 | }
52 | err = model.UpdateOption(option.Key, option.Value)
53 | if err != nil {
54 | c.JSON(http.StatusOK, gin.H{
55 | "success": false,
56 | "message": err.Error(),
57 | })
58 | return
59 | }
60 | if option.Key == "WeChatMenu" {
61 | httpResponse, err := http.Post(fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/menu/create?access_token=%s", common.GetAccessToken()), "application/json", bytes.NewBuffer([]byte(option.Value)))
62 | if err != nil {
63 | c.JSON(http.StatusOK, gin.H{
64 | "success": false,
65 | "message": err.Error(),
66 | })
67 | return
68 | }
69 | defer httpResponse.Body.Close()
70 | var res wechatResponse
71 | err = json.NewDecoder(httpResponse.Body).Decode(&res)
72 | if err != nil {
73 | c.JSON(http.StatusOK, gin.H{
74 | "success": false,
75 | "message": err.Error(),
76 | })
77 | return
78 | }
79 | if res.ErrCode != 0 {
80 | c.JSON(http.StatusOK, gin.H{
81 | "success": false,
82 | "message": res.ErrMsg,
83 | })
84 | return
85 | }
86 | }
87 | c.JSON(http.StatusOK, gin.H{
88 | "success": true,
89 | "message": "",
90 | })
91 | return
92 | }
93 |
--------------------------------------------------------------------------------
/controller/user.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/gin-contrib/sessions"
6 | "github.com/gin-gonic/gin"
7 | "github.com/google/uuid"
8 | "net/http"
9 | "strconv"
10 | "strings"
11 | "wechat-server/common"
12 | "wechat-server/model"
13 | )
14 |
15 | type LoginRequest struct {
16 | Username string `json:"username"`
17 | Password string `json:"password"`
18 | }
19 |
20 | func Login(c *gin.Context) {
21 | if !common.PasswordLoginEnabled {
22 | c.JSON(http.StatusOK, gin.H{
23 | "message": "管理员关闭了密码登录",
24 | "success": false,
25 | })
26 | return
27 | }
28 | var loginRequest LoginRequest
29 | err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
30 | if err != nil {
31 | c.JSON(http.StatusOK, gin.H{
32 | "message": "无效的参数",
33 | "success": false,
34 | })
35 | return
36 | }
37 | username := loginRequest.Username
38 | password := loginRequest.Password
39 | if username == "" || password == "" {
40 | c.JSON(http.StatusOK, gin.H{
41 | "message": "无效的参数",
42 | "success": false,
43 | })
44 | return
45 | }
46 | user := model.User{
47 | Username: username,
48 | Password: password,
49 | }
50 | err = user.ValidateAndFill()
51 | if err != nil {
52 | c.JSON(http.StatusOK, gin.H{
53 | "message": err.Error(),
54 | "success": false,
55 | })
56 | return
57 | }
58 | setupLogin(&user, c)
59 | }
60 |
61 | // setup session & cookies and then return user info
62 | func setupLogin(user *model.User, c *gin.Context) {
63 | session := sessions.Default(c)
64 | session.Set("id", user.Id)
65 | session.Set("username", user.Username)
66 | session.Set("role", user.Role)
67 | session.Set("status", user.Status)
68 | err := session.Save()
69 | if err != nil {
70 | c.JSON(http.StatusOK, gin.H{
71 | "message": "无法保存会话信息,请重试",
72 | "success": false,
73 | })
74 | return
75 | }
76 | user.Password = ""
77 | user.Token = ""
78 | c.JSON(http.StatusOK, gin.H{
79 | "message": "",
80 | "success": true,
81 | "data": user,
82 | })
83 | }
84 |
85 | func Logout(c *gin.Context) {
86 | session := sessions.Default(c)
87 | session.Options(sessions.Options{MaxAge: -1})
88 | err := session.Save()
89 | if err != nil {
90 | c.JSON(http.StatusOK, gin.H{
91 | "message": err.Error(),
92 | "success": false,
93 | })
94 | return
95 | }
96 | c.JSON(http.StatusOK, gin.H{
97 | "message": "",
98 | "success": true,
99 | })
100 | }
101 |
102 | func Register(c *gin.Context) {
103 | if !common.RegisterEnabled {
104 | c.JSON(http.StatusOK, gin.H{
105 | "message": "管理员关闭了新用户注册",
106 | "success": false,
107 | })
108 | return
109 | }
110 | var user model.User
111 | err := json.NewDecoder(c.Request.Body).Decode(&user)
112 | if err != nil {
113 | c.JSON(http.StatusOK, gin.H{
114 | "success": false,
115 | "message": "无效的参数",
116 | })
117 | return
118 | }
119 | if err := common.Validate.Struct(&user); err != nil {
120 | c.JSON(http.StatusOK, gin.H{
121 | "success": false,
122 | "message": err.Error(),
123 | })
124 | return
125 | }
126 | if common.EmailVerificationEnabled {
127 | if user.Email == "" || user.VerificationCode == "" {
128 | c.JSON(http.StatusOK, gin.H{
129 | "success": false,
130 | "message": "管理员开启了邮箱验证,请输入邮箱地址和验证码",
131 | })
132 | return
133 | }
134 | if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {
135 | c.JSON(http.StatusOK, gin.H{
136 | "success": false,
137 | "message": "验证码错误!",
138 | })
139 | return
140 | }
141 | }
142 | cleanUser := model.User{
143 | Username: user.Username,
144 | Password: user.Password,
145 | DisplayName: user.Username,
146 | }
147 | if common.EmailVerificationEnabled {
148 | cleanUser.Email = user.Email
149 | }
150 | if err := cleanUser.Insert(); err != nil {
151 | c.JSON(http.StatusOK, gin.H{
152 | "success": false,
153 | "message": err.Error(),
154 | })
155 | return
156 | }
157 |
158 | c.JSON(http.StatusOK, gin.H{
159 | "success": true,
160 | "message": "",
161 | })
162 | return
163 | }
164 |
165 | func GetAllUsers(c *gin.Context) {
166 | users, err := model.GetAllUsers()
167 | if err != nil {
168 | c.JSON(http.StatusOK, gin.H{
169 | "success": false,
170 | "message": err.Error(),
171 | })
172 | return
173 | }
174 | c.JSON(http.StatusOK, gin.H{
175 | "success": true,
176 | "message": "",
177 | "data": users,
178 | })
179 | return
180 | }
181 |
182 | func GetUser(c *gin.Context) {
183 | id, err := strconv.Atoi(c.Param("id"))
184 | if err != nil {
185 | c.JSON(http.StatusOK, gin.H{
186 | "success": false,
187 | "message": err.Error(),
188 | })
189 | return
190 | }
191 | user, err := model.GetUserById(id, false)
192 | if err != nil {
193 | c.JSON(http.StatusOK, gin.H{
194 | "success": false,
195 | "message": err.Error(),
196 | })
197 | return
198 | }
199 | myRole := c.GetInt("role")
200 | if myRole <= user.Role {
201 | c.JSON(http.StatusOK, gin.H{
202 | "success": false,
203 | "message": "无权获取同级或更高等级用户的信息",
204 | })
205 | return
206 | }
207 | c.JSON(http.StatusOK, gin.H{
208 | "success": true,
209 | "message": "",
210 | "data": user,
211 | })
212 | return
213 | }
214 |
215 | func GenerateToken(c *gin.Context) {
216 | id := c.GetInt("id")
217 | user, err := model.GetUserById(id, true)
218 | if err != nil {
219 | c.JSON(http.StatusOK, gin.H{
220 | "success": false,
221 | "message": err.Error(),
222 | })
223 | return
224 | }
225 | user.Token = uuid.New().String()
226 | user.Token = strings.Replace(user.Token, "-", "", -1)
227 |
228 | if model.DB.Where("token = ?", user.Token).First(user).RowsAffected != 0 {
229 | c.JSON(http.StatusOK, gin.H{
230 | "success": false,
231 | "message": "请重试,系统生成的 UUID 竟然重复了!",
232 | })
233 | return
234 | }
235 |
236 | if err := user.Update(false); err != nil {
237 | c.JSON(http.StatusOK, gin.H{
238 | "success": false,
239 | "message": err.Error(),
240 | })
241 | return
242 | }
243 |
244 | c.JSON(http.StatusOK, gin.H{
245 | "success": true,
246 | "message": "",
247 | "data": user.Token,
248 | })
249 | return
250 | }
251 |
252 | func GetSelf(c *gin.Context) {
253 | id := c.GetInt("id")
254 | user, err := model.GetUserById(id, true)
255 | if err != nil {
256 | c.JSON(http.StatusOK, gin.H{
257 | "success": false,
258 | "message": err.Error(),
259 | })
260 | return
261 | }
262 | c.JSON(http.StatusOK, gin.H{
263 | "success": true,
264 | "message": "",
265 | "data": user,
266 | })
267 | return
268 | }
269 |
270 | func UpdateUser(c *gin.Context) {
271 | var updatedUser model.User
272 | err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
273 | if err != nil || updatedUser.Id == 0 {
274 | c.JSON(http.StatusOK, gin.H{
275 | "success": false,
276 | "message": "无效的参数",
277 | })
278 | return
279 | }
280 | originUser, err := model.GetUserById(updatedUser.Id, false)
281 | if err != nil {
282 | c.JSON(http.StatusOK, gin.H{
283 | "success": false,
284 | "message": err.Error(),
285 | })
286 | return
287 | }
288 | myRole := c.GetInt("role")
289 | if myRole <= originUser.Role {
290 | c.JSON(http.StatusOK, gin.H{
291 | "success": false,
292 | "message": "无权更新同权限等级或更高权限等级的用户信息",
293 | })
294 | return
295 | }
296 | if myRole <= updatedUser.Role {
297 | c.JSON(http.StatusOK, gin.H{
298 | "success": false,
299 | "message": "无权将其他用户权限等级提升到大于等于自己的权限等级",
300 | })
301 | return
302 | }
303 | updatePassword := updatedUser.Password != ""
304 | if err := updatedUser.Update(updatePassword); err != nil {
305 | c.JSON(http.StatusOK, gin.H{
306 | "success": false,
307 | "message": err.Error(),
308 | })
309 | return
310 | }
311 | c.JSON(http.StatusOK, gin.H{
312 | "success": true,
313 | "message": "",
314 | })
315 | return
316 | }
317 |
318 | func UpdateSelf(c *gin.Context) {
319 | var user model.User
320 | err := json.NewDecoder(c.Request.Body).Decode(&user)
321 | if err != nil {
322 | c.JSON(http.StatusOK, gin.H{
323 | "success": false,
324 | "message": "无效的参数",
325 | })
326 | return
327 | }
328 | // User cannot change its id, role, status itself
329 | user.Id = c.GetInt("id")
330 | user.Role = c.GetInt("role")
331 | user.Status = c.GetInt("status")
332 |
333 | updatePassword := user.Password != ""
334 | // TODO: check Display Name to avoid XSS attack
335 | if err := user.Update(updatePassword); err != nil {
336 | c.JSON(http.StatusOK, gin.H{
337 | "success": false,
338 | "message": err.Error(),
339 | })
340 | return
341 | }
342 |
343 | c.JSON(http.StatusOK, gin.H{
344 | "success": true,
345 | "message": "",
346 | })
347 | return
348 | }
349 |
350 | func DeleteUser(c *gin.Context) {
351 | id, err := strconv.Atoi(c.Param("id"))
352 | if err != nil {
353 | c.JSON(http.StatusOK, gin.H{
354 | "success": false,
355 | "message": err.Error(),
356 | })
357 | return
358 | }
359 | originUser, err := model.GetUserById(id, false)
360 | if err != nil {
361 | c.JSON(http.StatusOK, gin.H{
362 | "success": false,
363 | "message": err.Error(),
364 | })
365 | return
366 | }
367 | myRole := c.GetInt("role")
368 | if myRole <= originUser.Role {
369 | c.JSON(http.StatusOK, gin.H{
370 | "success": false,
371 | "message": "无权删除同权限等级或更高权限等级的用户",
372 | })
373 | return
374 | }
375 | err = model.DeleteUserById(id)
376 | if err != nil {
377 | c.JSON(http.StatusOK, gin.H{
378 | "success": true,
379 | "message": "",
380 | })
381 | return
382 | }
383 | }
384 |
385 | func DeleteSelf(c *gin.Context) {
386 | id := c.GetInt("id")
387 | err := model.DeleteUserById(id)
388 | if err != nil {
389 | c.JSON(http.StatusOK, gin.H{
390 | "success": false,
391 | "message": err.Error(),
392 | })
393 | return
394 | }
395 | c.JSON(http.StatusOK, gin.H{
396 | "success": true,
397 | "message": "",
398 | })
399 | return
400 | }
401 |
402 | // CreateUser Only admin user can call this, so we can trust it
403 | func CreateUser(c *gin.Context) {
404 | var user model.User
405 | err := json.NewDecoder(c.Request.Body).Decode(&user)
406 | if err != nil || user.Username == "" || user.Password == "" {
407 | c.JSON(http.StatusOK, gin.H{
408 | "success": false,
409 | "message": "无效的参数",
410 | })
411 | return
412 | }
413 | if user.DisplayName == "" {
414 | user.DisplayName = user.Username
415 | }
416 | myRole := c.GetInt("role")
417 | if user.Role >= myRole {
418 | c.JSON(http.StatusOK, gin.H{
419 | "success": false,
420 | "message": "无法创建权限大于等于自己的用户",
421 | })
422 | return
423 | }
424 |
425 | if err := user.Insert(); err != nil {
426 | c.JSON(http.StatusOK, gin.H{
427 | "success": false,
428 | "message": err.Error(),
429 | })
430 | return
431 | }
432 |
433 | c.JSON(http.StatusOK, gin.H{
434 | "success": true,
435 | "message": "",
436 | })
437 | return
438 | }
439 |
440 | type ManageRequest struct {
441 | Username string `json:"username"`
442 | Action string `json:"action"`
443 | }
444 |
445 | // ManageUser Only admin user can do this
446 | func ManageUser(c *gin.Context) {
447 | var req ManageRequest
448 | err := json.NewDecoder(c.Request.Body).Decode(&req)
449 |
450 | if err != nil {
451 | c.JSON(http.StatusOK, gin.H{
452 | "success": false,
453 | "message": "无效的参数",
454 | })
455 | return
456 | }
457 | user := model.User{
458 | Username: req.Username,
459 | }
460 | // Fill attributes
461 | model.DB.Where(&user).First(&user)
462 | if user.Id == 0 {
463 | c.JSON(http.StatusOK, gin.H{
464 | "success": false,
465 | "message": "用户不存在",
466 | })
467 | return
468 | }
469 | myRole := c.GetInt("role")
470 | if myRole <= user.Role {
471 | c.JSON(http.StatusOK, gin.H{
472 | "success": false,
473 | "message": "无权更新同权限等级或更高权限等级的用户信息",
474 | })
475 | return
476 | }
477 | switch req.Action {
478 | case "disable":
479 | user.Status = common.UserStatusDisabled
480 | case "enable":
481 | user.Status = common.UserStatusEnabled
482 | case "delete":
483 | if err := user.Delete(); err != nil {
484 | c.JSON(http.StatusOK, gin.H{
485 | "success": false,
486 | "message": err.Error(),
487 | })
488 | return
489 | }
490 | case "promote":
491 | if myRole != common.RoleRootUser {
492 | c.JSON(http.StatusOK, gin.H{
493 | "success": false,
494 | "message": "普通管理员用户无法提升其他用户为管理员",
495 | })
496 | return
497 | }
498 | user.Role = common.RoleAdminUser
499 | case "demote":
500 | user.Role = common.RoleCommonUser
501 | }
502 |
503 | if err := user.Update(false); err != nil {
504 | c.JSON(http.StatusOK, gin.H{
505 | "success": false,
506 | "message": err.Error(),
507 | })
508 | return
509 | }
510 |
511 | c.JSON(http.StatusOK, gin.H{
512 | "success": true,
513 | "message": "",
514 | })
515 | return
516 | }
517 |
--------------------------------------------------------------------------------
/controller/wechat.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "crypto/sha1"
5 | "encoding/hex"
6 | "encoding/xml"
7 | "github.com/gin-gonic/gin"
8 | "net/http"
9 | "sort"
10 | "strings"
11 | "time"
12 | "wechat-server/common"
13 | )
14 |
15 | type wechatResponse struct {
16 | ErrCode int `json:"errcode"`
17 | ErrMsg string `json:"errmsg"`
18 | }
19 |
20 | func WeChatVerification(c *gin.Context) {
21 | // https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
22 | signature := c.Query("signature")
23 | timestamp := c.Query("timestamp")
24 | nonce := c.Query("nonce")
25 | echoStr := c.Query("echostr")
26 | arr := []string{common.WeChatToken, timestamp, nonce}
27 | sort.Strings(arr)
28 | str := strings.Join(arr, "")
29 | hash := sha1.Sum([]byte(str))
30 | hexStr := hex.EncodeToString(hash[:])
31 | if signature == hexStr {
32 | c.String(http.StatusOK, echoStr)
33 | } else {
34 | c.Status(http.StatusForbidden)
35 | }
36 | }
37 |
38 | func ProcessWeChatMessage(c *gin.Context) {
39 | var req common.WeChatMessageRequest
40 | err := xml.NewDecoder(c.Request.Body).Decode(&req)
41 | if err != nil {
42 | common.SysError(err.Error())
43 | c.Abort()
44 | return
45 | }
46 | res := common.WeChatMessageResponse{
47 | ToUserName: req.FromUserName,
48 | FromUserName: req.ToUserName,
49 | CreateTime: time.Now().Unix(),
50 | MsgType: "text",
51 | Content: "",
52 | }
53 | common.ProcessWeChatMessage(&req, &res)
54 | if res.Content == "" {
55 | c.String(http.StatusOK, "")
56 | return
57 | }
58 | c.XML(http.StatusOK, &res)
59 | }
60 |
61 | func GetUserIDByCode(c *gin.Context) {
62 | code := c.Query("code")
63 | if code == "" {
64 | c.JSON(http.StatusOK, gin.H{
65 | "message": "无效的参数",
66 | "success": false,
67 | })
68 | return
69 | }
70 | id := common.GetWeChatIDByCode(code)
71 | c.JSON(http.StatusOK, gin.H{
72 | "message": "",
73 | "success": true,
74 | "data": id,
75 | })
76 | return
77 | }
78 |
79 | func GetAccessToken(c *gin.Context) {
80 | accessToken, expiration := common.GetAccessTokenAndExpirationSeconds()
81 | c.JSON(http.StatusOK, gin.H{
82 | "success": true,
83 | "message": "",
84 | "access_token": accessToken,
85 | "expiration": expiration,
86 | })
87 | }
88 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module wechat-server
2 |
3 | // +heroku goVersion go1.18
4 | go 1.18
5 |
6 | require (
7 | github.com/gin-contrib/sessions v0.0.5
8 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19
9 | github.com/gin-gonic/gin v1.9.1
10 | github.com/go-playground/validator/v10 v10.14.0
11 | github.com/go-redis/redis/v8 v8.11.5
12 | github.com/google/uuid v1.3.0
13 | golang.org/x/crypto v0.9.0
14 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
15 | gorm.io/driver/mysql v1.4.3
16 | gorm.io/driver/sqlite v1.4.3
17 | gorm.io/gorm v1.24.0
18 | )
19 |
20 | require (
21 | github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
22 | github.com/bytedance/sonic v1.9.1 // indirect
23 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
24 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
25 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
26 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect
27 | github.com/gin-contrib/sse v0.1.0 // indirect
28 | github.com/go-playground/locales v0.14.1 // indirect
29 | github.com/go-playground/universal-translator v0.18.1 // indirect
30 | github.com/go-sql-driver/mysql v1.6.0 // indirect
31 | github.com/goccy/go-json v0.10.2 // indirect
32 | github.com/gomodule/redigo v2.0.0+incompatible // indirect
33 | github.com/gorilla/context v1.1.1 // indirect
34 | github.com/gorilla/securecookie v1.1.1 // indirect
35 | github.com/gorilla/sessions v1.2.1 // indirect
36 | github.com/jinzhu/inflection v1.0.0 // indirect
37 | github.com/jinzhu/now v1.1.5 // indirect
38 | github.com/json-iterator/go v1.1.12 // indirect
39 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect
40 | github.com/leodido/go-urn v1.2.4 // indirect
41 | github.com/mattn/go-isatty v0.0.19 // indirect
42 | github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
44 | github.com/modern-go/reflect2 v1.0.2 // indirect
45 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect
46 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
47 | github.com/ugorji/go/codec v1.2.11 // indirect
48 | golang.org/x/arch v0.3.0 // indirect
49 | golang.org/x/net v0.10.0 // indirect
50 | golang.org/x/sys v0.8.0 // indirect
51 | golang.org/x/text v0.9.0 // indirect
52 | google.golang.org/protobuf v1.30.0 // indirect
53 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
54 | gopkg.in/yaml.v3 v3.0.1 // indirect
55 | )
56 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
2 | github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
3 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
4 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
5 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
6 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
7 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
8 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
9 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
10 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
15 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
16 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
17 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
18 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
19 | github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
20 | github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
21 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
22 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
23 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 h1:J2LPEOcQmWaooBnBtUDV9KHFEnP5LYTZY03GiQ0oQBw=
24 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg=
25 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
26 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
27 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
28 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
29 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
30 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
31 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
32 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
33 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
34 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
35 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
36 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
37 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
38 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
39 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
40 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
41 | github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
42 | github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
43 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
44 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
45 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
46 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
47 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
48 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
49 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
50 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
51 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
52 | github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
53 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
54 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
55 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
56 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
57 | github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
58 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
59 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
60 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
61 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
62 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
63 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
64 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
65 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
66 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
67 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
68 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
69 | github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
70 | github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
71 | github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
72 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
73 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
74 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
75 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
76 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
77 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
78 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
79 | github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
80 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
81 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
82 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
83 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
84 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
85 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
86 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
87 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
88 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
89 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
90 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
91 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
92 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
93 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
94 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
95 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
96 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
97 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
98 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
99 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
100 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
101 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
102 | golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
103 | golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
104 | golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
105 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
106 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
107 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
108 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
109 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
110 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
111 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
112 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
113 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
114 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
115 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
116 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
117 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
118 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
119 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
120 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
121 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
122 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
123 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
124 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
125 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
126 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
127 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
128 | gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k=
129 | gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
130 | gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
131 | gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
132 | gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
133 | gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
134 | gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
135 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
136 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-contrib/sessions"
6 | "github.com/gin-contrib/sessions/cookie"
7 | "github.com/gin-contrib/sessions/redis"
8 | "github.com/gin-gonic/gin"
9 | "log"
10 | "os"
11 | "strconv"
12 | "wechat-server/common"
13 | "wechat-server/middleware"
14 | "wechat-server/model"
15 | "wechat-server/router"
16 | )
17 |
18 | //go:embed web/build
19 | var buildFS embed.FS
20 |
21 | //go:embed web/build/index.html
22 | var indexPage []byte
23 |
24 | func main() {
25 | common.SetupGinLog()
26 | common.SysLog("system started")
27 | if os.Getenv("GIN_MODE") != "debug" {
28 | gin.SetMode(gin.ReleaseMode)
29 | }
30 | // Initialize SQL Database
31 | err := model.InitDB()
32 | if err != nil {
33 | common.FatalLog(err)
34 | }
35 | defer func() {
36 | err := model.CloseDB()
37 | if err != nil {
38 | common.FatalLog(err)
39 | }
40 | }()
41 |
42 | // Initialize Redis
43 | err = common.InitRedisClient()
44 | if err != nil {
45 | common.FatalLog(err)
46 | }
47 |
48 | // Initialize options
49 | model.InitOptionMap()
50 |
51 | // Initialize access token store
52 | common.InitAccessTokenStore()
53 |
54 | // Initialize HTTP server
55 | server := gin.Default()
56 | server.Use(middleware.CORS())
57 |
58 | // Initialize session store
59 | if common.RedisEnabled {
60 | opt := common.ParseRedisOption()
61 | store, _ := redis.NewStore(opt.MinIdleConns, opt.Network, opt.Addr, opt.Password, []byte(common.SessionSecret))
62 | server.Use(sessions.Sessions("session", store))
63 | } else {
64 | store := cookie.NewStore([]byte(common.SessionSecret))
65 | server.Use(sessions.Sessions("session", store))
66 | }
67 |
68 | router.SetRouter(server, buildFS, indexPage)
69 | var port = os.Getenv("PORT")
70 | if port == "" {
71 | port = strconv.Itoa(*common.Port)
72 | }
73 | //if *common.Host == "localhost" {
74 | // ip := common.GetIp()
75 | // if ip != "" {
76 | // *common.Host = ip
77 | // }
78 | //}
79 | //serverUrl := "http://" + *common.Host + ":" + port + "/"
80 | //if !*common.NoBrowser {
81 | // common.OpenBrowser(serverUrl)
82 | //}
83 | err = server.Run(":" + port)
84 | if err != nil {
85 | log.Println(err)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/middleware/auth.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-contrib/sessions"
5 | "github.com/gin-gonic/gin"
6 | "net/http"
7 | "wechat-server/common"
8 | "wechat-server/model"
9 | )
10 |
11 | func authHelper(c *gin.Context, minRole int) {
12 | session := sessions.Default(c)
13 | username := session.Get("username")
14 | role := session.Get("role")
15 | id := session.Get("id")
16 | status := session.Get("status")
17 | authByToken := false
18 | if username == nil {
19 | // Check token
20 | token := c.Request.Header.Get("Authorization")
21 | if token == "" {
22 | c.JSON(http.StatusOK, gin.H{
23 | "success": false,
24 | "message": "无权进行此操作,未登录或 token 无效",
25 | })
26 | c.Abort()
27 | return
28 | }
29 | user := model.ValidateUserToken(token)
30 | if user != nil && user.Username != "" {
31 | // Token is valid
32 | username = user.Username
33 | role = user.Role
34 | id = user.Id
35 | status = user.Status
36 | } else {
37 | c.JSON(http.StatusOK, gin.H{
38 | "success": false,
39 | "message": "无权进行此操作,未登录或 token 无效",
40 | })
41 | c.Abort()
42 | return
43 | }
44 | authByToken = true
45 | }
46 | if status.(int) == common.UserStatusDisabled {
47 | c.JSON(http.StatusOK, gin.H{
48 | "success": false,
49 | "message": "用户已被封禁",
50 | })
51 | c.Abort()
52 | return
53 | }
54 | if role.(int) < minRole {
55 | c.JSON(http.StatusOK, gin.H{
56 | "success": false,
57 | "message": "无权进行此操作,未登录或 token 无效,或没有权限",
58 | })
59 | c.Abort()
60 | return
61 | }
62 | c.Set("username", username)
63 | c.Set("role", role)
64 | c.Set("id", id)
65 | c.Set("authByToken", authByToken)
66 | c.Next()
67 | }
68 |
69 | func UserAuth() func(c *gin.Context) {
70 | return func(c *gin.Context) {
71 | authHelper(c, common.RoleCommonUser)
72 | }
73 | }
74 |
75 | func AdminAuth() func(c *gin.Context) {
76 | return func(c *gin.Context) {
77 | authHelper(c, common.RoleAdminUser)
78 | }
79 | }
80 |
81 | func RootAuth() func(c *gin.Context) {
82 | return func(c *gin.Context) {
83 | authHelper(c, common.RoleRootUser)
84 | }
85 | }
86 |
87 | // NoTokenAuth You should always use this after normal auth middlewares.
88 | func NoTokenAuth() func(c *gin.Context) {
89 | return func(c *gin.Context) {
90 | authByToken := c.GetBool("authByToken")
91 | if authByToken {
92 | c.JSON(http.StatusOK, gin.H{
93 | "success": false,
94 | "message": "本接口不支持使用 token 进行验证",
95 | })
96 | c.Abort()
97 | return
98 | }
99 | c.Next()
100 | }
101 | }
102 |
103 | // TokenOnlyAuth You should always use this after normal auth middlewares.
104 | func TokenOnlyAuth() func(c *gin.Context) {
105 | return func(c *gin.Context) {
106 | authByToken := c.GetBool("authByToken")
107 | if !authByToken {
108 | c.JSON(http.StatusOK, gin.H{
109 | "success": false,
110 | "message": "本接口仅支持使用 token 进行验证",
111 | })
112 | c.Abort()
113 | return
114 | }
115 | c.Next()
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/middleware/cors.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-gonic/contrib/cors"
5 | "github.com/gin-gonic/gin"
6 | "time"
7 | )
8 |
9 | func CORS() gin.HandlerFunc {
10 | config := cors.DefaultConfig()
11 | config.AllowedHeaders = []string{"Authorization", "Content-Type", "Origin",
12 | "Connection", "Accept-Encoding", "Accept-Language", "Host"}
13 | config.AllowedMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}
14 | config.AllowCredentials = true
15 | config.MaxAge = 12 * time.Hour
16 | // if you want to allow all origins, comment the following two lines
17 | config.AllowAllOrigins = false
18 | config.AllowedOrigins = []string{"https://wechat-server.vercel.app"}
19 | return cors.New(config)
20 | }
21 |
--------------------------------------------------------------------------------
/middleware/rate-limit.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "net/http"
8 | "time"
9 | "wechat-server/common"
10 | )
11 |
12 | var timeFormat = "2006-01-02T15:04:05.000Z"
13 |
14 | var inMemoryRateLimiter common.InMemoryRateLimiter
15 |
16 | func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
17 | ctx := context.Background()
18 | rdb := common.RDB
19 | key := "rateLimit:" + mark + c.ClientIP()
20 | listLength, err := rdb.LLen(ctx, key).Result()
21 | if err != nil {
22 | fmt.Println(err.Error())
23 | c.Status(http.StatusInternalServerError)
24 | c.Abort()
25 | return
26 | }
27 | if listLength < int64(maxRequestNum) {
28 | rdb.LPush(ctx, key, time.Now().Format(timeFormat))
29 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
30 | } else {
31 | oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
32 | oldTime, err := time.Parse(timeFormat, oldTimeStr)
33 | if err != nil {
34 | fmt.Println(err)
35 | c.Status(http.StatusInternalServerError)
36 | c.Abort()
37 | return
38 | }
39 | nowTimeStr := time.Now().Format(timeFormat)
40 | nowTime, err := time.Parse(timeFormat, nowTimeStr)
41 | if err != nil {
42 | fmt.Println(err)
43 | c.Status(http.StatusInternalServerError)
44 | c.Abort()
45 | return
46 | }
47 | // time.Since will return negative number!
48 | // See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows
49 | if int64(nowTime.Sub(oldTime).Seconds()) < duration {
50 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
51 | c.Status(http.StatusTooManyRequests)
52 | c.Abort()
53 | return
54 | } else {
55 | rdb.LPush(ctx, key, time.Now().Format(timeFormat))
56 | rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
57 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
58 | }
59 | }
60 | }
61 |
62 | func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
63 | key := mark + c.ClientIP()
64 | if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {
65 | c.Status(http.StatusTooManyRequests)
66 | c.Abort()
67 | return
68 | }
69 | }
70 |
71 | func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
72 | if common.RedisEnabled {
73 | return func(c *gin.Context) {
74 | redisRateLimiter(c, maxRequestNum, duration, mark)
75 | }
76 | } else {
77 | // It's safe to call multi times.
78 | inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
79 | return func(c *gin.Context) {
80 | memoryRateLimiter(c, maxRequestNum, duration, mark)
81 | }
82 | }
83 | }
84 |
85 | func GlobalWebRateLimit() func(c *gin.Context) {
86 | return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW")
87 | }
88 |
89 | func GlobalAPIRateLimit() func(c *gin.Context) {
90 | return rateLimitFactory(common.GlobalApiRateLimitNum, common.GlobalApiRateLimitDuration, "GA")
91 | }
92 |
93 | func CriticalRateLimit() func(c *gin.Context) {
94 | return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
95 | }
96 |
97 | func DownloadRateLimit() func(c *gin.Context) {
98 | return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW")
99 | }
100 |
101 | func UploadRateLimit() func(c *gin.Context) {
102 | return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP")
103 | }
104 |
--------------------------------------------------------------------------------
/model/file.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | _ "gorm.io/driver/sqlite"
5 | "gorm.io/gorm"
6 | "os"
7 | "path"
8 | "strings"
9 | "wechat-server/common"
10 | )
11 |
12 | type File struct {
13 | Id int `json:"id"`
14 | Filename string `json:"filename"`
15 | Description string `json:"description"`
16 | Uploader string `json:"uploader"`
17 | Link string `json:"link" gorm:"unique"`
18 | Time string `json:"time"`
19 | DownloadCounter int `json:"download_counter"`
20 | }
21 |
22 | func GetAllFiles() ([]*File, error) {
23 | var files []*File
24 | var err error
25 | err = DB.Find(&files).Error
26 | return files, err
27 | }
28 |
29 | func QueryFiles(query string, startIdx int) ([]*File, error) {
30 | var files []*File
31 | var err error
32 | query = strings.ToLower(query)
33 | 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
34 | return files, err
35 | }
36 |
37 | func (file *File) Insert() error {
38 | var err error
39 | err = DB.Create(file).Error
40 | return err
41 | }
42 |
43 | // Delete Make sure link is valid! Because we will use os.Remove to delete it!
44 | func (file *File) Delete() error {
45 | var err error
46 | err = DB.Delete(file).Error
47 | err = os.Remove(path.Join(common.UploadPath, file.Link))
48 | return err
49 | }
50 |
51 | func UpdateDownloadCounter(link string) {
52 | DB.Model(&File{}).Where("link = ?", link).UpdateColumn("download_counter", gorm.Expr("download_counter + 1"))
53 | }
54 |
--------------------------------------------------------------------------------
/model/main.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/driver/mysql"
5 | "gorm.io/driver/sqlite"
6 | "gorm.io/gorm"
7 | "os"
8 | "wechat-server/common"
9 | )
10 |
11 | var DB *gorm.DB
12 |
13 | func createRootAccountIfNeed() error {
14 | var user User
15 | //if user.Status != common.UserStatusEnabled {
16 | if err := DB.First(&user).Error; err != nil {
17 | hashedPassword, err := common.Password2Hash("123456")
18 | if err != nil {
19 | return err
20 | }
21 | rootUser := User{
22 | Username: "root",
23 | Password: hashedPassword,
24 | Role: common.RoleRootUser,
25 | Status: common.UserStatusEnabled,
26 | DisplayName: "Root User",
27 | }
28 | DB.Create(&rootUser)
29 | }
30 | return nil
31 | }
32 |
33 | func CountTable(tableName string) (num int64) {
34 | DB.Table(tableName).Count(&num)
35 | return
36 | }
37 |
38 | func InitDB() (err error) {
39 | var db *gorm.DB
40 | if os.Getenv("SQL_DSN") != "" {
41 | // Use MySQL
42 | db, err = gorm.Open(mysql.Open(os.Getenv("SQL_DSN")), &gorm.Config{
43 | PrepareStmt: true, // precompile SQL
44 | })
45 | } else {
46 | // Use SQLite
47 | db, err = gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
48 | PrepareStmt: true, // precompile SQL
49 | })
50 | common.SysLog("SQL_DSN not set, using SQLite as database")
51 | }
52 | if err == nil {
53 | DB = db
54 | err := db.AutoMigrate(&File{})
55 | if err != nil {
56 | return err
57 | }
58 | err = db.AutoMigrate(&User{})
59 | if err != nil {
60 | return err
61 | }
62 | err = db.AutoMigrate(&Option{})
63 | if err != nil {
64 | return err
65 | }
66 | err = createRootAccountIfNeed()
67 | return err
68 | } else {
69 | common.FatalLog(err)
70 | }
71 | return err
72 | }
73 |
74 | func CloseDB() error {
75 | sqlDB, err := DB.DB()
76 | if err != nil {
77 | return err
78 | }
79 | err = sqlDB.Close()
80 | return err
81 | }
82 |
--------------------------------------------------------------------------------
/model/option.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "errors"
5 | "strconv"
6 | "strings"
7 | "wechat-server/common"
8 | )
9 |
10 | type Option struct {
11 | Key string `json:"key" gorm:"primaryKey"`
12 | Value string `json:"value"`
13 | }
14 |
15 | func AllOption() ([]*Option, error) {
16 | var options []*Option
17 | var err error
18 | err = DB.Find(&options).Error
19 | return options, err
20 | }
21 |
22 | func InitOptionMap() {
23 | common.OptionMapRWMutex.Lock()
24 | common.OptionMap = make(map[string]string)
25 | common.OptionMap["FileUploadPermission"] = strconv.Itoa(common.FileUploadPermission)
26 | common.OptionMap["FileDownloadPermission"] = strconv.Itoa(common.FileDownloadPermission)
27 | common.OptionMap["ImageUploadPermission"] = strconv.Itoa(common.ImageUploadPermission)
28 | common.OptionMap["ImageDownloadPermission"] = strconv.Itoa(common.ImageDownloadPermission)
29 | common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled)
30 | common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
31 | common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
32 | common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
33 | common.OptionMap["SMTPServer"] = ""
34 | common.OptionMap["SMTPAccount"] = ""
35 | common.OptionMap["SMTPToken"] = ""
36 | common.OptionMap["Notice"] = ""
37 | common.OptionMap["FooterHTML"] = common.FooterHTML
38 | common.OptionMap["ServerAddress"] = ""
39 | common.OptionMap["GitHubClientId"] = ""
40 | common.OptionMap["GitHubClientSecret"] = ""
41 | common.OptionMap["WeChatToken"] = ""
42 | common.OptionMap["WeChatAppID"] = ""
43 | common.OptionMap["WeChatAppSecret"] = ""
44 | common.OptionMap["WeChatEncodingAESKey"] = ""
45 | common.OptionMap["WeChatOwnerID"] = ""
46 | common.OptionMap["WeChatMenu"] = common.WeChatMenu
47 | common.OptionMapRWMutex.Unlock()
48 | options, _ := AllOption()
49 | for _, option := range options {
50 | updateOptionMap(option.Key, option.Value)
51 | }
52 | }
53 |
54 | func UpdateOption(key string, value string) error {
55 | if key == "StatEnabled" && value == "true" && !common.RedisEnabled {
56 | return errors.New("未启用 Redis,无法启用统计功能")
57 | }
58 |
59 | // Save to database first
60 | option := Option{
61 | Key: key,
62 | Value: value,
63 | }
64 | // When updating with struct it will only update non-zero fields by default
65 | // So we have to use Select here
66 | if DB.Model(&option).Where("key = ?", key).Update("value", option.Value).RowsAffected == 0 {
67 | DB.Create(&option)
68 | }
69 | // Update OptionMap
70 | updateOptionMap(key, value)
71 | return nil
72 | }
73 |
74 | func updateOptionMap(key string, value string) {
75 | common.OptionMapRWMutex.Lock()
76 | defer common.OptionMapRWMutex.Unlock()
77 | common.OptionMap[key] = value
78 | if strings.HasSuffix(key, "Permission") {
79 | intValue, _ := strconv.Atoi(value)
80 | switch key {
81 | case "FileUploadPermission":
82 | common.FileUploadPermission = intValue
83 | case "FileDownloadPermission":
84 | common.FileDownloadPermission = intValue
85 | case "ImageUploadPermission":
86 | common.ImageUploadPermission = intValue
87 | case "ImageDownloadPermission":
88 | common.ImageDownloadPermission = intValue
89 | }
90 | }
91 | boolValue := value == "true"
92 | switch key {
93 | case "RegisterEnabled":
94 | common.RegisterEnabled = boolValue
95 | case "PasswordLoginEnabled":
96 | common.PasswordLoginEnabled = boolValue
97 | case "EmailVerificationEnabled":
98 | common.EmailVerificationEnabled = boolValue
99 | case "GitHubOAuthEnabled":
100 | common.GitHubOAuthEnabled = boolValue
101 | case "SMTPServer":
102 | common.SMTPServer = value
103 | case "SMTPAccount":
104 | common.SMTPAccount = value
105 | case "SMTPToken":
106 | common.SMTPToken = value
107 | case "ServerAddress":
108 | common.ServerAddress = value
109 | case "GitHubClientId":
110 | common.GitHubClientId = value
111 | case "GitHubClientSecret":
112 | common.GitHubClientSecret = value
113 | case "FooterHTML":
114 | common.FooterHTML = value
115 | case "WeChatToken":
116 | common.WeChatToken = value
117 | case "WeChatAppID":
118 | common.WeChatAppID = value
119 | case "WeChatAppSecret":
120 | common.WeChatAppSecret = value
121 | case "WeChatEncodingAESKey":
122 | common.WeChatEncodingAESKey = value
123 | case "WeChatOwnerID":
124 | common.WeChatOwnerID = value
125 | case "WeChatMenu":
126 | common.WeChatMenu = value
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/model/user.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "errors"
5 | "strings"
6 | "wechat-server/common"
7 | )
8 |
9 | type User struct {
10 | Id int `json:"id"`
11 | Username string `json:"username" gorm:"unique;uniqueIndex" validate:"printascii"`
12 | Password string `json:"password" gorm:"not null;" validate:"min=8"`
13 | DisplayName string `json:"display_name"`
14 | Role int `json:"role" gorm:"type:int;default:1"` // admin, common
15 | Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
16 | Token string `json:"token;" gorm:"index"`
17 | Email string `json:"email" gorm:"index"`
18 | VerificationCode string `json:"verification_code" gorm:"-:all"`
19 | }
20 |
21 | func GetAllUsers() (users []*User, err error) {
22 | err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email"}).Find(&users).Error
23 | return users, err
24 | }
25 |
26 | func GetUserById(id int, selectAll bool) (*User, error) {
27 | user := User{Id: id}
28 | var err error = nil
29 | if selectAll {
30 | err = DB.First(&user, "id = ?", id).Error
31 | } else {
32 | err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email"}).First(&user, "id = ?", id).Error
33 | }
34 | return &user, err
35 | }
36 |
37 | func DeleteUserById(id int) (err error) {
38 | user := User{Id: id}
39 | err = DB.Delete(&user).Error
40 | return err
41 | }
42 |
43 | func QueryUsers(query string, startIdx int) (users []*User, err error) {
44 | query = strings.ToLower(query)
45 | err = DB.Limit(common.ItemsPerPage).Offset(startIdx).Where("username LIKE ? or display_name LIKE ?", "%"+query+"%", "%"+query+"%").Order("id desc").Find(&users).Error
46 | return users, err
47 | }
48 |
49 | func (user *User) Insert() error {
50 | var err error
51 | if user.Password != "" {
52 | user.Password, err = common.Password2Hash(user.Password)
53 | if err != nil {
54 | return err
55 | }
56 | }
57 | err = DB.Create(user).Error
58 | return err
59 | }
60 |
61 | func (user *User) Update(updatePassword bool) error {
62 | var err error
63 | if updatePassword {
64 | user.Password, err = common.Password2Hash(user.Password)
65 | if err != nil {
66 | return err
67 | }
68 | }
69 | err = DB.Model(user).Updates(user).Error
70 | return err
71 | }
72 |
73 | func (user *User) Delete() error {
74 | var err error
75 | err = DB.Delete(user).Error
76 | return err
77 | }
78 |
79 | // ValidateAndFill check password & user status
80 | func (user *User) ValidateAndFill() (err error) {
81 | // When querying with struct, GORM will only query with non-zero fields,
82 | // that means if your field’s value is 0, '', false or other zero values,
83 | // it won’t be used to build query conditions
84 | password := user.Password
85 | DB.Where(User{Username: user.Username}).First(user)
86 | okay := common.ValidatePasswordAndHash(password, user.Password)
87 | if !okay || user.Status != common.UserStatusEnabled {
88 | return errors.New("用户名或密码错误,或者该用户已被封禁")
89 | }
90 | return nil
91 | }
92 |
93 | func (user *User) FillUserByEmail() {
94 | DB.Where(User{Email: user.Email}).First(user)
95 | }
96 |
97 | func (user *User) FillUserByUsername() {
98 | DB.Where(User{Username: user.Username}).First(user)
99 | }
100 |
101 | func ValidateUserToken(token string) (user *User) {
102 | if token == "" {
103 | return nil
104 | }
105 | token = strings.Replace(token, "Bearer ", "", 1)
106 | user = &User{}
107 | if DB.Where("token = ?", token).First(user).RowsAffected == 1 {
108 | return user
109 | }
110 | return nil
111 | }
112 |
113 | func IsEmailAlreadyTaken(email string) bool {
114 | return DB.Where("email = ?", email).Find(&User{}).RowsAffected == 1
115 | }
116 |
117 | func IsUsernameAlreadyTaken(username string) bool {
118 | return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1
119 | }
120 |
121 | func ResetUserPasswordByEmail(email string, password string) error {
122 | hashedPassword, err := common.Password2Hash(password)
123 | if err != nil {
124 | return err
125 | }
126 | err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error
127 | return err
128 | }
129 |
--------------------------------------------------------------------------------
/router/api-router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "wechat-server/controller"
6 | "wechat-server/middleware"
7 | )
8 |
9 | func SetApiRouter(router *gin.Engine) {
10 | apiRouter := router.Group("/api")
11 | apiRouter.Use(middleware.GlobalAPIRateLimit())
12 | {
13 | apiRouter.GET("/status", controller.GetStatus)
14 | apiRouter.GET("/notice", controller.GetNotice)
15 | apiRouter.GET("/wechat", controller.WeChatVerification)
16 | apiRouter.POST("/wechat", controller.ProcessWeChatMessage)
17 | apiRouter.GET("/verification", middleware.CriticalRateLimit(), controller.SendEmailVerification)
18 | apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), controller.SendPasswordResetEmail)
19 | apiRouter.GET("/user/reset", controller.SendNewPasswordEmail)
20 | apiRouter.GET("/oauth/github", controller.GitHubOAuth)
21 |
22 | userRoute := apiRouter.Group("/user")
23 | {
24 | userRoute.POST("/register", middleware.CriticalRateLimit(), controller.Register)
25 | userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login)
26 | userRoute.GET("/logout", controller.Logout)
27 |
28 | selfRoute := userRoute.Group("/")
29 | selfRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
30 | {
31 | selfRoute.GET("/self", controller.GetSelf)
32 | selfRoute.PUT("/self", controller.UpdateSelf)
33 | selfRoute.DELETE("/self", controller.DeleteSelf)
34 | selfRoute.GET("/token", controller.GenerateToken)
35 | }
36 |
37 | adminRoute := userRoute.Group("/")
38 | adminRoute.Use(middleware.AdminAuth(), middleware.NoTokenAuth())
39 | {
40 | adminRoute.GET("/", controller.GetAllUsers)
41 | adminRoute.GET("/:id", controller.GetUser)
42 | adminRoute.POST("/", controller.CreateUser)
43 | adminRoute.POST("/manage", controller.ManageUser)
44 | adminRoute.PUT("/", controller.UpdateUser)
45 | adminRoute.DELETE("/:id", controller.DeleteUser)
46 | }
47 | }
48 | optionRoute := apiRouter.Group("/option")
49 | optionRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
50 | {
51 | optionRoute.GET("/", controller.GetOptions)
52 | optionRoute.PUT("/", controller.UpdateOption)
53 | }
54 | fileRoute := apiRouter.Group("/file")
55 | {
56 | fileRoute.GET("/:id", middleware.DownloadRateLimit(), controller.DownloadFile)
57 | fileRoute.POST("/", middleware.UserAuth(), middleware.UploadRateLimit(), controller.UploadFile)
58 | fileRoute.DELETE("/:id", middleware.UserAuth(), controller.DeleteFile)
59 | }
60 | wechatRoute := apiRouter.Group("/wechat")
61 | wechatRoute.Use(middleware.AdminAuth(), middleware.TokenOnlyAuth())
62 | {
63 | wechatRoute.GET("/access_token", controller.GetAccessToken)
64 | wechatRoute.GET("/user", controller.GetUserIDByCode)
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/router/main.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-gonic/gin"
6 | )
7 |
8 | func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
9 | SetApiRouter(router)
10 | setWebRouter(router, buildFS, indexPage)
11 | }
12 |
--------------------------------------------------------------------------------
/router/web-router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-gonic/contrib/static"
6 | "github.com/gin-gonic/gin"
7 | "net/http"
8 | "wechat-server/common"
9 | "wechat-server/middleware"
10 | )
11 |
12 | func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
13 | router.Use(middleware.GlobalWebRateLimit())
14 | router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
15 | router.NoRoute(func(c *gin.Context) {
16 | c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
17 | })
18 | }
19 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .idea
25 | package-lock.json
26 | yarn.lock
--------------------------------------------------------------------------------
/web/README.md:
--------------------------------------------------------------------------------
1 | # React Template
2 |
3 | ## Basic Usages
4 |
5 | ```shell
6 | # Runs the app in the development mode
7 | npm start
8 |
9 | # Builds the app for production to the `build` folder
10 | npm run build
11 | ```
12 |
13 | If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
14 | for example: `REACT_APP_SERVER=http://your.domain.com`.
15 |
16 | Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
17 |
18 | ## Reference
19 |
20 | 1. https://github.com/OIerDb-ng/OIerDb
21 | 2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-template",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "axios": "^0.27.2",
7 | "history": "^5.3.0",
8 | "marked": "^4.1.1",
9 | "react": "^18.2.0",
10 | "react-dom": "^18.2.0",
11 | "react-router-dom": "^6.3.0",
12 | "react-scripts": "5.0.1",
13 | "react-toastify": "^9.0.8",
14 | "semantic-ui-css": "^2.5.0",
15 | "semantic-ui-react": "^2.1.3"
16 | },
17 | "scripts": {
18 | "start": "react-scripts start",
19 | "build": "react-scripts build",
20 | "test": "react-scripts test",
21 | "eject": "react-scripts eject"
22 | },
23 | "eslintConfig": {
24 | "extends": [
25 | "react-app",
26 | "react-app/jest"
27 | ]
28 | },
29 | "browserslist": {
30 | "production": [
31 | ">0.2%",
32 | "not dead",
33 | "not op_mini all"
34 | ],
35 | "development": [
36 | "last 1 chrome version",
37 | "last 1 firefox version",
38 | "last 1 safari version"
39 | ]
40 | },
41 | "devDependencies": {
42 | "prettier": "^2.7.1"
43 | },
44 | "prettier": {
45 | "singleQuote": true
46 | },
47 | "proxy": "http://localhost:3000"
48 | }
49 |
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/wechat-server/13d0e3264ced535f424c586dd6465f0346e38e1a/web/public/favicon.ico
--------------------------------------------------------------------------------
/web/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 | 微信服务器
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/web/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/wechat-server/13d0e3264ced535f424c586dd6465f0346e38e1a/web/public/logo.png
--------------------------------------------------------------------------------
/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/web/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense, useEffect } from 'react';
2 | import { Route, Routes } from 'react-router-dom';
3 | import Loading from './components/Loading';
4 | import User from './pages/User';
5 | import { PrivateRoute } from './components/PrivateRoute';
6 | import RegisterForm from './components/RegisterForm';
7 | import LoginForm from './components/LoginForm';
8 | import NotFound from './pages/NotFound';
9 | import Setting from './pages/Setting';
10 | import EditUser from './pages/User/EditUser';
11 | import AddUser from './pages/User/AddUser';
12 | import { API, showError } from './helpers';
13 | import PasswordResetForm from './components/PasswordResetForm';
14 | import GitHubOAuth from './components/GitHubOAuth';
15 |
16 | const Home = lazy(() => import('./pages/Home'));
17 | const About = lazy(() => import('./pages/About'));
18 |
19 | function App() {
20 | const loadStatus = async () => {
21 | const res = await API.get('/api/status');
22 | const { success, data } = res.data;
23 | if (success) {
24 | localStorage.setItem('status', JSON.stringify(data));
25 | localStorage.setItem('footer_html', data.footer_html);
26 | } else {
27 | showError('无法正常连接至服务器!');
28 | }
29 | };
30 |
31 | useEffect(() => {
32 | loadStatus().then();
33 | }, []);
34 |
35 | return (
36 |
37 | }>
41 |
42 |
43 | }
44 | />
45 |
49 |
50 |
51 | }
52 | />
53 | }>
57 |
58 |
59 | }
60 | />
61 | }>
65 |
66 |
67 | }
68 | />
69 | }>
73 |
74 |
75 | }
76 | />
77 | }>
81 |
82 |
83 | }
84 | />
85 | }>
89 |
90 |
91 | }
92 | />
93 | }>
97 |
98 |
99 | }
100 | />
101 | }>
105 |
106 |
107 | }
108 | />
109 |
113 | }>
114 |
115 |
116 |
117 | }
118 | />
119 | }>
123 |
124 |
125 | }
126 | />
127 |
128 |
129 | );
130 | }
131 |
132 | export default App;
133 |
--------------------------------------------------------------------------------
/web/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import { Container, Segment } from 'semantic-ui-react';
4 |
5 | const Footer = () => {
6 | const [footerHTML, setFooterHTML] = useState('');
7 | useEffect(() => {
8 | let savedFooterHTML = localStorage.getItem('footer_html');
9 | if (!savedFooterHTML) savedFooterHTML = '';
10 | setFooterHTML(savedFooterHTML);
11 | });
12 |
13 | return (
14 |
15 |
16 | {footerHTML === '' ? (
17 |
33 | ) : (
34 |
38 | )}
39 |
40 |
41 | );
42 | };
43 |
44 | export default Footer;
45 |
--------------------------------------------------------------------------------
/web/src/components/GitHubOAuth.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState } from 'react';
2 | import { Dimmer, Loader, Segment } from 'semantic-ui-react';
3 | import { useNavigate, useSearchParams } from 'react-router-dom';
4 | import { API, showError, showSuccess } from '../helpers';
5 | import { UserContext } from '../context/User';
6 |
7 | const GitHubOAuth = () => {
8 | const [searchParams, setSearchParams] = useSearchParams();
9 |
10 | const [userState, userDispatch] = useContext(UserContext);
11 | const [prompt, setPrompt] = useState('处理中...');
12 | const [processing, setProcessing] = useState(true);
13 |
14 | let navigate = useNavigate();
15 |
16 | const sendCode = async (code, count) => {
17 | const res = await API.get(`/api/oauth/github?code=${code}`);
18 | const { success, message, data } = res.data;
19 | if (success) {
20 | userDispatch({ type: 'login', payload: data });
21 | localStorage.setItem('user', JSON.stringify(data));
22 | navigate('/');
23 | showSuccess('登录成功!');
24 | } else {
25 | showError(message);
26 | if (count === 3) {
27 | setPrompt(`登录失败,重定向至登录界面中...`);
28 | navigate('/login');
29 | return;
30 | }
31 | count++;
32 | setPrompt(`出现错误,第 ${count} 次重试中...`);
33 | await new Promise(resolve => setTimeout(resolve, count * 2000));
34 | await sendCode(code, count);
35 | }
36 | };
37 |
38 | useEffect(() => {
39 | let code = searchParams.get('code');
40 | sendCode(code, 0).then();
41 | }, []);
42 |
43 | return (
44 |
45 |
46 | {prompt}
47 |
48 |
49 | );
50 | };
51 |
52 | export default GitHubOAuth;
53 |
--------------------------------------------------------------------------------
/web/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from 'react';
2 | import { Link, useNavigate } from 'react-router-dom';
3 | import { UserContext } from '../context/User';
4 |
5 | import { Button, Container, Icon, Menu, Segment } from 'semantic-ui-react';
6 | import { API, isAdmin, isMobile, showSuccess } from '../helpers';
7 | import '../index.css';
8 |
9 | // Header Buttons
10 | const headerButtons = [
11 | {
12 | name: '首页',
13 | to: '/',
14 | icon: 'home',
15 | },
16 | {
17 | name: '用户',
18 | to: '/user',
19 | icon: 'user',
20 | admin: true,
21 | },
22 | {
23 | name: '设置',
24 | to: '/setting',
25 | icon: 'setting',
26 | },
27 | ];
28 |
29 | const Header = () => {
30 | const [userState, userDispatch] = useContext(UserContext);
31 | let navigate = useNavigate();
32 | let size = isMobile() ? 'large' : '';
33 |
34 | const [showSidebar, setShowSidebar] = useState(false);
35 |
36 | async function logout() {
37 | setShowSidebar(false);
38 | await API.get('/api/user/logout');
39 | showSuccess('注销成功!');
40 | userDispatch({ type: 'logout' });
41 | localStorage.removeItem('user');
42 | navigate('/login');
43 | }
44 |
45 | const toggleSidebar = () => {
46 | setShowSidebar(!showSidebar);
47 | };
48 |
49 | const renderButtons = (isMobile) => {
50 | return headerButtons.map((button) => {
51 | if (button.admin && !isAdmin()) return <>>;
52 | if (isMobile) {
53 | return (
54 | {
56 | navigate(button.to);
57 | setShowSidebar(false);
58 | }}
59 | >
60 | {button.name}
61 |
62 | );
63 | }
64 | return (
65 |
66 |
67 | {button.name}
68 |
69 | );
70 | });
71 | };
72 |
73 | if (isMobile()) {
74 | return (
75 | <>
76 |
108 | {showSidebar ? (
109 |
110 |
137 |
138 | ) : (
139 | <>>
140 | )}
141 | >
142 | );
143 | }
144 |
145 | return (
146 | <>
147 |
174 | >
175 | );
176 | };
177 |
178 | export default Header;
179 |
--------------------------------------------------------------------------------
/web/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Dimmer, Loader } from 'semantic-ui-react';
3 |
4 | const Loading = ({ prompt: name = 'page' }) => {
5 | return (
6 |
7 |
8 | 加载{name}中...
9 |
10 |
11 | );
12 | };
13 |
14 | export default Loading;
15 |
--------------------------------------------------------------------------------
/web/src/components/LoginForm.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState } from 'react';
2 | import {
3 | Button,
4 | Divider,
5 | Form,
6 | Grid,
7 | Header,
8 | Image,
9 | Message,
10 | Segment,
11 | } from 'semantic-ui-react';
12 | import { Link, useNavigate } from 'react-router-dom';
13 | import { UserContext } from '../context/User';
14 | import { API, showError, showSuccess } from '../helpers';
15 |
16 | const LoginForm = () => {
17 | const [inputs, setInputs] = useState({
18 | username: '',
19 | password: '',
20 | });
21 | const [submitted, setSubmitted] = useState(false);
22 | const { username, password } = inputs;
23 | const [userState, userDispatch] = useContext(UserContext);
24 | let navigate = useNavigate();
25 |
26 | const [status, setStatus] = useState({});
27 |
28 | useEffect(() => {
29 | let status = localStorage.getItem('status');
30 | if (status) {
31 | status = JSON.parse(status);
32 | setStatus(status);
33 | }
34 | }, []);
35 |
36 | const onGitHubOAuthClicked = () => {
37 | window.open(
38 | `https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email`
39 | );
40 | };
41 |
42 | function handleChange(e) {
43 | const { name, value } = e.target;
44 | setInputs((inputs) => ({ ...inputs, [name]: value }));
45 | }
46 |
47 | async function handleSubmit(e) {
48 | setSubmitted(true);
49 | if (username && password) {
50 | const res = await API.post('/api/user/login', {
51 | username,
52 | password,
53 | });
54 | const { success, message, data } = res.data;
55 | if (success) {
56 | userDispatch({ type: 'login', payload: data });
57 | localStorage.setItem('user', JSON.stringify(data));
58 | navigate('/');
59 | showSuccess('登录成功!');
60 | } else {
61 | showError(message);
62 | }
63 | }
64 | }
65 |
66 | return (
67 |
68 |
69 |
72 |
98 |
99 | 忘记密码?
100 |
101 | 点击重置
102 |
103 | ; 没有账户?
104 |
105 | 点击注册
106 |
107 |
108 | {status.github_oauth ? (
109 | <>
110 | Or
111 |
117 | >
118 | ) : (
119 | <>>
120 | )}
121 |
122 |
123 | );
124 | };
125 |
126 | export default LoginForm;
127 |
--------------------------------------------------------------------------------
/web/src/components/PasswordResetForm.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
3 | import { API, showError, showSuccess } from '../helpers';
4 |
5 | const PasswordResetForm = () => {
6 | const [inputs, setInputs] = useState({
7 | email: '',
8 | });
9 | const { email } = inputs;
10 |
11 | const [loading, setLoading] = useState(false);
12 |
13 | function handleChange(e) {
14 | const { name, value } = e.target;
15 | setInputs((inputs) => ({ ...inputs, [name]: value }));
16 | }
17 |
18 | async function handleSubmit(e) {
19 | setLoading(true);
20 | if (!email) return;
21 | const res = await API.get(`/api/reset_password?email=${email}`);
22 | const { success, message } = res.data;
23 | if (success) {
24 | showSuccess('重置邮件发送成功,请检查邮箱!');
25 | setInputs({ ...inputs, email: '' });
26 | } else {
27 | showError(message);
28 | }
29 | setLoading(false);
30 | }
31 |
32 | return (
33 |
34 |
35 |
38 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default PasswordResetForm;
66 |
--------------------------------------------------------------------------------
/web/src/components/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import { Navigate } from 'react-router-dom';
2 |
3 | import { history } from '../helpers';
4 |
5 |
6 | function PrivateRoute({ children }) {
7 | if (!localStorage.getItem('user')) {
8 | return ;
9 | }
10 | return children;
11 | }
12 |
13 | export { PrivateRoute };
--------------------------------------------------------------------------------
/web/src/components/RegisterForm.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import {
3 | Button,
4 | Form,
5 | Grid,
6 | Header,
7 | Image,
8 | Message,
9 | Segment,
10 | } from 'semantic-ui-react';
11 | import { Link, useNavigate } from 'react-router-dom';
12 | import { API, showError, showInfo, showSuccess } from '../helpers';
13 |
14 | const RegisterForm = () => {
15 | const [inputs, setInputs] = useState({
16 | username: '',
17 | password: '',
18 | password2: '',
19 | email: '',
20 | verification_code: '',
21 | });
22 | const { username, password, password2 } = inputs;
23 |
24 | const [showEmailVerification, setShowEmailVerification] = useState(false);
25 |
26 | useEffect(() => {
27 | let status = localStorage.getItem('status');
28 | if (status) {
29 | status = JSON.parse(status);
30 | setShowEmailVerification(status.email_verification);
31 | }
32 | });
33 |
34 | let navigate = useNavigate();
35 |
36 | function handleChange(e) {
37 | const { name, value } = e.target;
38 | console.log(name, value);
39 | setInputs((inputs) => ({ ...inputs, [name]: value }));
40 | }
41 |
42 | async function handleSubmit(e) {
43 | if (password !== password2) {
44 | showInfo('两次输入的密码不一致');
45 | return;
46 | }
47 | if (username && password) {
48 | const res = await API.post('/api/user/register', inputs);
49 | const { success, message } = res.data;
50 | if (success) {
51 | navigate('/login');
52 | showSuccess('注册成功!');
53 | } else {
54 | showError(message);
55 | }
56 | }
57 | }
58 |
59 | const sendVerificationCode = async () => {
60 | if (inputs.email === '') return;
61 | const res = await API.get(`/api/verification?email=${inputs.email}`);
62 | const { success, message } = res.data;
63 | if (success) {
64 | showSuccess('验证码发送成功,请检查你的邮箱!');
65 | } else {
66 | showError(message);
67 | }
68 | };
69 |
70 | return (
71 |
72 |
73 |
76 |
135 |
136 | 已有账户?
137 |
138 | 点击登录
139 |
140 |
141 |
142 |
143 | );
144 | };
145 |
146 | export default RegisterForm;
147 |
--------------------------------------------------------------------------------
/web/src/components/SystemSetting.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Form, Grid } from 'semantic-ui-react';
3 | import { API, showError } from '../helpers';
4 |
5 | const SystemSetting = () => {
6 | let [inputs, setInputs] = useState({
7 | PasswordLoginEnabled: '',
8 | RegisterEnabled: '',
9 | EmailVerificationEnabled: '',
10 | GitHubOAuthEnabled: '',
11 | GitHubClientId: '',
12 | GitHubClientSecret: '',
13 | Notice: '',
14 | SMTPServer: '',
15 | SMTPAccount: '',
16 | SMTPToken: '',
17 | ServerAddress: '',
18 | FooterHTML: '',
19 | });
20 | let originInputs = {};
21 | let [loading, setLoading] = useState(false);
22 |
23 | const getOptions = async () => {
24 | const res = await API.get('/api/option');
25 | const { success, message, data } = res.data;
26 | if (success) {
27 | let newInputs = {};
28 | data.forEach((item) => {
29 | newInputs[item.key] = item.value;
30 | });
31 | setInputs(newInputs);
32 | originInputs = newInputs;
33 | } else {
34 | showError(message);
35 | }
36 | };
37 |
38 | useEffect(() => {
39 | getOptions().then();
40 | }, []);
41 |
42 | const updateOption = async (key, value) => {
43 | setLoading(true);
44 | switch (key) {
45 | case 'PasswordLoginEnabled':
46 | case 'RegisterEnabled':
47 | case 'EmailVerificationEnabled':
48 | case 'GitHubOAuthEnabled':
49 | value = inputs[key] === 'true' ? 'false' : 'true';
50 | break;
51 | default:
52 | break;
53 | }
54 | const res = await API.put('/api/option', {
55 | key,
56 | value,
57 | });
58 | const { success, message } = res.data;
59 | if (success) {
60 | setInputs((inputs) => ({ ...inputs, [key]: value }));
61 | } else {
62 | showError(message);
63 | }
64 | setLoading(false);
65 | };
66 |
67 | const handleInputChange = async (e, { name, value }) => {
68 | if (
69 | name === 'Notice' ||
70 | name.startsWith('SMTP') ||
71 | name === 'ServerAddress' ||
72 | name === 'GitHubClientId' ||
73 | name === 'GitHubClientSecret' ||
74 | name === 'FooterHTML'
75 | ) {
76 | setInputs((inputs) => ({ ...inputs, [name]: value }));
77 | } else {
78 | await updateOption(name, value);
79 | }
80 | };
81 |
82 | const submitNotice = async () => {
83 | await updateOption('Notice', inputs.Notice);
84 | };
85 |
86 | const submitServerAddress = async () => {
87 | let ServerAddress = inputs.ServerAddress;
88 | if (ServerAddress.endsWith('/')) {
89 | ServerAddress = ServerAddress.slice(0, ServerAddress.length - 1);
90 | }
91 | await updateOption('ServerAddress', ServerAddress);
92 | };
93 |
94 | const submitSMTP = async () => {
95 | if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
96 | await updateOption('SMTPServer', inputs.SMTPServer);
97 | }
98 | if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {
99 | await updateOption('SMTPAccount', inputs.SMTPAccount);
100 | }
101 | if (
102 | originInputs['SMTPToken'] !== inputs.SMTPToken &&
103 | inputs.SMTPToken !== ''
104 | ) {
105 | await updateOption('SMTPToken', inputs.SMTPToken);
106 | }
107 | };
108 |
109 | const submitGitHubOAuth = async () => {
110 | if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) {
111 | await updateOption('GitHubClientId', inputs.GitHubClientId);
112 | }
113 | if (
114 | originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret &&
115 | inputs.GitHubClientSecret !== ''
116 | ) {
117 | await updateOption('GitHubClientSecret', inputs.GitHubClientSecret);
118 | }
119 | };
120 |
121 | return (
122 |
123 |
124 |
126 |
133 |
134 |
135 | 更新服务器地址
136 |
137 |
138 |
145 |
146 | {
148 | updateOption('FooterHTML', inputs.FooterHTML).then();
149 | }}
150 | >
151 | 设置页脚 HTML
152 |
153 |
154 |
162 |
163 | 保存公告
164 |
165 |
171 |
177 |
183 |
189 |
190 |
191 |
198 |
205 |
213 |
214 | 保存 SMTP 设置
215 |
216 |
223 |
231 |
232 |
233 | 保存 GitHub OAuth 设置
234 |
235 |
236 |
237 |
238 | );
239 | };
240 |
241 | export default SystemSetting;
242 |
--------------------------------------------------------------------------------
/web/src/components/UsersTable.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Label, Pagination, Table } from 'semantic-ui-react';
3 | import { Link } from 'react-router-dom';
4 | import { API, showError, showSuccess } from '../helpers';
5 |
6 | const itemsPerPage = 10;
7 |
8 | function renderRole(role) {
9 | switch (role) {
10 | case 1:
11 | return ;
12 | case 10:
13 | return ;
14 | case 100:
15 | return ;
16 | default:
17 | return ;
18 | }
19 | }
20 |
21 | const UsersTable = () => {
22 | const [users, setUsers] = useState([]);
23 | const [loading, setLoading] = useState(true);
24 | const [activePage, setActivePage] = useState(1);
25 |
26 | const loadUsers = async () => {
27 | const res = await API.get('/api/user');
28 | const { success, message, data } = res.data;
29 | if (success) {
30 | setUsers(data);
31 | } else {
32 | showError(message);
33 | }
34 | setLoading(false);
35 | };
36 |
37 | const onPaginationChange = (e, { activePage }) => {
38 | setActivePage(activePage);
39 | };
40 |
41 | useEffect(() => {
42 | loadUsers()
43 | .then()
44 | .catch((reason) => {
45 | showError(reason);
46 | });
47 | }, []);
48 |
49 | const manageUser = (username, action) => {
50 | (async () => {
51 | const res = await API.post('/api/user/manage', {
52 | username,
53 | action,
54 | });
55 | const { success, message } = res.data;
56 | if (success) {
57 | showSuccess('操作成功完成!');
58 | await loadUsers();
59 | } else {
60 | showError(message);
61 | }
62 | })();
63 | };
64 |
65 | const renderStatus = (status, id) => {
66 | switch (status) {
67 | case 1:
68 | return '已激活';
69 | case 2:
70 | return '已封禁';
71 | default:
72 | return '未知状态';
73 | }
74 | };
75 |
76 | return (
77 | <>
78 |
79 |
80 |
81 | 用户名
82 | 显示名称
83 | 邮箱地址
84 | 用户角色
85 | 状态
86 | 操作
87 |
88 |
89 |
90 |
91 | {users
92 | .slice((activePage - 1) * itemsPerPage, activePage * itemsPerPage)
93 | .map((user, idx) => {
94 | return (
95 |
96 | {user.username}
97 | {user.display_name}
98 | {user.email ? user.email : '无'}
99 | {renderRole(user.role)}
100 | {renderStatus(user.status, user.id)}
101 |
102 |
103 |
112 |
121 |
130 |
141 |
148 |
149 |
150 |
151 | );
152 | })}
153 |
154 |
155 |
156 |
157 |
158 |
161 |
169 |
170 |
171 |
172 |
173 | >
174 | );
175 | };
176 |
177 | export default UsersTable;
178 |
--------------------------------------------------------------------------------
/web/src/components/WeChatSetting.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Form, Grid } from 'semantic-ui-react';
3 | import { API, showError } from '../helpers';
4 | import { Link } from 'react-router-dom';
5 |
6 | const WeChatSetting = () => {
7 | let [inputs, setInputs] = useState({
8 | WeChatToken: '',
9 | WeChatAppID: '',
10 | WeChatAppSecret: '',
11 | WeChatEncodingAESKey: '',
12 | WeChatOwnerID: '',
13 | WeChatMenu: '',
14 | });
15 | let [loading, setLoading] = useState(false);
16 |
17 | const getOptions = async () => {
18 | const res = await API.get('/api/option');
19 | const { success, message, data } = res.data;
20 | if (success) {
21 | let newInputs = {};
22 | data.forEach((item) => {
23 | if (item.key.startsWith('WeChat')) {
24 | newInputs[item.key] = item.value;
25 | }
26 | });
27 | setInputs(newInputs);
28 | } else {
29 | showError(message);
30 | }
31 | };
32 |
33 | useEffect(() => {
34 | getOptions().then();
35 | }, []);
36 |
37 | const updateOption = async (key, value) => {
38 | setLoading(true);
39 | const res = await API.put('/api/option', {
40 | key,
41 | value,
42 | });
43 | const { success, message } = res.data;
44 | if (success) {
45 | setInputs((inputs) => ({ ...inputs, [key]: value }));
46 | } else {
47 | showError(message);
48 | }
49 | setLoading(false);
50 | };
51 |
52 | const handleInputChange = async (e, { name, value }) => {
53 | if (name === 'WeChatMenu') {
54 | setInputs((inputs) => ({ ...inputs, [name]: value }));
55 | } else {
56 | await updateOption(name, value);
57 | }
58 | };
59 |
60 | const submitWeChatMenu = async () => {
61 | await updateOption('WeChatMenu', inputs.WeChatMenu);
62 | };
63 |
64 | return (
65 |
66 |
67 |
69 |
76 |
77 |
78 |
85 |
86 |
87 |
94 |
95 |
96 |
103 |
104 |
105 |
112 |
113 |
114 |
117 | 公众号菜单(
118 |
122 | 格式请参考此处
123 |
124 | )
125 |
126 | }
127 | placeholder="JSON 格式"
128 | value={inputs.WeChatMenu}
129 | name="WeChatMenu"
130 | onChange={handleInputChange}
131 | style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
132 | />
133 |
134 | 更新公众号菜单
135 |
136 |
137 |
138 | );
139 | };
140 |
141 | export default WeChatSetting;
142 |
--------------------------------------------------------------------------------
/web/src/constants/index.js:
--------------------------------------------------------------------------------
1 | export * from './toast.constants';
2 | export * from './user.constants';
--------------------------------------------------------------------------------
/web/src/constants/toast.constants.js:
--------------------------------------------------------------------------------
1 | export const toastConstants = {
2 | SUCCESS_TIMEOUT: 500,
3 | INFO_TIMEOUT: 3000,
4 | ERROR_TIMEOUT: 5000,
5 | NOTICE_TIMEOUT: 20000
6 | };
7 |
--------------------------------------------------------------------------------
/web/src/constants/user.constants.js:
--------------------------------------------------------------------------------
1 | export const userConstants = {
2 | REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
3 | REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
4 | REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
5 |
6 | LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
7 | LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
8 | LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
9 |
10 | LOGOUT: 'USERS_LOGOUT',
11 |
12 | GETALL_REQUEST: 'USERS_GETALL_REQUEST',
13 | GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
14 | GETALL_FAILURE: 'USERS_GETALL_FAILURE',
15 |
16 | DELETE_REQUEST: 'USERS_DELETE_REQUEST',
17 | DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
18 | DELETE_FAILURE: 'USERS_DELETE_FAILURE'
19 | };
20 |
--------------------------------------------------------------------------------
/web/src/context/User/index.js:
--------------------------------------------------------------------------------
1 | // contexts/User/index.jsx
2 |
3 | import React from "react"
4 | import { reducer, initialState } from "./reducer"
5 |
6 | export const UserContext = React.createContext({
7 | state: initialState,
8 | dispatch: () => null
9 | })
10 |
11 | export const UserProvider = ({ children }) => {
12 | const [state, dispatch] = React.useReducer(reducer, initialState)
13 |
14 | return (
15 |
16 | { children }
17 |
18 | )
19 | }
--------------------------------------------------------------------------------
/web/src/context/User/reducer.js:
--------------------------------------------------------------------------------
1 | export const reducer = (state, action) => {
2 | switch (action.type) {
3 | case 'login':
4 | return {
5 | ...state,
6 | user: action.payload
7 | };
8 | case 'logout':
9 | return {
10 | ...state,
11 | user: undefined
12 | };
13 |
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | export const initialState = {
20 | user: undefined
21 | };
--------------------------------------------------------------------------------
/web/src/helpers/api.js:
--------------------------------------------------------------------------------
1 | import { showError } from './utils';
2 | import axios from 'axios';
3 |
4 | export const API = axios.create({
5 | baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',
6 | });
7 |
8 | API.interceptors.response.use(
9 | (response) => response,
10 | (error) => {
11 | showError(error);
12 | }
13 | );
14 |
--------------------------------------------------------------------------------
/web/src/helpers/auth-header.js:
--------------------------------------------------------------------------------
1 | export function authHeader() {
2 | // return authorization header with jwt token
3 | let user = JSON.parse(localStorage.getItem('user'));
4 |
5 | if (user && user.token) {
6 | return { 'Authorization': 'Bearer ' + user.token };
7 | } else {
8 | return {};
9 | }
10 | }
--------------------------------------------------------------------------------
/web/src/helpers/history.js:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from 'history';
2 |
3 | export const history = createBrowserHistory();
--------------------------------------------------------------------------------
/web/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export * from './history';
2 | export * from './auth-header';
3 | export * from './utils';
4 | export * from './api';
--------------------------------------------------------------------------------
/web/src/helpers/utils.js:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-toastify';
2 | import { toastConstants } from '../constants';
3 |
4 | export function isAdmin() {
5 | let user = localStorage.getItem('user');
6 | if (!user) return false;
7 | user = JSON.parse(user);
8 | return user.role >= 10;
9 | }
10 |
11 | export function isRoot() {
12 | let user = localStorage.getItem('user');
13 | if (!user) return false;
14 | user = JSON.parse(user);
15 | return user.role >= 100;
16 | }
17 |
18 | export async function copy(text) {
19 | let okay = true;
20 | try {
21 | await navigator.clipboard.writeText(text);
22 | } catch (e) {
23 | okay = false;
24 | console.error(e);
25 | }
26 | return okay;
27 | }
28 |
29 | export function isMobile() {
30 | return window.innerWidth <= 600;
31 | }
32 |
33 | let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
34 | let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
35 | let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
36 | let showNoticeOptions = { autoClose: false };
37 |
38 | if (isMobile()) {
39 | showErrorOptions.position = 'top-center';
40 | // showErrorOptions.transition = 'flip';
41 |
42 | showSuccessOptions.position = 'top-center';
43 | // showSuccessOptions.transition = 'flip';
44 |
45 | showInfoOptions.position = 'top-center';
46 | // showInfoOptions.transition = 'flip';
47 |
48 | showNoticeOptions.position = 'top-center';
49 | // showNoticeOptions.transition = 'flip';
50 | }
51 |
52 | export function showError(error) {
53 | console.error(error);
54 | if (error.message) {
55 | if (error.name === 'AxiosError') {
56 | switch (error.message) {
57 | case 'Request failed with status code 429':
58 | toast.error('错误:请求次数过多,请稍后再试!', showErrorOptions);
59 | break;
60 | case 'Request failed with status code 500':
61 | toast.error('错误:服务器内部错误,请联系管理员!', showErrorOptions);
62 | break;
63 | case 'Request failed with status code 405':
64 | toast.info('本站仅作演示之用,无服务端!');
65 | break;
66 | default:
67 | toast.error('错误:' + error.message, showErrorOptions);
68 | }
69 | return;
70 | }
71 | toast.error('错误:' + error.message, showErrorOptions);
72 | } else {
73 | toast.error('错误:' + error, showErrorOptions);
74 | }
75 | }
76 |
77 | export function showSuccess(message) {
78 | toast.success(message, showSuccessOptions);
79 | }
80 |
81 | export function showInfo(message) {
82 | toast.info(message, showInfoOptions);
83 | }
84 |
85 | export function showNotice(message) {
86 | toast.info(message, showNoticeOptions);
87 | }
88 |
--------------------------------------------------------------------------------
/web/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding-top: 55px;
4 | overflow-y: scroll;
5 | font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
12 | }
13 |
14 | .main-content {
15 | padding: 4px;
16 | }
17 |
18 | .small-icon .icon {
19 | font-size: 1em !important;
20 | }
21 |
22 | .custom-footer {
23 | font-size: 1.1em;
24 | }
25 |
26 | @media only screen and (max-width: 600px) {
27 | .hide-on-mobile {
28 | display: none !important;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/web/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { Container } from 'semantic-ui-react';
5 | import App from './App';
6 | import Header from './components/Header';
7 | import Footer from './components/Footer';
8 | import 'semantic-ui-css/semantic.min.css';
9 | import './index.css';
10 | import { UserProvider } from './context/User';
11 | import { ToastContainer } from 'react-toastify';
12 | import 'react-toastify/dist/ReactToastify.css';
13 |
14 |
15 | const root = ReactDOM.createRoot(document.getElementById('root'));
16 | root.render(
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 |
--------------------------------------------------------------------------------
/web/src/pages/About/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 |
4 | const About = () => (
5 | <>
6 |
7 |
8 | GitHub:{' '}
9 |
10 | https://github.com/songquanpeng/wechat-server
11 |
12 |
13 | >
14 | );
15 |
16 | export default About;
17 |
--------------------------------------------------------------------------------
/web/src/pages/Home/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { Grid, Header, Placeholder, Segment } from 'semantic-ui-react';
3 | import { API, showError, showNotice } from '../../helpers';
4 |
5 | const Home = () => {
6 | const displayNotice = async () => {
7 | const res = await API.get('/api/notice');
8 | const { success, message, data } = res.data;
9 | if (success) {
10 | let oldNotice = localStorage.getItem('notice');
11 | if (data !== oldNotice && data !== '') {
12 | showNotice(data);
13 | localStorage.setItem('notice', data);
14 | }
15 | } else {
16 | showError(message);
17 | }
18 | };
19 |
20 | useEffect(() => {
21 | displayNotice().then();
22 | }, []);
23 | return (
24 | <>
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | >
75 | );
76 | };
77 |
78 | export default Home;
79 |
--------------------------------------------------------------------------------
/web/src/pages/NotFound/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 |
4 | const NotFound = () => (
5 | <>
6 |
14 |
15 | 未找到所请求的页面
16 |
17 | >
18 | );
19 |
20 | export default NotFound;
21 |
--------------------------------------------------------------------------------
/web/src/pages/Setting/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Button, Modal, Segment, Tab } from 'semantic-ui-react';
3 | import SystemSetting from '../../components/SystemSetting';
4 | import { Link } from 'react-router-dom';
5 | import { API, copy, isRoot, showError, showSuccess } from '../../helpers';
6 | import { marked } from 'marked';
7 | import WeChatSetting from '../../components/WeChatSetting';
8 |
9 | const Setting = () => {
10 | const [showUpdateModal, setShowUpdateModal] = useState(false);
11 | const [updateData, setUpdateData] = useState({
12 | tag_name: '',
13 | content: '',
14 | });
15 |
16 | const generateToken = async () => {
17 | const res = await API.get('/api/user/token');
18 | const { success, message, data } = res.data;
19 | if (success) {
20 | await copy(data);
21 | showSuccess(`令牌已重置并已复制到剪切板:${data}`);
22 | } else {
23 | showError(message);
24 | }
25 | };
26 |
27 | const openGitHubRelease = () => {
28 | window.location =
29 | 'https://github.com/songquanpeng/wechat-server/releases/latest';
30 | };
31 |
32 | const checkUpdate = async () => {
33 | const res = await API.get(
34 | 'https://api.github.com/repos/songquanpeng/wechat-server/releases/latest'
35 | );
36 | const { tag_name, body } = res.data;
37 | if (tag_name === process.env.REACT_APP_VERSION) {
38 | showSuccess(`已是最新版本:${tag_name}`);
39 | } else {
40 | setUpdateData({
41 | tag_name: tag_name,
42 | content: marked.parse(body),
43 | });
44 | setShowUpdateModal(true);
45 | }
46 | };
47 |
48 | let panes = [
49 | {
50 | menuItem: '个人设置',
51 | render: () => (
52 |
53 |
56 |
57 |
58 | ),
59 | },
60 | ];
61 |
62 | if (isRoot()) {
63 | panes.push({
64 | menuItem: '系统设置',
65 | render: () => (
66 |
67 |
68 |
69 | ),
70 | });
71 | panes.push({
72 | menuItem: '微信设置',
73 | render: () => (
74 |
75 |
76 |
77 | ),
78 | });
79 | panes.push({
80 | menuItem: '其他设置',
81 | render: () => (
82 |
83 |
84 | setShowUpdateModal(false)}
86 | onOpen={() => setShowUpdateModal(true)}
87 | open={showUpdateModal}
88 | >
89 | 新版本:{updateData.tag_name}
90 |
91 |
92 |
95 |
96 |
97 |
98 |
99 |
107 |
108 |
109 | ),
110 | });
111 | }
112 |
113 | return (
114 |
115 |
116 |
117 | );
118 | };
119 |
120 | export default Setting;
121 |
--------------------------------------------------------------------------------
/web/src/pages/User/AddUser.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Button, Form, Header, Segment } from 'semantic-ui-react';
3 | import { API, showError, showSuccess } from '../../helpers';
4 |
5 | const AddUser = () => {
6 | const originInputs = {
7 | username: '',
8 | display_name: '',
9 | password: '',
10 | };
11 | const [inputs, setInputs] = useState(originInputs);
12 | const { username, display_name, password } = inputs;
13 |
14 | const handleInputChange = (e, { name, value }) => {
15 | setInputs((inputs) => ({ ...inputs, [name]: value }));
16 | };
17 |
18 | const submit = async () => {
19 | if (inputs.username === '' || inputs.password === '') return;
20 | const res = await API.post(`/api/user/`, inputs);
21 | const { success, message } = res.data;
22 | if (success) {
23 | showSuccess('用户账户创建成功!');
24 | setInputs(originInputs);
25 | } else {
26 | showError(message);
27 | }
28 | };
29 |
30 | return (
31 | <>
32 |
33 |
34 |
36 |
45 |
46 |
47 |
55 |
56 |
57 |
67 |
68 |
71 |
72 |
73 | >
74 | );
75 | };
76 |
77 | export default AddUser;
78 |
--------------------------------------------------------------------------------
/web/src/pages/User/EditUser.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Header, Segment } from 'semantic-ui-react';
3 | import { useParams } from 'react-router-dom';
4 | import { API, showError, showSuccess } from '../../helpers';
5 |
6 | const EditUser = () => {
7 | const params = useParams();
8 | const userId = params.id;
9 | const [loading, setLoading] = useState(true);
10 | const [inputs, setInputs] = useState({
11 | username: '',
12 | display_name: '',
13 | password: '',
14 | });
15 | const { username, display_name, password } = inputs;
16 | const handleInputChange = (e, { name, value }) => {
17 | setInputs((inputs) => ({ ...inputs, [name]: value }));
18 | };
19 |
20 | const loadUser = async () => {
21 | let res = undefined;
22 | if (userId) {
23 | res = await API.get(`/api/user/${userId}`);
24 | } else {
25 | res = await API.get(`/api/user/self`);
26 | }
27 | const { success, message, data } = res.data;
28 | if (success) {
29 | data.password = '';
30 | setInputs(data);
31 | } else {
32 | showError(message);
33 | }
34 | setLoading(false);
35 | };
36 | useEffect(() => {
37 | loadUser()
38 | .then()
39 | .catch((reason) => {
40 | showError(reason);
41 | });
42 | }, []);
43 |
44 | const submit = async () => {
45 | let res = undefined;
46 | if (userId) {
47 | res = await API.put(`/api/user/`, { ...inputs, id: parseInt(userId) });
48 | } else {
49 | res = await API.put(`/api/user/self`, inputs);
50 | }
51 | const { success, message } = res.data;
52 | if (success) {
53 | showSuccess('用户信息更新成功!');
54 | } else {
55 | showError(message);
56 | }
57 | };
58 |
59 | return (
60 | <>
61 |
62 |
63 |
65 |
73 |
74 |
75 |
84 |
85 |
86 |
94 |
95 |
96 |
97 |
98 | >
99 | );
100 | };
101 |
102 | export default EditUser;
103 |
--------------------------------------------------------------------------------
/web/src/pages/User/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 | import UsersTable from '../../components/UsersTable';
4 |
5 | const User = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default User;
15 |
--------------------------------------------------------------------------------
/web/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------