├── .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 | ![main page](doc/screenshot.png) 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 | --------------------------------------------------------------------------------