├── .dockerignore
├── .gitignore
├── doc
└── screenshot.png
├── .gitmodules
├── Caddyfile
├── .idea
├── .gitignore
├── vcs.xml
├── modules.xml
└── JoiAsk.iml
├── pkg
└── util
│ └── util.go
├── Dockerfile
├── cmd
├── cmd.go
└── migrate
│ └── migrate.go
├── config
└── config_sample.json
├── .github
└── workflows
│ └── docker-image.yml
├── internal
├── database
│ ├── oldmodels
│ │ └── oldmodels.go
│ ├── models.go
│ └── database.go
├── storage
│ ├── local.go
│ ├── oss.go
│ └── storage.go
├── router
│ ├── authorize.go
│ └── router.go
└── controller
│ ├── config_controller.go
│ ├── controller.go
│ ├── tag_controller.go
│ ├── user_controller.go
│ └── question_controller.go
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── go.mod
└── go.sum
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.git
2 | **/node_modules
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | config.json
2 | db.sqlite3
3 | ask.db
--------------------------------------------------------------------------------
/doc/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xinrea/JoiAsk/HEAD/doc/screenshot.png
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "frontend"]
2 | path = frontend
3 | url = git@github.com:Xinrea/JoiAsk-frontend.git
4 |
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | localhost
2 |
3 | handle /api/* {
4 | reverse_proxy /api/* :8080
5 | }
6 |
7 | handle * {
8 | reverse_proxy :5173
9 | }
10 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # 默认忽略的文件
2 | /shelf/
3 | /workspace.xml
4 | # 基于编辑器的 HTTP 客户端请求
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/pkg/util/util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/md5"
5 | "encoding/hex"
6 | )
7 |
8 | func Md5v(v string) string {
9 | d := []byte(v)
10 | m := md5.New()
11 | m.Write(d)
12 | return hex.EncodeToString(m.Sum(nil))
13 | }
14 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/JoiAsk.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22.9.0 as frontendBuilder
2 | WORKDIR /work
3 | COPY frontend .
4 | RUN npm install && npm run build
5 |
6 | FROM golang:1.23 as backendBuilder
7 | WORKDIR /work
8 | COPY . .
9 | RUN go build -o jask cmd/cmd.go
10 |
11 | FROM ubuntu:latest
12 | RUN apt-get update && apt-get install -y ca-certificates
13 | WORKDIR /work/
14 | COPY --from=frontendBuilder /work/dist ./frontend/public
15 | COPY --from=backendBuilder /work/jask ./
16 | ENV GIN_MODE=release
17 | CMD ["./jask"]
18 |
--------------------------------------------------------------------------------
/cmd/cmd.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "joiask-backend/internal/database"
5 | "joiask-backend/internal/router"
6 |
7 | _ "github.com/mattn/go-sqlite3"
8 | log "github.com/sirupsen/logrus"
9 | "github.com/spf13/viper"
10 | )
11 |
12 | func main() {
13 | viper.SetConfigFile("./config/config.json")
14 | err := viper.ReadInConfig()
15 | if err != nil {
16 | log.Fatal(err)
17 | }
18 | log.Info("Config loaded")
19 | database.Init()
20 | log.Info("Database initialized")
21 | log.Info("Starting server")
22 | router.Run()
23 | }
24 |
--------------------------------------------------------------------------------
/config/config_sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "db_type": "sqlite",
3 | "sqlite": "./ask.db",
4 | "mysql": {
5 | "host": "192.168.50.58",
6 | "port": 3306,
7 | "user": "root",
8 | "pass": "test",
9 | "name": "jask"
10 | },
11 | "server": {
12 | "host": "0.0.0.0",
13 | "port": 8080
14 | },
15 | "storage_type": "oss",
16 | "oss": {
17 | "address": "https://i0.vjoi.cn",
18 | "endpoint": "oss-cn-beijing.aliyuncs.com",
19 | "access_key": "",
20 | "secret_key": "",
21 | "bucket": "jwebsite-storage"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | env:
9 | REGISTRY: ghcr.io
10 | IMAGE_NAME: xinrea/joiask
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v3
17 | with:
18 | submodules: 'true'
19 | - name: Prepare Docker
20 | uses: docker/login-action@v1.14.1
21 | with:
22 | registry: ${{ env.REGISTRY }}
23 | username: ${{ github.actor }}
24 | password: ${{ secrets.GITHUB_TOKEN }}
25 | - name: Build the Docker image
26 | run: |
27 | docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} .
28 | docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
29 |
--------------------------------------------------------------------------------
/internal/database/oldmodels/oldmodels.go:
--------------------------------------------------------------------------------
1 | package oldmodels
2 |
3 | import "gorm.io/gorm"
4 |
5 | type Tag struct {
6 | gorm.Model
7 | TagName string
8 | }
9 |
10 | type Question struct {
11 | gorm.Model
12 | TagID int `gorm:"index"`
13 | Content string
14 | ImagesNum int
15 | Images string
16 | Likes int
17 | IsHide bool
18 | IsRainbow bool `gorm:"index"`
19 | IsArchived bool `gorm:"index"`
20 | IsPublished bool `gorm:"index"`
21 | }
22 |
23 | type LikeRecord struct {
24 | gorm.Model
25 | IP string
26 | QuestionID int `gorm:"index"`
27 | Question Question
28 | }
29 |
30 | type Admin struct {
31 | gorm.Model
32 | Username string `gorm:"unique"`
33 | Password string
34 | }
35 |
36 | type Config struct {
37 | gorm.Model
38 | Announcement string
39 | }
40 |
--------------------------------------------------------------------------------
/internal/storage/local.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "os"
7 |
8 | "github.com/sirupsen/logrus"
9 | )
10 |
11 | type Local struct {
12 | }
13 |
14 | func (l *Local) Upload(filename string, content *bytes.Reader) (string, error) {
15 | data, err := io.ReadAll(content)
16 | if err != nil {
17 | return "", err
18 | }
19 | err = os.WriteFile("frontend/public/upload-img/"+filename, data, 0644)
20 | if err != nil {
21 | return "", err
22 | }
23 | return "upload-img/" + filename, nil
24 | }
25 |
26 | func (l *Local) Delete(filename string) error {
27 | err := os.Remove("frontend/public/upload-img/" + filename)
28 | if err != nil {
29 | logrus.Error("remove file failed: ", err)
30 | }
31 | return err
32 | }
33 |
34 | func NewLocal() *Local {
35 | return &Local{}
36 | }
37 |
--------------------------------------------------------------------------------
/internal/router/authorize.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "joiask-backend/internal/database"
5 |
6 | "github.com/gin-contrib/sessions"
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func authMiddleware(c *gin.Context) {
11 | session := sessions.Default(c)
12 | authed := session.Get("authed")
13 | userID := session.Get("user")
14 | if authed == nil || authed == false || userID == nil {
15 | c.AbortWithStatusJSON(200, gin.H{
16 | "code": 408,
17 | "message": "请先登录",
18 | })
19 | return
20 | }
21 | var userModel database.Admin
22 | if database.DB.Where("id = ?", userID).First(&userModel).RowsAffected == 0 {
23 | session.Delete("user")
24 | session.Delete("authed")
25 | c.AbortWithStatusJSON(200, gin.H{
26 | "code": 408,
27 | "message": "请先登录",
28 | })
29 | return
30 | }
31 | c.Set("user", userModel)
32 | c.Set("authed", true)
33 | c.Next()
34 | }
35 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # 开发环境的配置
2 |
3 | ## 基础
4 |
5 | - Golang
6 | - NodeJS / NPM
7 | - Caddy
8 |
9 | ## 开发与调试
10 |
11 | 要注意本项目前后端分离存放在不同的仓库中,在 clone 时注意加上 `--recurse-submodules`。
12 |
13 | ```
14 | git clone --recurse-submodules git@github.com:Xinrea/JoiAsk.git
15 | ```
16 |
17 | ### 1. 启动后端
18 |
19 | ```
20 | go run cmd/cmd.go
21 | ```
22 |
23 | ### 2. 启动前端 dev
24 |
25 | ```
26 | cd frontend && npm run dev
27 | ```
28 |
29 | ### 3. 启动本地服务器
30 |
31 | > [!NOTE]
32 | > 由于未 Release 打包前,前后端完全独立,在本地需要进行一定的 Path Routing 设置才能够正常访问,因此推荐使用 Caddy 来启用一个本地服务器。
33 |
34 | Caddyfile 已提供在项目根目录,只需 `caddy run` 即可启动本地服务器。
35 |
36 | Caddyfile 的内容如下所示,十分简单,将 `/api/*` 指向后端 gin server;将其余请求指向前端 dev server。如果你更改了前后端默认端口,Caddyfile 中的内容也应同步更改。
37 |
38 | ```
39 | localhost
40 |
41 | handle /api/* {
42 | reverse_proxy /api/* :8080
43 | }
44 |
45 | handle * {
46 | reverse_proxy :5173
47 | }
48 | ```
49 |
50 | 之后浏览器访问本地 localhost 即能够正常预览页面。
51 |
--------------------------------------------------------------------------------
/internal/controller/config_controller.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "joiask-backend/internal/database"
5 |
6 | "github.com/gin-gonic/gin"
7 | log "github.com/sirupsen/logrus"
8 | )
9 |
10 | type ConfigController struct{}
11 |
12 | type ConfigRequest struct {
13 | Announcement string `json:"announcement"`
14 | }
15 |
16 | func (*ConfigController) Get(c *gin.Context) {
17 | var config database.Config
18 | database.DB.First(&config)
19 | Success(c, config)
20 | }
21 |
22 | func (*ConfigController) Put(c *gin.Context) {
23 | var request ConfigRequest
24 | if err := c.ShouldBindJSON(&request); err != nil {
25 | Fail(c, 400, "请求错误")
26 | return
27 | }
28 | var config database.Config
29 | database.DB.First(&config)
30 | config.Announcement = request.Announcement
31 | if err := database.DB.Save(&config).Error; err != nil {
32 | log.Errorf("failed to save config: %v", err)
33 | Fail(c, 500, "内部错误")
34 | return
35 | }
36 | Success(c, config)
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Xinrea
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 |
--------------------------------------------------------------------------------
/internal/storage/oss.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "bytes"
5 |
6 | "github.com/aliyun/aliyun-oss-go-sdk/oss"
7 | "github.com/sirupsen/logrus"
8 | )
9 |
10 | type OSS struct {
11 | address string
12 | client *oss.Client
13 | bucket *oss.Bucket
14 | }
15 |
16 | func NewOSS(address string, endpoint string, accessKey string, secretKey string, bucket string) *OSS {
17 | var err error
18 | ossClient, err := oss.New(endpoint, accessKey, secretKey)
19 | if err != nil {
20 | logrus.Fatal(err)
21 | }
22 | // 获取存储空间。
23 | ossBucket, err := ossClient.Bucket(bucket)
24 | if err != nil {
25 | logrus.Fatal(err)
26 | }
27 | return &OSS{
28 | address, ossClient, ossBucket,
29 | }
30 | }
31 |
32 | func (s *OSS) Upload(filename string, content *bytes.Reader) (string, error) {
33 | err := s.bucket.PutObject("upload-img/"+filename, content)
34 | if err != nil {
35 | logrus.Error(err)
36 | return "", err
37 | }
38 | return s.address + "/upload-img/" + filename, nil
39 | }
40 |
41 | func (s *OSS) Delete(filename string) error {
42 | err := s.bucket.DeleteObject("upload-img/" + filename)
43 | if err != nil {
44 | logrus.Error("OSS delete file failed: ", err)
45 | }
46 | return err
47 | }
48 |
--------------------------------------------------------------------------------
/internal/storage/storage.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "bytes"
5 | "sync"
6 |
7 | "github.com/spf13/viper"
8 | )
9 |
10 | var storage Storage
11 | var once sync.Once
12 |
13 | type Storage interface {
14 | Upload(filename string, content *bytes.Reader) (string, error)
15 | Delete(filename string) error
16 | }
17 |
18 | const (
19 | TYPE_LOCAL = iota
20 | TYPE_OSS
21 | )
22 |
23 | func StrToType(s string) int {
24 | switch s {
25 | case "local":
26 | return TYPE_LOCAL
27 | case "oss":
28 | return TYPE_OSS
29 | }
30 | panic("Invalid storage type")
31 | }
32 |
33 | type StorageConfig struct {
34 | StorageType int
35 | Address string
36 | Endpoint string
37 | AccessKey string
38 | SecretKey string
39 | Bucket string
40 | }
41 |
42 | func New(s StorageConfig) Storage {
43 | switch s.StorageType {
44 | case TYPE_LOCAL:
45 | return NewLocal()
46 | case TYPE_OSS:
47 | return NewOSS(s.Address, s.Endpoint, s.AccessKey, s.SecretKey, s.Bucket)
48 | }
49 | return nil
50 | }
51 |
52 | // Get the single storage instance
53 | func Get() Storage {
54 | once.Do(func() {
55 | storeConfig := StorageConfig{
56 | StorageType: StrToType(viper.GetString("storage_type")),
57 | Address: viper.GetString("oss.address"),
58 | Endpoint: viper.GetString("oss.endpoint"),
59 | AccessKey: viper.GetString("oss.access_key"),
60 | SecretKey: viper.GetString("oss.secret_key"),
61 | Bucket: viper.GetString("oss.bucket"),
62 | }
63 | storage = New(storeConfig)
64 | })
65 | return storage
66 | }
67 |
--------------------------------------------------------------------------------
/internal/controller/controller.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "gorm.io/gorm"
6 | )
7 |
8 | func Success(c *gin.Context, data interface{}) {
9 | c.JSON(200, gin.H{
10 | "code": 200,
11 | "message": "success",
12 | "data": data,
13 | })
14 | }
15 |
16 | func Fail(c *gin.Context, code int, message string) {
17 | c.AbortWithStatusJSON(200, gin.H{
18 | "code": code,
19 | "message": message,
20 | "data": nil,
21 | })
22 | }
23 |
24 | func getOrderBy(orderBy string) string {
25 | switch orderBy {
26 | case "id":
27 | return "id"
28 | case "created_at":
29 | return "created_at"
30 | case "is_hide":
31 | return "is_hide"
32 | case "is_rainbow":
33 | return "is_rainbow"
34 | case "is_archive":
35 | return "is_archive"
36 | case "is_publish":
37 | return "is_publish"
38 | default:
39 | return "id"
40 | }
41 | }
42 |
43 | func getOrder(order string) string {
44 | if order == "asc" || order == "desc" {
45 | return order
46 | }
47 | return "desc"
48 | }
49 |
50 | func getPage(page int) int {
51 | if page < 1 {
52 | return 1
53 | }
54 | return page
55 | }
56 |
57 | func getPageSize(pageSize int) int {
58 | if pageSize < 5 {
59 | return 5
60 | }
61 | if pageSize > 100 {
62 | return 100
63 | }
64 | return pageSize
65 | }
66 |
67 | func paginate(page int, size int) func(db *gorm.DB) *gorm.DB {
68 | return func(db *gorm.DB) *gorm.DB {
69 | if page == 0 {
70 | page = 1
71 | }
72 | switch {
73 | case size > 100:
74 | size = 100
75 | case size <= 0:
76 | size = 10
77 | }
78 |
79 | offset := (page - 1) * size
80 | return db.Offset(offset).Limit(size)
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/internal/database/models.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type BaseModel struct {
8 | ID uint `gorm:"primary_key" json:"id"`
9 | CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
10 | UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
11 | }
12 | type Tag struct {
13 | BaseModel
14 | TagName string `gorm:"unique" json:"tag_name"`
15 | Description string `json:"description"`
16 | }
17 |
18 | type Question struct {
19 | BaseModel
20 | TagID int `gorm:"index" json:"tag_id"`
21 | Tag Tag `gorm:"foreignkey:TagID" json:"tag"`
22 | Content string `json:"content"`
23 | ImagesNum int `json:"images_num"`
24 | Images string `json:"images"`
25 | Likes int `json:"likes"`
26 | IsHide bool `gorm:"index" json:"is_hide"`
27 | IsRainbow bool `gorm:"index" json:"is_rainbow"`
28 | IsArchive bool `gorm:"index" json:"is_archive"`
29 | IsPublish bool `gorm:"index" json:"is_publish"`
30 | Emojis string `json:"emojis"`
31 | }
32 |
33 | type LikeRecord struct {
34 | BaseModel
35 | IP string `gorm:"index" json:"ip"`
36 | QuestionID int `gorm:"index" json:"question_id"`
37 | Question Question `json:"question"`
38 | }
39 |
40 | type Admin struct {
41 | BaseModel
42 | Username string `gorm:"unique" json:"username"`
43 | Password string `json:"-"`
44 | }
45 |
46 | type Config struct {
47 | BaseModel
48 | Announcement string `json:"announcement"`
49 | }
50 |
51 | func (t Tag) Json() map[string]interface{} {
52 | var count int64
53 | DB.Model(&Question{}).Where("tag_id = ?", t.ID).Count(&count)
54 | return map[string]interface{}{
55 | "id": t.ID,
56 | "tag_name": t.TagName,
57 | "description": t.Description,
58 | "question_count": count,
59 | "created_at": t.CreatedAt,
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # JoiAsk 提问箱
2 |
3 | 
4 |
5 | 样例可见 [轴伊Joi的提问箱](https://ask.vjoi.cn/)
6 |
7 | ## Build 构建
8 |
9 | ```bash
10 | docker build -t joiask .
11 | ```
12 |
13 | ## Run 运行
14 |
15 | ```bash
16 | docker run -it -d --restart always \
17 | -p 8080:8080 \
18 | -v /path/to/config.json:/work/config/config.json \
19 | -v /path/to/storage/:/work/frontend/public/upload-img/ \
20 | --name jask \
21 | ghcr.io/xinrea/joiask:latest
22 | ```
23 |
24 | 后台地址为: https://your.domain/admin
25 |
26 | 默认管理员账号/密码为:admin/admin
27 |
28 | 记得第一时间登录后台修改管理员密码。
29 |
30 | ## Configuration 配置
31 |
32 | ### OSS 存储图片
33 |
34 | ```json
35 | {
36 | "db_type": "mysql",
37 | "mysql": {
38 | "host": "192.168.50.58",
39 | "port": 3306,
40 | "user": "root",
41 | "pass": "test",
42 | "name": "jask"
43 | },
44 | "server": {
45 | "host": "0.0.0.0",
46 | "port": 8080
47 | },
48 | "storage_type": "oss",
49 | "oss":{
50 | "address": "https://i0.vjoi.cn",
51 | "endpoint":"oss-cn-beijing.aliyuncs.com",
52 | "access_key":"",
53 | "secret_key":"",
54 | "bucket":"jwebsite-storage"
55 | }
56 | }
57 | ```
58 |
59 | ### 本地存储图片
60 |
61 | 本地存储时,上传的图片将会被存储在容器的 `/work/frontend/public/upload-img/` 目录。记得要将存储目录挂在到容器的该位置下。
62 |
63 | ```json
64 | {
65 | "db_type": "mysql",
66 | "mysql": {
67 | "host": "192.168.50.58",
68 | "port": 3306,
69 | "user": "root",
70 | "pass": "test",
71 | "name": "jask"
72 | },
73 | "server": {
74 | "host": "0.0.0.0",
75 | "port": 8080
76 | },
77 | "storage_type": "local"
78 | }
79 | ```
80 |
81 | ### 使用 SQlite 数据库
82 |
83 | 同样记得将数据库文件挂载到配置文件所指定的位置,否则删除容器会导致数据丢失。
84 |
85 | ```json
86 | {
87 | "db_type": "sqlite",
88 | "sqlite": "/work/db/jask.db",
89 | "server": {
90 | ...
91 | }
92 | ```
93 |
--------------------------------------------------------------------------------
/internal/database/database.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "fmt"
5 | "joiask-backend/pkg/util"
6 |
7 | log "github.com/sirupsen/logrus"
8 | "github.com/spf13/viper"
9 | "gorm.io/driver/mysql"
10 | "gorm.io/driver/sqlite"
11 | "gorm.io/gorm"
12 | )
13 |
14 | var DB *gorm.DB
15 |
16 | const DefaultTagName = "提问箱"
17 |
18 | // Init opens connection and try to initialize the database.
19 | func Init() {
20 | var err error
21 | switch viper.GetString("db_type") {
22 | case "sqlite":
23 | log.Info("Using sqlite database.")
24 | DB, err = gorm.Open(sqlite.Open(viper.GetString("sqlite")), &gorm.Config{})
25 | case "mysql":
26 | log.Info("Using mysql database.")
27 | dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True", viper.GetString("mysql.user"), viper.GetString("mysql.pass"), viper.GetString("mysql.host"), viper.GetInt("mysql.port"), viper.GetString("mysql.name"))
28 | DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
29 | }
30 | if err != nil {
31 | log.Fatal(err)
32 | }
33 | initializeDB()
34 | }
35 |
36 | // initializeDB initializes the database, create tables and default records.
37 | func initializeDB() {
38 | err := DB.AutoMigrate(&Question{}, &LikeRecord{}, &Admin{}, &Config{}, &Tag{})
39 | if err != nil {
40 | log.Fatal(err)
41 | }
42 | // Initialize default admin account.
43 | if DB.Where("username = ?", "admin").First(&Admin{}).RowsAffected == 0 {
44 | log.Info("Initializing default admin account.")
45 | if err := DB.Create(&Admin{Username: "admin", Password: util.Md5v("admin")}).Error; err != nil {
46 | log.Fatal("Failed to initialize default admin account.", err)
47 | }
48 | }
49 | // Initialize default config.
50 | if DB.First(&Config{}).RowsAffected == 0 {
51 | log.Info("Initializing default config.")
52 | if err := DB.Create(&Config{Announcement: "提问内容将在审核后公开"}).Error; err != nil {
53 | log.Fatal("Failed to initialize default config.", err)
54 | }
55 | }
56 | // Initialize default tag.
57 | if DB.First(&Tag{}).RowsAffected == 0 {
58 | log.Info("Initializing default tag.")
59 | if err := DB.Create(&Tag{TagName: DefaultTagName, Description: "默认话题"}).Error; err != nil {
60 | log.Fatal("Failed to initialize default tag.", err)
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/internal/controller/tag_controller.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "joiask-backend/internal/database"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/samber/lo"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | type TagController struct{}
12 |
13 | type TagRequest struct {
14 | TagName string `json:"tag_name"`
15 | Description string `json:"description"`
16 | }
17 |
18 | // Get all tags
19 | func (t *TagController) Get(c *gin.Context) {
20 | var tags []database.Tag
21 | database.DB.Find(&tags)
22 | Success(c, lo.Map(tags, func(t database.Tag, _ int) interface{} { return t.Json() }))
23 | }
24 |
25 | // Put modify tag
26 | func (t *TagController) Put(c *gin.Context) {
27 | var tag database.Tag
28 | database.DB.First(&tag, c.Param("id"))
29 | if tag.ID == 0 {
30 | Fail(c, 404, "tag not found")
31 | return
32 | }
33 | var tagRequest TagRequest
34 | if err := c.ShouldBindJSON(&tagRequest); err != nil {
35 | Fail(c, 400, "invalid request")
36 | return
37 | }
38 | tag.TagName = tagRequest.TagName
39 | tag.Description = tagRequest.Description
40 | if err := database.DB.Save(&tag).Error; err != nil {
41 | log.Errorf("failed to save tag: %v", err)
42 | Fail(c, 500, "internal server error")
43 | return
44 | }
45 | Success(c, tag)
46 | }
47 |
48 | // Post create tag
49 | func (t *TagController) Post(c *gin.Context) {
50 | var tagRequest TagRequest
51 | if err := c.ShouldBindJSON(&tagRequest); err != nil {
52 | Fail(c, 400, "invalid request")
53 | return
54 | }
55 | var tag database.Tag
56 | tag.TagName = tagRequest.TagName
57 | tag.Description = tagRequest.Description
58 | if err := database.DB.Create(&tag).Error; err != nil {
59 | log.Error("failed to create tag: ", err)
60 | Fail(c, 401, "创建话题失败")
61 | return
62 | }
63 | Success(c, tag)
64 | }
65 |
66 | func (t *TagController) Delete(c *gin.Context) {
67 | var tag database.Tag
68 | database.DB.First(&tag, c.Param("id"))
69 | if tag.ID == 0 {
70 | Fail(c, 404, "话题不存在")
71 | return
72 | }
73 | if tag.ID == 1 {
74 | Fail(c, 403, "不能删除默认话题")
75 | return
76 | }
77 | var questionCount int64
78 | database.DB.Model(&database.Question{}).Where("tag_id = ?", tag.ID).Count(&questionCount)
79 | if questionCount > 0 {
80 | Fail(c, 400, "话题仍在使用中")
81 | return
82 | }
83 | if err := database.DB.Delete(&tag).Error; err != nil {
84 | log.Error("failed to delete tag: ", err.Error())
85 | Fail(c, 500, "internal server error")
86 | return
87 | }
88 | Success(c, nil)
89 | }
90 |
--------------------------------------------------------------------------------
/internal/router/router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "joiask-backend/internal/controller"
5 | "net/http"
6 | "strconv"
7 |
8 | "github.com/gin-contrib/cors"
9 | "github.com/gin-contrib/sessions"
10 | "github.com/gin-contrib/sessions/cookie"
11 | "github.com/gin-gonic/contrib/static"
12 | "github.com/gin-gonic/gin"
13 | "github.com/sirupsen/logrus"
14 | "github.com/spf13/viper"
15 | )
16 |
17 | func Run() {
18 | r := gin.Default()
19 | r.Use(gin.Recovery())
20 | r.Use(cors.New(cors.Config{
21 | AllowOrigins: []string{"*"},
22 | AllowCredentials: true,
23 | AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
24 | }))
25 | store := cookie.NewStore([]byte("WhyJoiIsSoCute"))
26 | store.Options(sessions.Options{
27 | Path: "/",
28 | Secure: false,
29 | SameSite: http.SameSiteDefaultMode,
30 | })
31 | r.Use(sessions.Sessions("session", store))
32 | r.Use(static.Serve("/", static.LocalFile("./frontend/public/", true)))
33 | r.Use(static.Serve("/admin", static.LocalFile("./frontend/public/", true)))
34 | r.Use(static.Serve("/tags", static.LocalFile("./frontend/public/", true)))
35 | r.Use(static.Serve("/rainbow", static.LocalFile("./frontend/public/", true)))
36 | r.Use(static.Serve("/image", static.LocalFile("./frontend/public/", true)))
37 | r.Use(static.Serve("/search", static.LocalFile("./frontend/public/", true)))
38 | api := r.Group("/api")
39 | tagController := new(controller.TagController)
40 | userController := new(controller.UserController)
41 | questionController := controller.NewQuestionController()
42 | configController := new(controller.ConfigController)
43 | {
44 | // User
45 | {
46 | api.POST("/login", userController.Login)
47 | api.GET("/info", authMiddleware, userController.Info)
48 | api.GET("/logout", authMiddleware, userController.Logout)
49 |
50 | api.GET("/user", authMiddleware, userController.Get)
51 | api.POST("/user", authMiddleware, userController.Post)
52 | api.PUT("/user/:id", authMiddleware, userController.Put)
53 | api.DELETE("/user/:id", authMiddleware, userController.Delete)
54 | }
55 | // Tag
56 | {
57 | api.GET("/tag", tagController.Get)
58 | api.PUT("/tag/:id", authMiddleware, tagController.Put)
59 | api.DELETE("/tag/:id", authMiddleware, tagController.Delete)
60 | api.POST("/tag", authMiddleware, tagController.Post)
61 | }
62 | // Question
63 | {
64 | api.GET("/question", questionController.Get)
65 | api.POST("/question", questionController.Post)
66 | api.PUT("/question/:id", authMiddleware, questionController.Put)
67 | api.POST("/question/:id/emoji", questionController.Emoji)
68 | api.GET("/sse", questionController.SSE)
69 | api.DELETE("/question/:id", authMiddleware, questionController.Delete)
70 | }
71 | // Config
72 | {
73 | api.GET("/config", configController.Get)
74 | api.PUT("/config", authMiddleware, configController.Put)
75 | }
76 | }
77 | address := viper.GetString("server.host") + ":" + strconv.Itoa(viper.GetInt("server.port"))
78 | logrus.Info(address)
79 | logrus.Error(r.Run(address))
80 | }
81 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module joiask-backend
2 |
3 | go 1.23
4 |
5 | require (
6 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19
7 | github.com/gin-gonic/gin v1.9.1
8 | github.com/samber/lo v1.27.0
9 | gorm.io/driver/sqlite v1.3.6
10 | )
11 |
12 | require (
13 | github.com/bytedance/sonic v1.11.2 // indirect
14 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
15 | github.com/chenzhuoyu/iasm v0.9.1 // indirect
16 | github.com/fsnotify/fsnotify v1.5.1 // indirect
17 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect
18 | github.com/gin-contrib/sse v0.1.0 // indirect
19 | github.com/go-playground/locales v0.14.1 // indirect
20 | github.com/go-playground/universal-translator v0.18.1 // indirect
21 | github.com/go-playground/validator/v10 v10.19.0 // indirect
22 | github.com/go-sql-driver/mysql v1.6.0 // indirect
23 | github.com/goccy/go-json v0.10.2 // indirect
24 | github.com/gorilla/context v1.1.1 // indirect
25 | github.com/gorilla/securecookie v1.1.1 // indirect
26 | github.com/gorilla/sessions v1.2.0 // indirect
27 | github.com/hashicorp/hcl v1.0.0 // indirect
28 | github.com/jinzhu/inflection v1.0.0 // indirect
29 | github.com/jinzhu/now v1.1.5 // indirect
30 | github.com/json-iterator/go v1.1.12 // indirect
31 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect
32 | github.com/leodido/go-urn v1.4.0 // indirect
33 | github.com/magiconair/properties v1.8.5 // indirect
34 | github.com/mattn/go-isatty v0.0.20 // indirect
35 | github.com/mitchellh/mapstructure v1.4.3 // indirect
36 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
37 | github.com/modern-go/reflect2 v1.0.2 // indirect
38 | github.com/pelletier/go-toml v1.9.4 // indirect
39 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect
40 | github.com/spf13/afero v1.6.0 // indirect
41 | github.com/spf13/cast v1.4.1 // indirect
42 | github.com/spf13/jwalterweatherman v1.1.0 // indirect
43 | github.com/spf13/pflag v1.0.5 // indirect
44 | github.com/subosito/gotenv v1.2.0 // indirect
45 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
46 | github.com/ugorji/go/codec v1.2.12 // indirect
47 | golang.org/x/arch v0.7.0 // indirect
48 | golang.org/x/crypto v0.21.0 // indirect
49 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
50 | golang.org/x/net v0.23.0 // indirect
51 | golang.org/x/sys v0.18.0 // indirect
52 | golang.org/x/text v0.14.0 // indirect
53 | google.golang.org/protobuf v1.33.0 // indirect
54 | gopkg.in/ini.v1 v1.66.2 // indirect
55 | gopkg.in/yaml.v2 v2.4.0 // indirect
56 | gopkg.in/yaml.v3 v3.0.1 // indirect
57 | )
58 |
59 | require (
60 | github.com/aliyun/aliyun-oss-go-sdk v2.2.1+incompatible
61 | github.com/mattn/go-sqlite3 v1.14.12
62 | golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect
63 | )
64 |
65 | require (
66 | github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect
67 | github.com/gin-contrib/cors v1.6.0
68 | github.com/gin-contrib/sessions v0.0.4
69 | github.com/satori/go.uuid v1.2.0 // indirect
70 | github.com/sirupsen/logrus v1.8.1
71 | github.com/spf13/viper v1.10.1
72 | gorm.io/driver/mysql v1.3.2
73 | gorm.io/gorm v1.23.4
74 | )
75 |
--------------------------------------------------------------------------------
/cmd/migrate/migrate.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "joiask-backend/internal/database"
6 | "joiask-backend/internal/database/oldmodels"
7 | "strings"
8 |
9 | log "github.com/sirupsen/logrus"
10 | "github.com/spf13/viper"
11 | "gorm.io/driver/mysql"
12 | "gorm.io/gorm"
13 | )
14 |
15 | // Only use for joi's question box
16 | func main() {
17 | log.Info("Starting migration")
18 | viper.SetConfigFile("./config/config.json")
19 | err := viper.ReadInConfig()
20 | if err != nil {
21 | log.Fatal(err)
22 | }
23 | log.Info("Config loaded")
24 | v1Dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", viper.GetString("mysql.user"), viper.GetString("mysql.pass"), viper.GetString("mysql.host"), viper.GetInt("mysql.port"), "jask")
25 | V1DB, err := gorm.Open(mysql.Open(v1Dsn), &gorm.Config{})
26 | if err != nil {
27 | log.Fatal(err)
28 | }
29 | v2Dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", viper.GetString("mysql.user"), viper.GetString("mysql.pass"), viper.GetString("mysql.host"), viper.GetInt("mysql.port"), "jask_v2")
30 | V2DB, err := gorm.Open(mysql.Open(v2Dsn), &gorm.Config{})
31 | if err != nil {
32 | log.Fatal(err)
33 | }
34 | // Tag Table
35 | var oldTags []oldmodels.Tag
36 | V1DB.Find(&oldTags)
37 | for _, tag := range oldTags {
38 | if tag.ID == 1 {
39 | continue
40 | }
41 | var newTag database.Tag
42 | newTag.ID = tag.ID
43 | newTag.CreatedAt = tag.CreatedAt
44 | newTag.UpdatedAt = tag.UpdatedAt
45 | newTag.TagName = tag.TagName
46 | newTag.Description = ""
47 | V2DB.Create(&newTag)
48 | }
49 | log.Info("Tag migration finished:", len(oldTags))
50 | // Question Table
51 | var oldQuestions []oldmodels.Question
52 | V1DB.Find(&oldQuestions)
53 | for _, question := range oldQuestions {
54 | var newQuestion database.Question
55 | newQuestion.ID = question.ID
56 | newQuestion.CreatedAt = question.CreatedAt
57 | newQuestion.UpdatedAt = question.UpdatedAt
58 | newQuestion.Content = question.Content
59 | newQuestion.Likes = question.Likes
60 | newQuestion.TagID = question.TagID
61 | newQuestion.ImagesNum = question.ImagesNum
62 | newQuestion.IsHide = question.IsHide
63 | newQuestion.IsRainbow = question.IsRainbow
64 | newQuestion.IsArchive = question.IsArchived
65 | newQuestion.IsPublish = question.IsPublished
66 | // Add address prefix
67 | images := strings.Split(question.Images, ";")
68 | for i, image := range images {
69 | if len(image) == 0 {
70 | continue
71 | }
72 | if i != len(images)-1 {
73 | newQuestion.Images += "https://i0.vjoi.cn/" + image + ";"
74 | } else {
75 | newQuestion.Images += "https://i0.vjoi.cn/" + image
76 | }
77 | }
78 | V2DB.Create(&newQuestion)
79 | }
80 | log.Info("Question migration finished:", len(oldQuestions))
81 | // LikeRecord Table
82 | var oldLikeRecords []oldmodels.LikeRecord
83 | V1DB.Find(&oldLikeRecords)
84 | for _, likeRecord := range oldLikeRecords {
85 | var newLikeRecord database.LikeRecord
86 | newLikeRecord.ID = likeRecord.ID
87 | newLikeRecord.CreatedAt = likeRecord.CreatedAt
88 | newLikeRecord.UpdatedAt = likeRecord.UpdatedAt
89 | newLikeRecord.IP = likeRecord.IP
90 | newLikeRecord.QuestionID = likeRecord.QuestionID
91 | V2DB.Create(&newLikeRecord)
92 | }
93 | log.Info("LikeRecord migration finished:", len(oldLikeRecords))
94 | }
95 |
--------------------------------------------------------------------------------
/internal/controller/user_controller.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "joiask-backend/internal/database"
5 |
6 | "github.com/gin-contrib/sessions"
7 | "github.com/gin-gonic/gin"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | type UserController struct{}
12 |
13 | type LoginRequest struct {
14 | Username string `json:"username"`
15 | Password string `json:"password"`
16 | }
17 |
18 | type UserModifyRequest struct {
19 | Username string `json:"username"`
20 | Password string `json:"password"`
21 | }
22 |
23 | type UserAddRequest struct {
24 | Username string `json:"username"`
25 | Password string `json:"password"`
26 | }
27 |
28 | func (*UserController) Info(c *gin.Context) {
29 | Success(c, c.MustGet("user"))
30 | }
31 |
32 | func (*UserController) Login(c *gin.Context) {
33 | var request LoginRequest
34 | if err := c.ShouldBindJSON(&request); err != nil {
35 | Fail(c, 400, "请求无效")
36 | return
37 | }
38 | var user database.Admin
39 | err := database.DB.Where("username = ?", request.Username).First(&user).Error
40 | if err != nil {
41 | Fail(c, 404, "用户不存在")
42 | return
43 | }
44 | if request.Password != user.Password {
45 | Fail(c, 401, "密码错误")
46 | return
47 | }
48 | session := sessions.Default(c)
49 | session.Set("user", user.ID)
50 | session.Set("authed", true)
51 | err = session.Save()
52 | if err != nil {
53 | Fail(c, 500, "内部错误")
54 | return
55 | }
56 | Success(c, nil)
57 | }
58 |
59 | func (*UserController) Logout(c *gin.Context) {
60 | session := sessions.Default(c)
61 | session.Delete("user")
62 | session.Delete("authed")
63 | err := session.Save()
64 | if err != nil {
65 | Fail(c, 500, "内部错误")
66 | return
67 | }
68 | Success(c, nil)
69 | }
70 |
71 | func (*UserController) Get(c *gin.Context) {
72 | var users []database.Admin
73 | database.DB.Find(&users)
74 | Success(c, users)
75 | }
76 |
77 | func (*UserController) Put(c *gin.Context) {
78 | userModel := c.MustGet("user").(database.Admin)
79 | if userModel.Username != "admin" {
80 | Fail(c, 403, "没有权限")
81 | return
82 | }
83 | var user database.Admin
84 | database.DB.First(&user, c.Param("id"))
85 | if user.ID == 0 {
86 | Fail(c, 404, "用户不存在")
87 | return
88 | }
89 | var request UserModifyRequest
90 | if err := c.ShouldBindJSON(&request); err != nil {
91 | Fail(c, 400, "请求无效")
92 | return
93 | }
94 | if request.Username != "" {
95 | user.Username = request.Username
96 | }
97 | user.Password = request.Password
98 | if err := database.DB.Save(&user).Error; err != nil {
99 | log.Errorf("failed to save user: %v", err)
100 | Fail(c, 500, "内部错误")
101 | return
102 | }
103 | Success(c, user)
104 | }
105 |
106 | func (*UserController) Post(c *gin.Context) {
107 | userModel := c.MustGet("user").(database.Admin)
108 | if userModel.Username != "admin" {
109 | Fail(c, 403, "权限不足")
110 | return
111 | }
112 | var request UserAddRequest
113 | if err := c.ShouldBindJSON(&request); err != nil {
114 | Fail(c, 400, "请求无效")
115 | return
116 | }
117 | var user database.Admin
118 | user.Username = request.Username
119 | user.Password = request.Password
120 | if err := database.DB.Create(&user).Error; err != nil {
121 | log.Errorf("failed to create user: %v", err)
122 | Fail(c, 501, "创建用户失败")
123 | return
124 | }
125 | Success(c, user)
126 | }
127 |
128 | func (*UserController) Delete(c *gin.Context) {
129 | userModel := c.MustGet("user").(database.Admin)
130 | if userModel.Username != "admin" {
131 | Fail(c, 403, "没有权限")
132 | return
133 | }
134 | var user database.Admin
135 | database.DB.First(&user, c.Param("id"))
136 | if user.ID == 0 {
137 | Fail(c, 404, "用户不存在")
138 | return
139 | }
140 | if user.Username == "admin" {
141 | Fail(c, 403, "不能删除默认管理员")
142 | return
143 | }
144 | if err := database.DB.Delete(&user).Error; err != nil {
145 | log.Errorf("failed to delete user: %v", err)
146 | Fail(c, 502, "删除用户失败")
147 | return
148 | }
149 | Success(c, nil)
150 | }
151 |
--------------------------------------------------------------------------------
/internal/controller/question_controller.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "joiask-backend/internal/database"
9 | "joiask-backend/internal/storage"
10 | "joiask-backend/pkg/util"
11 | "path"
12 | "strconv"
13 | "strings"
14 | "sync"
15 | "time"
16 |
17 | "github.com/gin-contrib/sessions"
18 | "github.com/gin-contrib/sse"
19 | "github.com/gin-gonic/gin"
20 | log "github.com/sirupsen/logrus"
21 | "gorm.io/gorm/clause"
22 | )
23 |
24 | // 1 for emoji op
25 | // 2 for archive op
26 | const (
27 | SSEventEmoji = iota + 1
28 | SSEventArchive
29 | )
30 |
31 | type SSEvent struct {
32 | Type int
33 | Data any
34 | }
35 |
36 | type EmojiRecords struct {
37 | CardID int `json:"card_id"`
38 | Emojis []*EmojiRecord `json:"emojis"`
39 | }
40 |
41 | type QuestionController struct {
42 | eventChan chan SSEvent
43 | clients map[chan SSEvent]bool
44 | clientsMutex sync.Mutex
45 | }
46 |
47 | func NewQuestionController() *QuestionController {
48 | controller := &QuestionController{
49 | eventChan: make(chan SSEvent),
50 | clients: make(map[chan SSEvent]bool),
51 | }
52 | // Start broadcast goroutine
53 | go controller.broadcast()
54 | return controller
55 | }
56 |
57 | func (this *QuestionController) broadcast() {
58 | for event := range this.eventChan {
59 | // Broadcast to all clients
60 | this.clientsMutex.Lock()
61 | for client := range this.clients {
62 | select {
63 | case client <- event:
64 | default:
65 | // Remove client if channel is full
66 | delete(this.clients, client)
67 | close(client)
68 | }
69 | }
70 | this.clientsMutex.Unlock()
71 | }
72 | }
73 |
74 | type QuestionRequest struct {
75 | OrderBy string `form:"order_by"`
76 | Order string `form:"order"`
77 | Page int `form:"page"`
78 | PageSize int `form:"page_size"`
79 | TagID int `form:"tag_id"`
80 | Search string `form:"search"`
81 | Hide bool `form:"hide"`
82 | Rainbow bool `form:"rainbow"`
83 | Archive bool `form:"archive"`
84 | Publish bool `form:"publish"`
85 | }
86 |
87 | type QuestionModifyRequest struct {
88 | TagID int `json:"tag_id"`
89 | IsHide bool `json:"is_hide"`
90 | IsRainbow bool `json:"is_rainbow"`
91 | IsArchive bool `json:"is_archive"`
92 | IsPublish bool `json:"is_publish"`
93 | }
94 |
95 | // Get questions
96 | func (*QuestionController) Get(c *gin.Context) {
97 | // Prepare for auth
98 | if sessions.Default(c).Get("authed") == true {
99 | c.Set("authed", true)
100 | }
101 | var questionList []database.Question
102 | var total int64
103 | var request QuestionRequest
104 | if err := c.Bind(&request); err != nil {
105 | Fail(c, 400, "请求错误")
106 | return
107 | }
108 | var tx = database.DB.Model(&database.Question{}).Preload(clause.Associations).Order(getOrderBy(request.OrderBy) + " " + getOrder(request.Order))
109 | if getOrderBy(request.OrderBy) != "is_archive" {
110 | tx = tx.Order("is_archive asc")
111 | }
112 | if _, ok := c.GetQuery("tag_id"); ok {
113 | if request.TagID > 0 {
114 | tx = tx.Where("tag_id = ?", request.TagID)
115 | }
116 | }
117 | if _, ok := c.GetQuery("search"); ok {
118 | tx = tx.Where("content like ?", "%"+request.Search+"%")
119 | }
120 | if _, ok := c.GetQuery("hide"); ok {
121 | tx = tx.Where("is_hide = ?", request.Hide)
122 | }
123 | if _, ok := c.GetQuery("rainbow"); ok {
124 | tx = tx.Where("is_rainbow = ?", request.Rainbow)
125 | }
126 | if _, ok := c.GetQuery("archive"); ok {
127 | tx = tx.Where("is_archive = ?", request.Archive)
128 | }
129 | if _, ok := c.GetQuery("publish"); ok {
130 | // Normal users can only see published questions
131 | log.Debug("publish: ", request.Publish)
132 | if !c.GetBool("authed") {
133 | tx = tx.Where("is_publish = ?", true)
134 | } else {
135 | tx = tx.Where("is_publish = ?", request.Publish)
136 | }
137 | } else {
138 | // Normal users can only see published questions
139 | if !c.GetBool("authed") {
140 | tx = tx.Where("is_publish = ?", true)
141 | }
142 | }
143 | err := tx.Count(&total).Scopes(paginate(getPage(request.Page), getPageSize(request.PageSize))).Find(&questionList).Error
144 | if err != nil {
145 | log.Error(err)
146 | Fail(c, 500, "获取提问失败")
147 | return
148 | }
149 | Success(c, gin.H{
150 | "questions": questionList,
151 | "total": total,
152 | "page": getPage(request.Page),
153 | "page_size": getPageSize(request.PageSize),
154 | })
155 | }
156 |
157 | func (this *QuestionController) Put(c *gin.Context) {
158 | var request QuestionModifyRequest
159 | if err := c.ShouldBindJSON(&request); err != nil {
160 | Fail(c, 400, "请求错误")
161 | return
162 | }
163 | var q database.Question
164 | database.DB.First(&q, c.Param("id"))
165 | if q.ID == 0 {
166 | Fail(c, 404, "提问不存在")
167 | return
168 | }
169 | if request.TagID == 0 {
170 | Fail(c, 400, "请求错误")
171 | return
172 | }
173 | q.TagID = request.TagID
174 | q.IsHide = request.IsHide
175 | q.IsRainbow = request.IsRainbow
176 | q.IsArchive = request.IsArchive
177 | q.IsPublish = request.IsPublish
178 | err := database.DB.Save(&q).Error
179 | if err != nil {
180 | log.Error(err)
181 | Fail(c, 500, "修改提问失败")
182 | return
183 | }
184 | this.eventChan <- SSEvent{
185 | Type: SSEventArchive,
186 | Data: q.ID,
187 | }
188 | Success(c, nil)
189 | }
190 |
191 | func (*QuestionController) Post(c *gin.Context) {
192 | var tag database.Tag
193 | tagID := c.PostForm("tag_id")
194 | database.DB.First(&tag, tagID)
195 | if tag.ID == 0 {
196 | Fail(c, 404, "话题不存在")
197 | return
198 | }
199 | var q database.Question
200 | q.TagID = int(tag.ID)
201 | q.Content = strings.Trim(c.PostForm("content"), " \r\n\t")
202 | q.IsHide = c.PostForm("hide") == "true"
203 | q.IsRainbow = c.PostForm("rainbow") == "true"
204 | mp, _ := c.MultipartForm()
205 | for _, v := range mp.File["files[]"] {
206 | f, err := v.Open()
207 | if err != nil {
208 | log.Error(err)
209 | Fail(c, 405, "文件上传失败")
210 | return
211 | }
212 | fileContent, err := io.ReadAll(f)
213 | if err != nil {
214 | log.Error(err)
215 | Fail(c, 405, "文件上传失败")
216 | return
217 | }
218 | newFileName := util.Md5v(string(fileContent)) + path.Ext(v.Filename)
219 | url, err := storage.Get().Upload(newFileName, bytes.NewReader(fileContent))
220 | if err != nil {
221 | log.Error(err)
222 | Fail(c, 500, "文件上传失败")
223 | return
224 | }
225 | q.ImagesNum++
226 | if q.Images == "" {
227 | q.Images = url
228 | } else {
229 | q.Images += ";" + url
230 | }
231 | }
232 | err := database.DB.Save(&q).Error
233 | if err != nil {
234 | log.Error(err)
235 | Fail(c, 500, "创建提问失败")
236 | return
237 | }
238 | Success(c, nil)
239 | }
240 |
241 | func (*QuestionController) Delete(c *gin.Context) {
242 | var q database.Question
243 | id := c.Param("id")
244 | err := database.DB.First(&q, id).Error
245 | if q.ID == 0 {
246 | log.Error(err)
247 | Fail(c, 404, "提问不存在")
248 | return
249 | }
250 | tx := database.DB.Begin()
251 | tx.Delete(&database.LikeRecord{}, "question_id", q.ID)
252 | tx.Delete(&q)
253 | if tx.Error != nil {
254 | log.Error(err)
255 | Fail(c, 500, "删除提问失败")
256 | tx.Rollback()
257 | return
258 | }
259 | tx.Commit()
260 | // clean images in storage
261 | key := "upload-img/"
262 | images := strings.Split(q.Images, ";")
263 | filenames := []string{}
264 | for i := range images {
265 | cur := images[i]
266 | parts := strings.SplitN(cur, key, 2)
267 | if len(parts) < 2 {
268 | continue
269 | }
270 | filenames = append(filenames, parts[1])
271 | }
272 | // it's ok to fail deleting
273 | for _, f := range filenames {
274 | _ = storage.Get().Delete(f)
275 | }
276 | Success(c, nil)
277 | }
278 |
279 | type EmojiRecord struct {
280 | Value string `json:"value"`
281 | Count int `json:"count"`
282 | }
283 |
284 | var EmojiValid = map[string]bool{
285 | "👍": true,
286 | "👎": true,
287 | "🤣": true,
288 | "😭": true,
289 | "😓": true,
290 | "😬": true,
291 | "🥳": true,
292 | "😨": true,
293 | "😠": true,
294 | "💩": true,
295 | "💖": true,
296 | "🐵": true,
297 | "❓": true,
298 | "🫂": true,
299 | "🔘": true,
300 | "👅": true,
301 | "🥺": true,
302 | "👻": true,
303 | "😅": true,
304 | "🌹": true,
305 | }
306 |
307 | func (this *QuestionController) Emoji(c *gin.Context) {
308 | emojiToAdd := c.PostForm("emoji")
309 | // should check emojiToAdd valid or not
310 | if !EmojiValid[emojiToAdd] {
311 | Fail(c, 400, "无效的表情符号")
312 | return
313 | }
314 | // ip := c.ClientIP()
315 | id, _ := strconv.Atoi(c.Param("id"))
316 | // var lr database.LikeRecord
317 | // database.DB.Where("ip = ? and question_id = ?", ip, id).First(&lr)
318 | // if lr.ID > 0 {
319 | // Fail(c, 400, "您已经评价过了")
320 | // return
321 | // }
322 | // lr.IP = ip
323 | // lr.QuestionID = id
324 | // tx := database.DB.Begin()
325 | // err := tx.Create(&lr).Error
326 | // if err != nil {
327 | // log.Error(err)
328 | // Fail(c, 500, "评价失败")
329 | // tx.Rollback()
330 | // return
331 | // }
332 | tx := database.DB.Begin()
333 | var q database.Question
334 | err := database.DB.Where("id = ?", id).First(&q).Error
335 | if err != nil {
336 | log.Error(err)
337 | Fail(c, 500, "评价失败")
338 | tx.Rollback()
339 | return
340 | }
341 | var emojis []*EmojiRecord
342 | if len(q.Emojis) > 0 {
343 | err = json.Unmarshal([]byte(q.Emojis), &emojis)
344 | if err != nil {
345 | log.Error((err))
346 | Fail(c, 500, "解析表情失败")
347 | tx.Rollback()
348 | return
349 | }
350 | }
351 | added := false
352 | for _, e := range emojis {
353 | if e.Value == emojiToAdd {
354 | added = true
355 | e.Count++
356 | break
357 | }
358 | }
359 | if !added {
360 | emojis = append(emojis, &EmojiRecord{
361 | Value: emojiToAdd,
362 | Count: 1,
363 | })
364 | }
365 | updatedList, err := json.Marshal(emojis)
366 | if err != nil {
367 | log.Error(err)
368 | Fail(c, 500, "评价失败")
369 | tx.Rollback()
370 | return
371 | }
372 | q.Emojis = string(updatedList)
373 | err = tx.Save(&q).Error
374 | if err != nil {
375 | log.Error(err)
376 | Fail(c, 500, "评价失败")
377 | tx.Rollback()
378 | return
379 | }
380 | if tx.Commit().Error != nil {
381 | log.Error(err)
382 | Fail(c, 500, "评价失败")
383 | tx.Rollback()
384 | return
385 | }
386 | this.eventChan <- SSEvent{
387 | Type: SSEventEmoji,
388 | Data: EmojiRecords{
389 | CardID: id,
390 | Emojis: emojis,
391 | },
392 | }
393 | Success(c, nil)
394 | }
395 |
396 | func (this *QuestionController) SSE(c *gin.Context) {
397 | // Set SSE headers
398 | c.Header("Content-Type", "text/event-stream")
399 | c.Header("Cache-Control", "no-cache")
400 | c.Header("Connection", "keep-alive")
401 | c.Header("Access-Control-Allow-Origin", "*")
402 | c.Header("X-Accel-Buffering", "no") // Disable buffering for Nginx
403 |
404 | // Create a channel for this client
405 | clientChan := make(chan SSEvent, 1024)
406 |
407 | // Add client to the connection manager
408 | this.clientsMutex.Lock()
409 | this.clients[clientChan] = true
410 | this.clientsMutex.Unlock()
411 |
412 | // Create a channel to handle client disconnection
413 | clientGone := c.Writer.CloseNotify()
414 |
415 | // Create a ticker for heartbeat with initial immediate tick
416 | heartbeat := time.NewTicker(15 * time.Second)
417 | defer heartbeat.Stop()
418 |
419 | // Create a message ID counter
420 | messageID := 0
421 |
422 | // Start streaming
423 | c.Stream(func(w io.Writer) bool {
424 | // Send initial connection message on first call
425 | if messageID == 0 {
426 | err := sse.Encode(w, sse.Event{
427 | Event: "connected",
428 | Data: "connected",
429 | })
430 | if err != nil {
431 | log.Error("Failed to encode connected event:", err)
432 | return false
433 | }
434 | messageID++
435 | return true
436 | }
437 |
438 | select {
439 | case <-clientGone:
440 | log.Info("Client disconnected")
441 | // Remove client from connection manager
442 | this.clientsMutex.Lock()
443 | delete(this.clients, clientChan)
444 | this.clientsMutex.Unlock()
445 | close(clientChan)
446 | return false
447 | case <-heartbeat.C:
448 | // Send heartbeat comment
449 | err := sse.Encode(w, sse.Event{
450 | Event: "heartbeat",
451 | Data: "heartbeat",
452 | })
453 | if err != nil {
454 | log.Error("Failed to encode heartbeat event:", err)
455 | return false
456 | }
457 | return true
458 | case emojis := <-clientChan:
459 | emojiJson, err := json.Marshal(emojis)
460 | if err != nil {
461 | log.Error("Failed to marshal emoji data:", err)
462 | return false
463 | }
464 |
465 | // Send emoji update
466 | err = sse.Encode(w, sse.Event{
467 | Id: fmt.Sprintf("%d", messageID),
468 | Event: "emoji",
469 | Data: string(emojiJson),
470 | })
471 | if err != nil {
472 | log.Error("Failed to encode emoji event:", err)
473 | return false
474 | }
475 | messageID++
476 | return true
477 | case <-time.After(30 * time.Second):
478 | // Send retry message in case of timeout
479 | err := sse.Encode(w, sse.Event{
480 | Event: "retry",
481 | Retry: 10000,
482 | })
483 | if err != nil {
484 | log.Error("Failed to encode retry event:", err)
485 | return false
486 | }
487 | return true
488 | }
489 | })
490 |
491 | // Cleanup when connection is closed
492 | log.Info("SSE connection closed")
493 | }
494 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aliyun/aliyun-oss-go-sdk v2.2.1+incompatible h1:uuJIwCFhbZy+zdvLy5zrcIToPEQP0s5CFOZ0Zj03O/w=
2 | github.com/aliyun/aliyun-oss-go-sdk v2.2.1+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
3 | github.com/antonlindstrom/pgstore v0.0.0-20200229204646-b08ebf1105e0/go.mod h1:2Ti6VUHVxpC0VSmTZzEvpzysnaGAfGBOoMIz5ykPyyw=
4 | github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f h1:ZNv7On9kyUzm7fvRZumSyy/IUiSC7AzL0I1jKKtwooA=
5 | github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc=
6 | github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
7 | github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
8 | github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
9 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
10 | github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
11 | github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
12 | github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
13 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
14 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
15 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
16 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
17 | github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
18 | github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
19 | github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
23 | github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
24 | github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
25 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
26 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
27 | github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg=
28 | github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
29 | github.com/gin-contrib/sessions v0.0.4 h1:gq4fNa1Zmp564iHP5G6EBuktilEos8VKhe2sza1KMgo=
30 | github.com/gin-contrib/sessions v0.0.4/go.mod h1:pQ3sIyviBBGcxgyR8mkeJuXbeV3h3NYmhJADQTq5+Vo=
31 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
32 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
33 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 h1:J2LPEOcQmWaooBnBtUDV9KHFEnP5LYTZY03GiQ0oQBw=
34 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg=
35 | github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
36 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
37 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
38 | github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
39 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
40 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
41 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
42 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
43 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
44 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
45 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
46 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
47 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
48 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
49 | github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
50 | github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
51 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
52 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
53 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
54 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
55 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
56 | github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
57 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
58 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
59 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
60 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
61 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
62 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
63 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
64 | github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
65 | github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
66 | github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
67 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
68 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
69 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
70 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
71 | github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
72 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
73 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
74 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
75 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
76 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
77 | github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
78 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
79 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
80 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
81 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
82 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
83 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
84 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
85 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
86 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
87 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
88 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
89 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
90 | github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
91 | github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
92 | github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
93 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
94 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
95 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
96 | github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
97 | github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
98 | github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
99 | github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
100 | github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
101 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
102 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
103 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
104 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
105 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
106 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
107 | github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
108 | github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
109 | github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
110 | github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
111 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
112 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
113 | github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
114 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
115 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
116 | github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
117 | github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
118 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
119 | github.com/samber/lo v1.27.0 h1:GOyDWxsblvqYobqsmUuMddPa2/mMzkKyojlXol4+LaQ=
120 | github.com/samber/lo v1.27.0/go.mod h1:it33p9UtPMS7z72fP4gw/EIfQB2eI8ke7GR2wc6+Rhg=
121 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
122 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
123 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
124 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
125 | github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
126 | github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
127 | github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
128 | github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
129 | github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
130 | github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
131 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
132 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
133 | github.com/spf13/viper v1.10.1 h1:nuJZuYpG7gTj/XqiUwg8bA0cp1+M2mC3J4g5luUYBKk=
134 | github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU=
135 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
136 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
137 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
138 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
139 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
140 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
141 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
142 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
143 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
144 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
145 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
146 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
147 | github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
148 | github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
149 | github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
150 | github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
151 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
152 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
153 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
154 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
155 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
156 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
157 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
158 | golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
159 | golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
160 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
161 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
162 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
163 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
164 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
165 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
166 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
167 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
168 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
169 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
170 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
171 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
172 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
173 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
174 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
175 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
176 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
177 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
178 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
179 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
180 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
181 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
182 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
183 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
184 | golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
185 | golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
186 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
187 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
188 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
189 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
190 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
191 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
192 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
193 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
194 | gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
195 | gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
196 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
197 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
198 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
199 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
200 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
201 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
202 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
203 | gorm.io/driver/mysql v1.3.2 h1:QJryWiqQ91EvZ0jZL48NOpdlPdMjdip1hQ8bTgo4H7I=
204 | gorm.io/driver/mysql v1.3.2/go.mod h1:ChK6AHbHgDCFZyJp0F+BmVGb06PSIoh9uVYKAlRbb2U=
205 | gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
206 | gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
207 | gorm.io/gorm v1.23.1/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
208 | gorm.io/gorm v1.23.4 h1:1BKWM67O6CflSLcwGQR7ccfmC4ebOxQrTfOQGRE9wjg=
209 | gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
210 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
211 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
212 |
--------------------------------------------------------------------------------