├── .dockerignore
├── .gitattributes
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── bootstrap
├── app.go
├── init.go
└── static.go
├── build.sh
├── conf
└── config.yml
├── controllers
├── site.go
├── user.go
└── wiki.go
├── frontend
├── .gitignore
├── .yarnclean
├── package.json
├── public
│ ├── index.html
│ ├── logo.png
│ ├── manifest.json
│ ├── profile.png
│ └── robots.txt
├── src
│ ├── App.js
│ ├── App.test.js
│ ├── actions
│ │ └── index.js
│ ├── assets
│ │ ├── css
│ │ │ ├── index.css
│ │ │ ├── markdown.css
│ │ │ ├── nord.css
│ │ │ └── prism.css
│ │ ├── font
│ │ │ └── Inter
│ │ │ │ ├── Inter-Black.woff
│ │ │ │ ├── Inter-Black.woff2
│ │ │ │ ├── Inter-BlackItalic.woff
│ │ │ │ ├── Inter-BlackItalic.woff2
│ │ │ │ ├── Inter-Bold.woff
│ │ │ │ ├── Inter-Bold.woff2
│ │ │ │ ├── Inter-BoldItalic.woff
│ │ │ │ ├── Inter-BoldItalic.woff2
│ │ │ │ ├── Inter-ExtraBold.woff
│ │ │ │ ├── Inter-ExtraBold.woff2
│ │ │ │ ├── Inter-ExtraBoldItalic.woff
│ │ │ │ ├── Inter-ExtraBoldItalic.woff2
│ │ │ │ ├── Inter-ExtraLight.woff
│ │ │ │ ├── Inter-ExtraLight.woff2
│ │ │ │ ├── Inter-ExtraLightItalic.woff
│ │ │ │ ├── Inter-ExtraLightItalic.woff2
│ │ │ │ ├── Inter-Italic.woff
│ │ │ │ ├── Inter-Italic.woff2
│ │ │ │ ├── Inter-Light.woff
│ │ │ │ ├── Inter-Light.woff2
│ │ │ │ ├── Inter-LightItalic.woff
│ │ │ │ ├── Inter-LightItalic.woff2
│ │ │ │ ├── Inter-Medium.woff
│ │ │ │ ├── Inter-Medium.woff2
│ │ │ │ ├── Inter-MediumItalic.woff
│ │ │ │ ├── Inter-MediumItalic.woff2
│ │ │ │ ├── Inter-Regular.woff
│ │ │ │ ├── Inter-Regular.woff2
│ │ │ │ ├── Inter-SemiBold.woff
│ │ │ │ ├── Inter-SemiBold.woff2
│ │ │ │ ├── Inter-SemiBoldItalic.woff
│ │ │ │ ├── Inter-SemiBoldItalic.woff2
│ │ │ │ ├── Inter-Thin.woff
│ │ │ │ ├── Inter-Thin.woff2
│ │ │ │ ├── Inter-ThinItalic.woff
│ │ │ │ ├── Inter-ThinItalic.woff2
│ │ │ │ ├── Inter-italic.var.woff2
│ │ │ │ ├── Inter-roman.var.woff2
│ │ │ │ ├── Inter.var.woff2
│ │ │ │ └── inter.css
│ │ ├── img
│ │ │ └── card.png
│ │ └── js
│ │ │ └── prism.js
│ ├── components
│ │ ├── Article
│ │ │ ├── Article.js
│ │ │ ├── Highlight.js
│ │ │ └── Latex.js
│ │ ├── Common
│ │ │ ├── NotFound.js
│ │ │ └── Snackbar.js
│ │ ├── Login
│ │ │ └── Login.js
│ │ ├── Navbar
│ │ │ └── Navbar.js
│ │ └── Tag
│ │ │ └── Tag.js
│ ├── index.js
│ ├── logo.svg
│ ├── middleware
│ │ ├── Api.js
│ │ ├── Auth.js
│ │ ├── AuthRoute.js
│ │ └── Init.js
│ ├── reducers
│ │ └── index.js
│ ├── serviceWorker.js
│ ├── setupTests.js
│ └── utils
│ │ └── index.js
└── yarn.lock
├── go.mod
├── go.sum
├── images
├── article.jpg
├── home.jpg
├── login.jpg
├── repo.jpg
└── search.jpg
├── main.go
├── middleware
├── auth.go
├── option.go
└── session.go
├── model
├── article.go
├── init.go
├── migration.go
├── repo.go
├── tag.go
└── user.go
├── pkg
├── conf
│ ├── config.go
│ └── config_test.go
├── serializer
│ ├── error.go
│ ├── response.go
│ └── user.go
├── session
│ └── session.go
└── utils
│ ├── common.go
│ ├── file.go
│ ├── hash.go
│ └── log.go
├── routers
└── router.go
└── service
├── article.go
├── refresh.go
├── search.go
├── tag.go
└── user.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | statik
2 | *.log
3 | *.db
4 | vinki
5 |
6 | # dependencies
7 | frontend/node_modules
8 | frontend/.pnp
9 | .pnp.js
10 |
11 | # testing
12 | frontend/coverage
13 |
14 | # production
15 | frontend/build
16 |
17 | # misc
18 | .DS_Store
19 | .env.local
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 |
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.css linguist-detectable=false
2 | *.html linguist-detectable=false
3 | *.js linguist-detectable=false
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### Example user template template
3 | ### Example user template
4 |
5 | # IntelliJ project files
6 | .idea
7 | *.iml
8 | out
9 | gen
10 | ### Go template
11 | # Binaries for programs and plugins
12 | *.exe
13 | *.exe~
14 | *.dll
15 | *.so
16 | *.dylib
17 |
18 | # Test binary, built with `go test -c`
19 | *.test
20 |
21 | # Output of the go coverage tool, specifically when used with LiteIDE
22 | *.out
23 |
24 | # Dependency directories (remove the comment below to include it)
25 | # vendor/
26 |
27 | ### macOS template
28 | # General
29 | .DS_Store
30 | .AppleDouble
31 | .LSOverride
32 |
33 | # Icon must end with two \r
34 | Icon
35 |
36 | # Thumbnails
37 | ._*
38 |
39 | # Files that might appear in the root of a volume
40 | .DocumentRevisions-V100
41 | .fseventsd
42 | .Spotlight-V100
43 | .TemporaryItems
44 | .Trashes
45 | .VolumeIcon.icns
46 | .com.apple.timemachine.donotpresent
47 |
48 | # Directories potentially created on remote AFP share
49 | .AppleDB
50 | .AppleDesktop
51 | Network Trash Folder
52 | Temporary Items
53 | .apdisk
54 |
55 | ### JetBrains template
56 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
57 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
58 |
59 | # User-specific stuff
60 | .idea/**/workspace.xml
61 | .idea/**/tasks.xml
62 | .idea/**/usage.statistics.xml
63 | .idea/**/dictionaries
64 | .idea/**/shelf
65 |
66 | # Generated files
67 | .idea/**/contentModel.xml
68 |
69 | # Sensitive or high-churn files
70 | .idea/**/dataSources/
71 | .idea/**/dataSources.ids
72 | .idea/**/dataSources.local.xml
73 | .idea/**/sqlDataSources.xml
74 | .idea/**/dynamic.xml
75 | .idea/**/uiDesigner.xml
76 | .idea/**/dbnavigator.xml
77 |
78 | # Gradle
79 | .idea/**/gradle.xml
80 | .idea/**/libraries
81 |
82 | # Gradle and Maven with auto-import
83 | # When using Gradle or Maven with auto-import, you should exclude module files,
84 | # since they will be recreated, and may cause churn. Uncomment if using
85 | # auto-import.
86 | # .idea/artifacts
87 | # .idea/compiler.xml
88 | # .idea/modules.xml
89 | # .idea/*.iml
90 | # .idea/modules
91 | # *.iml
92 | # *.ipr
93 |
94 | # CMake
95 | cmake-build-*/
96 |
97 | # Mongo Explorer plugin
98 | .idea/**/mongoSettings.xml
99 |
100 | # File-based project format
101 | *.iws
102 |
103 | # IntelliJ
104 | out/
105 |
106 | # mpeltonen/sbt-idea plugin
107 | .idea_modules/
108 |
109 | # JIRA plugin
110 | atlassian-ide-plugin.xml
111 |
112 | # Cursive Clojure plugin
113 | .idea/replstate.xml
114 |
115 | # Crashlytics plugin (for Android Studio and IntelliJ)
116 | com_crashlytics_export_strings.xml
117 | crashlytics.properties
118 | crashlytics-build.properties
119 | fabric.properties
120 |
121 | # Editor-based Rest Client
122 | .idea/httpRequests
123 |
124 | # Android studio 3.1+ serialized cache file
125 | .idea/caches/build_file_checksums.ser
126 |
127 | .idea/.gitignore
128 | .idea/misc.xml
129 | .idea/modules.xml
130 | .idea/vcs.xml
131 | .idea/vinki.iml
132 | .idea/watcherTasks.xml
133 |
134 | vinki
135 |
136 | *.log
137 |
138 | # dependencies
139 | frontend/node_modules
140 | frontend/.pnp
141 | .pnp.js
142 |
143 | # testing
144 | frontend/coverage
145 |
146 | # production
147 | frontend/build
148 |
149 | # misc
150 | .DS_Store
151 | .env.local
152 | .env.development.local
153 | .env.test.local
154 | .env.production.local
155 |
156 | npm-debug.log*
157 | yarn-debug.log*
158 | yarn-error.log*
159 |
160 | # db
161 | *.db
162 |
163 |
164 | /statik/
165 |
166 | .vscode
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mhart/alpine-node:14 AS front-build
2 | WORKDIR /frontend
3 | COPY ./frontend .
4 | RUN yarn config set registry https://registry.npm.taobao.org
5 | RUN yarn install
6 | RUN yarn run build
7 |
8 | FROM golang:1.14-alpine AS build
9 | MAINTAINER renzo "luyang.sun@outlook.com"
10 |
11 | WORKDIR $GOPATH/src/github.com/louisun/vinki
12 | COPY . .
13 | COPY --from=front-build /frontend/build ./statics
14 | ENV GOPROXY=https://goproxy.io
15 | RUN go get github.com/rakyll/statik
16 | RUN statik -src=statics -include="*.html,*.js,*.json,*.css,*.png,*.svg,*.ico,*.woff,*.woff2,*.txt" -f
17 | RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
18 | RUN apk add --update gcc g++
19 | RUN go build -o vinki .
20 |
21 | FROM alpine:latest AS prod
22 | COPY --from=build /go/src/github.com/louisun/vinki/vinki /vinki/
23 | WORKDIR /vinki
24 | EXPOSE 6166
25 | ENTRYPOINT ["./vinki"]
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Renzo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Vinki
2 |
3 | Vinki 是一款面向个人的轻量级 wiki 服务,用于快速预览和查询知识文档,有以下特点:
4 |
5 | - **安全和便捷**。所有文档都由本地 Markdown 文件生成,文件都在本地,在安全性、可迁移性上都比「在线的第三方服务」更好。
6 |
7 | - **高效地预览本地文档**。传统文件缺乏快速、便捷的查询和浏览方式,Vinki 旨在提供一种更优雅的方式利用自己的知识库。
8 | - **无侵入**。Vinki 只负责文档的浏览、查询,不负责文档的编辑与管理,不对原始文件的组织形式做任何更改,用户只需要配置本地 Markdown 目录树的根路径,即可生成 wiki 仓库。
9 |
10 | ## Feature
11 |
12 | - 多仓库切换
13 | - 灵活选择多级标签
14 | - 标签、文档搜索
15 | - 文档预览:同标签文档列表、TOC 跳转
16 | - 权限控制
17 |
18 | 
19 |
20 | 
21 |
22 | 
23 |
24 | 
25 |
26 | 
27 |
28 | ## Philosophy
29 |
30 | > Vinki 源自 Wiki(维基),结合了漫画「冰海战记」[Vinland Saga](https://en.wikipedia.org/wiki/Vinland_Saga_(manga)) 的名称。
31 | >
32 | > UI 也是来自北欧的 [Nord](https://www.nordtheme.com/) 配色,旨在提供简洁愉快的阅读体验。
33 |
34 | ## Else
35 |
36 | > 注:目前由于 Markdown 渲染库不完善,文件中仅支持星号 `*` 作为 emphasis 斜体,`_` 下划线无作用。
37 |
38 | ## Usage
39 |
40 | ```bash
41 | # build
42 | ./build.sh -b
43 |
44 | # run
45 | ./vinki -c ~/.vinki/config.yml
46 | ```
47 |
48 | 配置文件示例:
49 |
50 | ```yaml
51 | # ~/.vinki/config.yml
52 | system:
53 | debug: false
54 | port: 6167
55 |
56 | repositories:
57 | - root: "~/Cloudz/Notes/Code"
58 | exclude:
59 | - "Effective Java"
60 | - root: "~/louisun/Cloudz/Notes/Reading"
61 | - root: "~/louisun/Cloudz/Notes/Life"
62 | exclude:
63 | - "Fold"
64 | ```
65 |
66 | 上面配置了 3 个仓库的路径(包括要排除的文件或目录名),服务端口为 `6167`。
67 |
68 | ```properties
69 | # default admin
70 | admin: admin@vinki.org
71 | password: vinkipass
72 | ```
73 |
74 | ### Docker
75 |
76 | > 若使用 Docker 运行服务,主要变动是配置文件。
77 |
78 | **一、制作镜像**
79 |
80 | 可以直接从仓库拉取作者的镜像:
81 |
82 | ```bash
83 | docker pull louisun/vinki:latest
84 | ```
85 |
86 | 也可以在本地构建:
87 |
88 | ```bash
89 | docker build -t louisun/vinki .
90 | ```
91 |
92 | **二、创建配置**
93 |
94 | > 在 Docker 环境下,**需要映射目录到容器中**:
95 | >
96 | > - 容器中的服务默认读取的配置文件为`/vinki/conf/config.yaml`,因此要将配置文件映射到该路径;
97 | > - 建议**将所有仓库目录放到同一个目录下**,再结合 `config.yaml` 映射到容器目录。
98 |
99 | 下面的有 3 个仓库 `仓库A`、`仓库B` 和 `仓库C`,统一放在宿主机 `/Users/dog/vinki` 目录下:
100 |
101 | ```bash
102 | vinki
103 | ├── 仓库A
104 | ├── 仓库B
105 | └── 仓库C
106 | ```
107 |
108 | 下面会映射上述目录至容器中的目录,推荐为 `/vinki/repository`。
109 |
110 | 假如本机配置放在 `/Users/dog/.vinki/conf/config.yml` 中, 需要做如下的配置:
111 |
112 | ```yaml
113 | system:
114 | debug: false
115 | port: 6166
116 |
117 | repositories:
118 | - root: "/vinki/repository/仓库A"
119 | - root: "/vinki/repository/仓库B"
120 | - root: "/vinki/repository/仓库C"
121 | exclude:
122 | - "排除目录名"
123 | - "排除文件名"
124 | ```
125 |
126 | **三、启动容器**:
127 |
128 | ```bash
129 | docker run -d --name vinki -p 6166:6166 \
130 | -v /Users/dog/vinki:/vinki/repository \
131 | -v /Users/dog/.vinki/conf:/vinki/conf \
132 | louisun/vinki:latest
133 | ```
134 |
--------------------------------------------------------------------------------
/bootstrap/app.go:
--------------------------------------------------------------------------------
1 | package bootstrap
2 |
3 | import "fmt"
4 |
5 | func InitApplication() {
6 | fmt.Print(`
7 | _ _ _ _ _
8 | | | | (_) | | (_)
9 | | | | |_ _ __ | | ___
10 | | | | | | '_ \| |/ / |
11 | \ \_/ / | | | | <| |
12 | \___/|_|_| |_|_|\_\_|
13 | `)
14 | }
15 |
--------------------------------------------------------------------------------
/bootstrap/init.go:
--------------------------------------------------------------------------------
1 | package bootstrap
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/louisun/vinki/model"
6 | "github.com/louisun/vinki/pkg/conf"
7 | "github.com/louisun/vinki/pkg/utils"
8 | )
9 |
10 | func Init(path string) {
11 | InitApplication()
12 | // 加载配置文件
13 | conf.Init(path)
14 | // 设置 gin 模式
15 | if !conf.GlobalConfig.System.Debug {
16 | utils.Log().Info("gin 当前为 Release 模式")
17 | gin.SetMode(gin.ReleaseMode)
18 | } else {
19 | utils.Log().Info("gin 当前为 Test 模式")
20 | gin.SetMode(gin.TestMode)
21 | }
22 | // 初始化 Redis
23 | // cache.Init()
24 |
25 | // 初始化数据库
26 | model.Init()
27 |
28 | // 初始化静态文件系统 StaticFS
29 | InitStatic()
30 |
31 | // 初始化通用鉴权器
32 | //auth.Init()
33 | }
34 |
--------------------------------------------------------------------------------
/bootstrap/static.go:
--------------------------------------------------------------------------------
1 | package bootstrap
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/louisun/vinki/pkg/utils"
7 |
8 | "github.com/gin-contrib/static"
9 | _ "github.com/louisun/vinki/statik"
10 | "github.com/rakyll/statik/fs"
11 | )
12 |
13 | // GinFS 实现了 ServeFileSystem 和 FileSystem 接口
14 | type GinFS struct {
15 | FS http.FileSystem
16 | }
17 |
18 | var StaticFS static.ServeFileSystem
19 |
20 | func (b *GinFS) Open(name string) (http.File, error) {
21 | return b.FS.Open(name)
22 | }
23 |
24 | func (b *GinFS) Exists(prefix string, filepath string) bool {
25 | if _, err := b.FS.Open(filepath); err != nil {
26 | return false
27 | }
28 |
29 | return true
30 | }
31 |
32 | // InitStatic 初始化静态资源的文件系统
33 | func InitStatic() {
34 | var err error
35 |
36 | if utils.ExistsDir(utils.RelativePath("statics")) {
37 | utils.Log().Info("检测到 statics 目录存在,将使用此目录下的静态资源文件")
38 | // 使用 gin-contrib 的 static.LoadFile 创建静态文件系统
39 | StaticFS = static.LocalFile(utils.RelativePath("statics"), false)
40 | } else {
41 | // 不存在 static 目录,加载 statik 注册的数据(Zip压缩的字符串),创建文件系统
42 | StaticFS = &GinFS{}
43 | StaticFS.(*GinFS).FS, err = fs.New()
44 | if err != nil {
45 | utils.Log().Panicf("无法初始化静态资源, %s", err)
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | usage() {
2 | echo " [Vinki Build Scripts]"
3 | echo "[Params] default='--all'"
4 | echo
5 | echo "--all build all project (frontend end backend)"
6 | echo "--front build web files (including generate static go file system)"
7 | echo "--back build go project"
8 | }
9 |
10 | if [[ $# == 0 ]];then
11 | usage
12 | exit 0
13 | fi
14 |
15 | REPO=$(cd $(dirname $0); pwd)
16 |
17 | buildFrontend () {
18 | cd "$REPO" || exit
19 | rm -rf frontend/build
20 | rm -f statik/statik.go
21 |
22 | export CI=false
23 |
24 | cd "$REPO"/frontend || exit
25 |
26 | yarn install
27 | echo "[INFO] yarn install finished"
28 | yarn run build
29 | echo "[INFO] yarn build finished"
30 |
31 | if ! [ -x "$(command -v statik)" ]; then
32 | export CGO_ENABLED=0
33 | go get github.com/rakyll/statik
34 | fi
35 |
36 | cd "$REPO" || exit
37 | statik -src=frontend/build/ -include="*.html,*.js,*.json,*.css,*.png,*.svg,*.ico,*.woff,*.woff2,*.txt" -f
38 | echo "[INFO] static/statik.go generated"
39 | echo "[SUCCESS] build frontend done"
40 | }
41 |
42 | buildBackend () {
43 | cd "$REPO" || exit
44 | echo "[INFO] go build -a -o vinki"
45 | go build -a -o vinki
46 | echo "[INFO] vinki binary generated"
47 | echo "[SUCCESS] build backend done"
48 | }
49 |
50 |
51 | buildAll () {
52 | buildFrontend
53 | buildBackend
54 | }
55 |
56 | case $1 in
57 | -a|--all)
58 | buildAll
59 | ;;
60 | -f | --front)
61 | buildFrontend
62 | ;;
63 | -b | --back)
64 | buildBackend
65 | ;;
66 | *)
67 | usage
68 | ;;
69 | esac
70 |
--------------------------------------------------------------------------------
/conf/config.yml:
--------------------------------------------------------------------------------
1 | system:
2 | debug: false
3 | port: 6166
4 |
5 | repositories:
6 | - root: "./repository"
7 | exclude:
8 | - "A Directory"
9 | - "B Directory"
10 |
--------------------------------------------------------------------------------
/controllers/site.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/louisun/vinki/pkg/serializer"
6 | "github.com/louisun/vinki/pkg/utils"
7 | "github.com/louisun/vinki/service"
8 | )
9 |
10 | func Ping(c *gin.Context) {
11 | c.JSON(200, serializer.CreateSuccessResponse("pong", "服务器状态正常"))
12 | }
13 |
14 | // GetSiteConfig 获取用户站点配置
15 | func GetSiteConfig(c *gin.Context) {
16 | user := GetCurrentUserFromCtx(c)
17 |
18 | if user != nil {
19 | c.JSON(200, serializer.CreateUserResponse(user))
20 | return
21 | }
22 | c.JSON(200, serializer.GetUnauthorizedResponse())
23 | return
24 | }
25 |
26 | // RefreshAll 刷新所有内容
27 | func RefreshAll(c *gin.Context) {
28 | res := service.RefreshGlobal()
29 | c.JSON(200, res)
30 | }
31 |
32 | // RefreshByRepo 刷新某 Repo 下的 articles
33 | func RefreshByRepo(c *gin.Context) {
34 | var repo service.RepoRequest
35 | if err := c.ShouldBindJSON(&repo); err != nil {
36 | c.JSON(200, serializer.CreateParamErrorResponse(err))
37 | } else {
38 | utils.Log().Info("repo.RepoName = ", repo.RepoName)
39 | res := service.RefreshRepo(repo.RepoName)
40 | c.JSON(200, res)
41 | }
42 | }
43 |
44 | // RefreshByTag 刷新某 Tag 下的 articles
45 | func RefreshByTag(c *gin.Context) {
46 | var tag service.RepoTagRequest
47 | if err := c.ShouldBindJSON(&tag); err != nil {
48 | c.JSON(200, serializer.CreateParamErrorResponse(err))
49 | } else {
50 | res := service.RefreshTag(tag.RepoName, tag.TagName)
51 | c.JSON(200, res)
52 | }
53 | }
54 |
55 | // Search 搜索
56 | func Search(c *gin.Context) {
57 | res := service.Search(c)
58 | c.JSON(200, res)
59 | }
60 |
--------------------------------------------------------------------------------
/controllers/user.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/louisun/vinki/model"
6 | "github.com/louisun/vinki/pkg/serializer"
7 | "github.com/louisun/vinki/service"
8 | )
9 |
10 | // GetCurrentUserFromCtx 获取当前用户
11 | func GetCurrentUserFromCtx(c *gin.Context) *model.User {
12 | if user, _ := c.Get("user"); user != nil {
13 | if u, ok := user.(*model.User); ok {
14 | return u
15 | }
16 | }
17 |
18 | return nil
19 | }
20 |
21 | // UserRegister 用户注册
22 | func UserRegister(c *gin.Context) {
23 | var s service.UserRegisterRequest
24 | if err := c.ShouldBindJSON(&s); err != nil {
25 | c.JSON(200, serializer.CreateParamErrorResponse(err))
26 | } else {
27 | res := s.Register(c)
28 | c.JSON(200, res)
29 | }
30 | }
31 |
32 | // UserLogin 用户登录
33 | func UserLogin(c *gin.Context) {
34 | var s service.UserLoginRequest
35 | if err := c.ShouldBindJSON(&s); err != nil {
36 | c.JSON(200, serializer.CreateParamErrorResponse(err))
37 | } else {
38 | res := s.Login(c)
39 | c.JSON(200, res)
40 | }
41 | }
42 |
43 | // UserLogout 用户登出
44 | func UserLogout(c *gin.Context) {
45 | var s service.UserLogoutRequest
46 | res := s.Logout(c)
47 | c.JSON(200, res)
48 | }
49 |
50 | // UserPasswordReset 用户重置密码
51 | func UserResetPassword(c *gin.Context) {
52 | var s service.UserResetRequest
53 | if err := c.ShouldBindJSON(&s); err != nil {
54 | c.JSON(200, serializer.CreateParamErrorResponse(err))
55 | } else {
56 | var user *model.User
57 | userCtx, ok := c.Get("user")
58 | if !ok {
59 | c.JSON(200, serializer.GetUnauthorizedResponse())
60 | return
61 | }
62 | user = userCtx.(*model.User)
63 | // 校验并重置密码
64 | res := s.ResetPassword(c, user)
65 | c.JSON(200, res)
66 | }
67 | }
68 |
69 | // GetApplications 管理员查看用户申请
70 | func GetApplications(c *gin.Context) {
71 | var s service.GetApplicationsRequest
72 | res := s.GetApplications()
73 | c.JSON(200, res)
74 | }
75 |
76 | // ActivateUser 激活用户
77 | func ActivateUser(c *gin.Context) {
78 | var s service.ActivateUserRequest
79 | if err := c.ShouldBindJSON(&s); err != nil {
80 | c.JSON(200, serializer.CreateParamErrorResponse(err))
81 | } else {
82 | res := s.ActivateUser()
83 | c.JSON(200, res)
84 | }
85 | }
86 |
87 | // RejectUserApplication 拒绝用户申请
88 | func RejectUserApplication(c *gin.Context) {
89 | var s service.RejectUserRequest
90 | if err := c.ShouldBindJSON(&s); err != nil {
91 | c.JSON(200, serializer.CreateParamErrorResponse(err))
92 | } else {
93 | res := s.RejectUser()
94 | c.JSON(200, res)
95 | }
96 | }
97 |
98 | // BanUser 封禁用户
99 | func BanUser(c *gin.Context) {
100 | var s service.BanUserRequest
101 | if err := c.ShouldBindJSON(&s); err != nil {
102 | c.JSON(200, serializer.CreateParamErrorResponse(err))
103 | } else {
104 | res := s.BanUser()
105 | c.JSON(200, res)
106 | }
107 | }
108 |
109 | // ApplyForActivate 向管理员申请激活
110 | func ApplyForActivate(c *gin.Context) {
111 | // 实际就是修改自己的 user 状态和申请 message
112 | var s service.ApplyForActivateRequest
113 | if err := c.ShouldBindJSON(&s); err != nil {
114 | c.JSON(200, serializer.CreateParamErrorResponse(err))
115 | } else {
116 | var user *model.User
117 | userCtx, ok := c.Get("user")
118 | if !ok {
119 | c.JSON(200, serializer.GetUnauthorizedResponse())
120 | return
121 | }
122 | user = userCtx.(*model.User)
123 | res := s.ApplyForActivate(c, user)
124 | c.JSON(200, res)
125 | }
126 | }
127 |
128 | // SetCurrentRepo 设置当前的仓库
129 | func SetCurrentRepo(c *gin.Context) {
130 | var s service.UserSetCurrentRepo
131 | if err := c.ShouldBindJSON(&s); err != nil {
132 | c.JSON(200, serializer.CreateParamErrorResponse(err))
133 | } else {
134 | var user *model.User
135 | userCtx, ok := c.Get("user")
136 | if !ok {
137 | c.JSON(200, serializer.GetUnauthorizedResponse())
138 | return
139 | }
140 |
141 | user = userCtx.(*model.User)
142 | res := s.SetCurrentRepo(user.ID)
143 |
144 | c.JSON(200, res)
145 | }
146 | }
147 |
148 | // GetCurrentRepo 获取当前仓库
149 | func GetCurrentRepo(c *gin.Context) {
150 | var user *model.User
151 | userCtx, ok := c.Get("user")
152 | if !ok {
153 | c.JSON(200, serializer.GetUnauthorizedResponse())
154 | return
155 | }
156 |
157 | user = userCtx.(*model.User)
158 | res := service.GetCurrentRepo(user.ID)
159 |
160 | c.JSON(200, res)
161 | }
162 |
--------------------------------------------------------------------------------
/controllers/wiki.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/louisun/vinki/pkg/serializer"
8 | "github.com/louisun/vinki/service"
9 | )
10 |
11 | // GetArticle 获取某文章详细信息
12 | func GetArticle(c *gin.Context) {
13 | repoName := c.Query("repoName")
14 | tagName := c.Query("tagName")
15 |
16 | articleName := c.Query("articleName")
17 | if repoName == "" || tagName == "" || articleName == "" {
18 | c.JSON(200, serializer.CreateGeneralParamErrorResponse("", errors.New("仓库名、标签名和文章名不能为空")))
19 | return
20 | }
21 |
22 | res := service.GetArticleDetail(repoName, tagName, articleName)
23 | c.JSON(200, res)
24 |
25 | return
26 | }
27 |
28 | // GetTagView 获取某 Tag 的基本信息
29 | func GetTagView(c *gin.Context) {
30 | repoName := c.Query("repoName")
31 | tagName := c.Query("tagName")
32 |
33 | if repoName == "" || tagName == "" {
34 | c.JSON(200, serializer.CreateGeneralParamErrorResponse("", errors.New("仓库名和标签名不能为空")))
35 | return
36 | }
37 |
38 | var flat bool
39 | if c.Query("flat") == "true" {
40 | flat = true
41 | }
42 |
43 | var res serializer.Response
44 |
45 | res = service.GetTagArticleView(repoName, tagName, flat)
46 | c.JSON(200, res)
47 |
48 | return
49 | }
50 |
51 | // GetTopTags 获取某 Repo 下的一级标签列表
52 | func GetTopTags(c *gin.Context) {
53 | repoName := c.Query("repoName")
54 | if repoName == "" {
55 | c.JSON(200, serializer.CreateGeneralParamErrorResponse("", errors.New("仓库名不能为空")))
56 | return
57 | }
58 |
59 | res := service.GetTopTagInfosByRepo(repoName)
60 | c.JSON(200, res)
61 |
62 | return
63 | }
64 |
65 | // GetRepos 获取所有 Repo 列表
66 | func GetRepos(c *gin.Context) {
67 | user := GetCurrentUserFromCtx(c)
68 | if user != nil {
69 | c.JSON(200, serializer.CreateSuccessResponse(user.RepoNames, ""))
70 | return
71 | }
72 |
73 | c.JSON(200, serializer.GetUnauthorizedResponse())
74 |
75 | return
76 | }
77 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/frontend/.yarnclean:
--------------------------------------------------------------------------------
1 | # test directories
2 | __tests__
3 | test
4 | tests
5 | powered-test
6 |
7 | # asset directories
8 | docs
9 | doc
10 | website
11 | images
12 | assets
13 |
14 | # examples
15 | example
16 | examples
17 |
18 | # code coverage directories
19 | coverage
20 | .nyc_output
21 |
22 | # build scripts
23 | Makefile
24 | Gulpfile.js
25 | Gruntfile.js
26 |
27 | # configs
28 | appveyor.yml
29 | circle.yml
30 | codeship-services.yml
31 | codeship-steps.yml
32 | wercker.yml
33 | .tern-project
34 | .gitattributes
35 | .editorconfig
36 | .*ignore
37 | .eslintrc
38 | .jshintrc
39 | .flowconfig
40 | .documentup.json
41 | .yarn-metadata.json
42 | .travis.yml
43 |
44 | # misc
45 | *.md
46 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@fortawesome/fontawesome-svg-core": "^1.2.28",
7 | "@fortawesome/free-brands-svg-icons": "^5.13.0",
8 | "@fortawesome/free-solid-svg-icons": "^5.13.0",
9 | "@fortawesome/react-fontawesome": "^0.1.9",
10 | "@material-ui/core": "^4.9.9",
11 | "@material-ui/icons": "^4.9.1",
12 | "@material-ui/lab": "^4.0.0-alpha.51",
13 | "@testing-library/jest-dom": "^4.2.4",
14 | "@testing-library/react": "^9.3.2",
15 | "@testing-library/user-event": "^7.1.2",
16 | "axios": "^0.19.2",
17 | "classnames": "^2.2.6",
18 | "font-awesome": "^4.7.0",
19 | "highlight.js": "^10.4.1",
20 | "prop-types": "^15.7.2",
21 | "react": "^16.13.1",
22 | "react-dom": "^16.13.1",
23 | "react-highlight": "^0.12.0",
24 | "react-redux": "^7.2.0",
25 | "react-router-dom": "^5.1.2",
26 | "react-scripts": "3.4.1",
27 | "redux": "^4.0.5"
28 | },
29 | "scripts": {
30 | "start": "react-scripts start",
31 | "build": "react-scripts build",
32 | "test": "react-scripts test",
33 | "eject": "react-scripts eject"
34 | },
35 | "eslintConfig": {
36 | "extends": "react-app"
37 | },
38 | "browserslist": {
39 | "production": [
40 | ">0.2%",
41 | "not dead",
42 | "not op_mini all"
43 | ],
44 | "development": [
45 | "last 1 chrome version",
46 | "last 1 firefox version",
47 | "last 1 safari version"
48 | ]
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 | Vinki
15 |
16 |
17 |
18 |
19 |
24 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/frontend/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/public/logo.png
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Vinki",
3 | "name": "Vinki App",
4 | "icons": [
5 | {
6 | "src": "logo.png",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/public/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/public/profile.png
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | Redirect,
5 | Route,
6 | Switch,
7 | } from 'react-router-dom';
8 |
9 | import {
10 | CssBaseline,
11 | makeStyles,
12 | } from '@material-ui/core';
13 |
14 | import Article from './components/Article/Article';
15 | import NotFound from './components/Common/NotFound';
16 | import MessageBar from './components/Common/Snackbar';
17 | import Login from './components/Login/Login';
18 | import Navbar from './components/Navbar/Navbar';
19 | import Tags from './components/Tag/Tag';
20 | import Auth from './middleware/Auth';
21 | import AuthRoute from './middleware/AuthRoute';
22 |
23 | const useStyles = makeStyles(() => ({
24 | container: {
25 | minHeight: "100%",
26 | },
27 | root: {
28 | minHeight: "calc(100vh - 50px)",
29 | marginTop: "50px",
30 | display: "flex",
31 | justifyContent: "space-between",
32 | backgroundColor: "#F2F4F8",
33 | // boxShadow: "0 12px 15px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19)"
34 | },
35 | }));
36 |
37 |
38 | export default function App() {
39 | const classes = useStyles();
40 | return (
41 |
42 |
43 | {/*基准样式*/}
44 |
45 |
46 | {/*导航栏*/}
47 |
48 | {/*主内容*/}
49 |
50 |
51 |
52 |
53 |
54 | (
55 | Auth.Check() ?
56 | () :
57 | ()
62 | )} />
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/frontend/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | const { getByText } = render();
7 | const linkElement = getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/frontend/src/actions/index.js:
--------------------------------------------------------------------------------
1 | // Actions
2 | export const toggleSnackbar = (vertical, horizontal, msg, color) => {
3 | return {
4 | type: "TOGGLE_SNACKBAR",
5 | vertical: vertical,
6 | horizontal: horizontal,
7 | msg: msg,
8 | color: color
9 | };
10 | };
11 |
12 | export const setLoginStatus = (status) => {
13 | return {
14 | type: 'SET_LOGIN_STATUS',
15 | status: status,
16 | };
17 | };
18 |
19 | export const setRepos = repos => {
20 | return {
21 | type: "SET_REPOS",
22 | repos: repos,
23 | }
24 | }
25 |
26 | export const setCurrentRepo = currentRepo => {
27 | return {
28 | type: "SET_CURRENT_REPO",
29 | currentRepo: currentRepo,
30 | }
31 | }
32 |
33 | export const setTopTags = topTags => {
34 | return {
35 | type: "SET_TOP_TAGS",
36 | topTags: topTags,
37 | }
38 | }
39 |
40 |
41 | export const setSecondTags = secondTags => {
42 | return {
43 | type: "SET_SECOND_TAGS",
44 | secondTags: secondTags,
45 | }
46 | }
47 |
48 | export const setSubTags = subTags => {
49 | return {
50 | type: "SET_SUB_TAGS",
51 | subTags: subTags,
52 | }
53 | }
54 |
55 | export const setCurrentTopTag = currentTopTag => {
56 | return {
57 | type: "SET_CURRENT_TOP_TAG",
58 | currentTopTag: currentTopTag,
59 | }
60 | }
61 |
62 | export const setCurrentTag = currentTag => {
63 | return {
64 | type: "SET_CURRENT_TAG",
65 | currentTag: currentTag,
66 | }
67 | }
68 |
69 | export const setArticleList = articleList => {
70 | return {
71 | type: "SET_ARTICLE_LIST",
72 | articleList: articleList,
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/frontend/src/assets/css/index.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | margin: 0;
3 | padding: 0;
4 | /* max-height: 100%; */
5 | background-color: #f2f4f8;
6 | }
7 |
8 | body {
9 | /* 解决侧边滑动引起界面抖动 */
10 | overflow-y: scroll;
11 | }
12 |
13 | /* ::-webkit-scrollbar {
14 | width: 0;
15 | } */
16 |
17 | #root {
18 | height: 100%;
19 | }
20 |
21 | .mjx-chtml {
22 | color: rgb(59, 65, 79);
23 | }
--------------------------------------------------------------------------------
/frontend/src/assets/css/markdown.css:
--------------------------------------------------------------------------------
1 | @import url("../font/Inter/inter.css");
2 | /* 全局 */
3 |
4 | .markdown-body {
5 | -ms-text-size-adjust: 100%;
6 | -webkit-text-size-adjust: 100%;
7 | font-family: inter, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
8 | line-height: 1.5;
9 | word-wrap: break-word;
10 | color: #3b424e;
11 | font-size: 16px;
12 | }
13 |
14 | .markdown-body .octicon {
15 | display: inline-block;
16 | fill: currentColor;
17 | vertical-align: text-bottom;
18 | }
19 |
20 | .markdown-body .anchor {
21 | float: left;
22 | line-height: 1;
23 | margin-left: -20px;
24 | padding-right: 4px;
25 | }
26 |
27 | .markdown-body .anchor:focus {
28 | outline: none;
29 | }
30 |
31 | .markdown-body h1 .octicon-link, .markdown-body h2 .octicon-link, .markdown-body h3 .octicon-link, .markdown-body h4 .octicon-link, .markdown-body h5 .octicon-link, .markdown-body h6 .octicon-link {
32 | color: #1b1f23;
33 | vertical-align: middle;
34 | visibility: hidden;
35 | }
36 |
37 | .markdown-body h1:hover .anchor, .markdown-body h2:hover .anchor, .markdown-body h3:hover .anchor, .markdown-body h4:hover .anchor, .markdown-body h5:hover .anchor, .markdown-body h6:hover .anchor {
38 | text-decoration: none;
39 | }
40 |
41 | .markdown-body h1:hover .anchor .octicon-link, .markdown-body h2:hover .anchor .octicon-link, .markdown-body h3:hover .anchor .octicon-link, .markdown-body h4:hover .anchor .octicon-link, .markdown-body h5:hover .anchor .octicon-link, .markdown-body h6:hover .anchor .octicon-link {
42 | visibility: visible;
43 | }
44 |
45 | .markdown-body .pl-c {
46 | color: #6a737d;
47 | }
48 |
49 | .markdown-body .pl-c1, .markdown-body .pl-s .pl-v {
50 | color: #005cc5;
51 | }
52 |
53 | .markdown-body .pl-e, .markdown-body .pl-en {
54 | color: #6f42c1;
55 | }
56 |
57 | .markdown-body .pl-s .pl-s1, .markdown-body .pl-smi {
58 | color: #24292e;
59 | }
60 |
61 | .markdown-body .pl-ent {
62 | color: #22863a;
63 | }
64 |
65 | .markdown-body .pl-k {
66 | color: #d73a49;
67 | }
68 |
69 | .markdown-body .pl-pds, .markdown-body .pl-s, .markdown-body .pl-s .pl-pse .pl-s1, .markdown-body .pl-sr, .markdown-body .pl-sr .pl-cce, .markdown-body .pl-sr .pl-sra, .markdown-body .pl-sr .pl-sre {
70 | color: #032f62;
71 | }
72 |
73 | .markdown-body .pl-smw, .markdown-body .pl-v {
74 | color: #e36209;
75 | }
76 |
77 | .markdown-body .pl-bu {
78 | color: #b31d28;
79 | }
80 |
81 | .markdown-body .pl-ii {
82 | background-color: #b31d28;
83 | color: #fafbfc;
84 | }
85 |
86 | .markdown-body .pl-c2 {
87 | background-color: #d73a49;
88 | color: #fafbfc;
89 | }
90 |
91 | .markdown-body .pl-c2:before {
92 | content: "^M";
93 | }
94 |
95 | .markdown-body .pl-sr .pl-cce {
96 | color: #22863a;
97 | font-weight: 700;
98 | }
99 |
100 | .markdown-body .pl-ml {
101 | color: #735c0f;
102 | }
103 |
104 | .markdown-body .pl-mh, .markdown-body .pl-mh .pl-en, .markdown-body .pl-ms {
105 | color: #005cc5;
106 | font-weight: 700;
107 | }
108 |
109 | .markdown-body .pl-mi {
110 | color: #24292e;
111 | font-style: italic;
112 | }
113 |
114 | .markdown-body .pl-mb {
115 | color: #24292e;
116 | font-weight: 700;
117 | }
118 |
119 | .markdown-body .pl-md {
120 | background-color: #ffeef0;
121 | color: #b31d28;
122 | }
123 |
124 | .markdown-body .pl-mi1 {
125 | background-color: #f0fff4;
126 | color: #22863a;
127 | }
128 |
129 | .markdown-body .pl-mc {
130 | background-color: #ffebda;
131 | color: #e36209;
132 | }
133 |
134 | .markdown-body .pl-mi2 {
135 | background-color: #005cc5;
136 | color: #f6f8fa;
137 | }
138 |
139 | .markdown-body .pl-mdr {
140 | color: #6f42c1;
141 | font-weight: 700;
142 | }
143 |
144 | .markdown-body .pl-ba {
145 | color: #586069;
146 | }
147 |
148 | .markdown-body .pl-sg {
149 | color: #959da5;
150 | }
151 |
152 | .markdown-body .pl-corl {
153 | color: #032f62;
154 | text-decoration: underline;
155 | }
156 |
157 | .markdown-body details {
158 | display: block;
159 | }
160 |
161 | .markdown-body summary {
162 | display: list-item;
163 | }
164 |
165 | .markdown-body a {
166 | background-color: transparent;
167 | }
168 |
169 | .markdown-body a:active, .markdown-body a:hover {
170 | outline-width: 0;
171 | }
172 |
173 | .markdown-body strong {
174 | font-weight: bold;
175 | padding: 0px 1px 0 1px;
176 | color: #4E5668;
177 | }
178 |
179 | .markdown-body em {
180 | padding: 0px 5px 0 2px;
181 | color: #da3a52;
182 | }
183 |
184 | .markdown-body h1 {
185 | font-size: 2em;
186 | margin: 0.67em 0;
187 | }
188 |
189 | .markdown-body img {
190 | border-style: none;
191 | }
192 |
193 | /* .markdown-body code,
194 | .markdown-body kbd,
195 | .markdown-body pre {
196 | font-family: monospace;
197 | } */
198 |
199 | .markdown-body hr {
200 | box-sizing: content-box;
201 | height: 0;
202 | overflow: visible;
203 | }
204 |
205 | .markdown-body input {
206 | font: inherit;
207 | margin: 0;
208 | }
209 |
210 | .markdown-body input {
211 | overflow: visible;
212 | }
213 |
214 | .markdown-body [type="checkbox"] {
215 | box-sizing: border-box;
216 | padding: 0;
217 | }
218 |
219 | .markdown-body * {
220 | box-sizing: border-box;
221 | }
222 |
223 | .markdown-body input {
224 | font-family: inherit;
225 | font-size: inherit;
226 | line-height: inherit;
227 | }
228 |
229 | .markdown-body a {
230 | color: #296cba;
231 | font-style: italic;
232 | text-decoration: underline;
233 | font-weight: bold;
234 | }
235 |
236 | .markdown-body a:hover {
237 | text-decoration: underline;
238 | }
239 |
240 | .markdown-body hr {
241 | background: transparent;
242 | border: 0;
243 | border-bottom: 1px solid #dfe2e5;
244 | height: 0;
245 | margin: 15px 0;
246 | overflow: hidden;
247 | }
248 |
249 | .markdown-body hr:before {
250 | content: "";
251 | display: table;
252 | }
253 |
254 | .markdown-body hr:after {
255 | clear: both;
256 | content: "";
257 | display: table;
258 | }
259 |
260 | .markdown-body table {
261 | border-collapse: collapse;
262 | border-spacing: 0;
263 | }
264 |
265 | .markdown-body td, .markdown-body th {
266 | padding: 0;
267 | }
268 |
269 | .markdown-body details summary {
270 | cursor: pointer;
271 | }
272 |
273 | .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
274 | margin-bottom: 0;
275 | margin-top: 0;
276 | }
277 |
278 | .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
279 | padding-top: 4px;
280 | padding-bottom: 3px;
281 | }
282 |
283 | .markdown-body h1 {
284 | font-size: 32px;
285 | color: #434c5e;
286 | text-align: center;
287 | }
288 |
289 | .markdown-body h1, .markdown-body h2 {
290 | font-weight: 600;
291 | }
292 |
293 | .markdown-body h2 {
294 | font-size: 24px;
295 | line-height: 1.225;
296 | padding-left: 9px;
297 | border-left: 8px solid #5c7fa9;
298 | color: #5c7fa9;
299 | background-color: #dce4ee;
300 | }
301 |
302 | .markdown-body h3 {
303 | font-size: 20px;
304 | line-height: 1.43;
305 | padding-left: 9px;
306 | border-left: 6px solid #96bb78;
307 | color: #69a934;
308 | background-color: #edf2e8;
309 | }
310 |
311 | .markdown-body h3, .markdown-body h4 {
312 | font-weight: 600;
313 | }
314 |
315 | .markdown-body h4 {
316 | font-size: 18px;
317 | padding-left: 9px;
318 | border-left: 6px solid #d8bb7d;
319 | color: #d3a748;
320 | }
321 |
322 | .markdown-body h5 {
323 | font-size: 14px;
324 | }
325 |
326 | .markdown-body h5, .markdown-body h6 {
327 | font-weight: 600;
328 | }
329 |
330 | .markdown-body h6 {
331 | font-size: 12px;
332 | }
333 |
334 | .markdown-body p {
335 | margin-bottom: 10px;
336 | margin-top: 0;
337 | }
338 |
339 | .markdown-body blockquote {
340 | margin: 0;
341 | }
342 |
343 | .markdown-body ol, .markdown-body ul {
344 | margin-bottom: 0;
345 | margin-top: 0;
346 | padding-left: 0;
347 | }
348 |
349 | .markdown-body ol ol, .markdown-body ul ol {
350 | list-style-type: lower-roman;
351 | }
352 |
353 | .markdown-body ol ol ol, .markdown-body ol ul ol, .markdown-body ul ol ol, .markdown-body ul ul ol {
354 | list-style-type: lower-alpha;
355 | }
356 |
357 | .markdown-body dd {
358 | margin-left: 0;
359 | }
360 |
361 | .markdown-body input::-webkit-inner-spin-button, .markdown-body input::-webkit-outer-spin-button {
362 | -webkit-appearance: none;
363 | appearance: none;
364 | margin: 0;
365 | }
366 |
367 | .markdown-body .border {
368 | border: 1px solid #e1e4e8 !important;
369 | }
370 |
371 | .markdown-body .border-0 {
372 | border: 0 !important;
373 | }
374 |
375 | .markdown-body .border-bottom {
376 | border-bottom: 1px solid #e1e4e8 !important;
377 | }
378 |
379 | .markdown-body .rounded-1 {
380 | border-radius: 3px !important;
381 | }
382 |
383 | .markdown-body .bg-white {
384 | background-color: #fff !important;
385 | }
386 |
387 | .markdown-body .bg-gray-light {
388 | background-color: #fafbfc !important;
389 | }
390 |
391 | .markdown-body .text-gray-light {
392 | color: #6a737d !important;
393 | }
394 |
395 | .markdown-body .mb-0 {
396 | margin-bottom: 0 !important;
397 | }
398 |
399 | .markdown-body .my-2 {
400 | margin-bottom: 8px !important;
401 | margin-top: 8px !important;
402 | }
403 |
404 | .markdown-body .pl-0 {
405 | padding-left: 0 !important;
406 | }
407 |
408 | .markdown-body .py-0 {
409 | padding-bottom: 0 !important;
410 | padding-top: 0 !important;
411 | }
412 |
413 | .markdown-body .pl-1 {
414 | padding-left: 4px !important;
415 | }
416 |
417 | .markdown-body .pl-2 {
418 | padding-left: 8px !important;
419 | }
420 |
421 | .markdown-body .py-2 {
422 | padding-bottom: 8px !important;
423 | padding-top: 8px !important;
424 | }
425 |
426 | .markdown-body .pl-3, .markdown-body .px-3 {
427 | padding-left: 16px !important;
428 | }
429 |
430 | .markdown-body .px-3 {
431 | padding-right: 16px !important;
432 | }
433 |
434 | .markdown-body .pl-4 {
435 | padding-left: 24px !important;
436 | }
437 |
438 | .markdown-body .pl-5 {
439 | padding-left: 32px !important;
440 | }
441 |
442 | .markdown-body .pl-6 {
443 | padding-left: 40px !important;
444 | }
445 |
446 | .markdown-body .f6 {
447 | font-size: 12px !important;
448 | }
449 |
450 | .markdown-body .lh-condensed {
451 | line-height: 1.25 !important;
452 | }
453 |
454 | .markdown-body .text-bold {
455 | font-weight: 600 !important;
456 | }
457 |
458 | .markdown-body:before {
459 | content: "";
460 | display: table;
461 | }
462 |
463 | .markdown-body:after {
464 | clear: both;
465 | content: "";
466 | display: table;
467 | }
468 |
469 | .markdown-body> :first-child {
470 | margin-top: 0 !important;
471 | }
472 |
473 | .markdown-body> :last-child {
474 | margin-bottom: 0 !important;
475 | }
476 |
477 | .markdown-body a:not([href]) {
478 | color: inherit;
479 | text-decoration: none;
480 | }
481 |
482 | .markdown-body blockquote, .markdown-body dl, .markdown-body ol, .markdown-body p, .markdown-body pre, .markdown-body table, .markdown-body ul {
483 | margin-bottom: 16px;
484 | margin-top: 0;
485 | }
486 |
487 | .markdown-body hr {
488 | background-color: #e1e4e8;
489 | border: 0;
490 | height: 0.25em;
491 | margin: 24px 0;
492 | padding: 0;
493 | }
494 |
495 | .markdown-body blockquote {
496 | border-left: 4px solid #5e81ad;
497 | padding: 10px 0px 10px 15px;
498 | background-color: #eceff4;
499 | font-size: 0.95em;
500 | }
501 |
502 | .markdown-body blockquote> :first-child {
503 | margin-top: 0;
504 | }
505 |
506 | .markdown-body blockquote> :last-child {
507 | margin-bottom: 0;
508 | }
509 |
510 | .markdown-body kbd {
511 | background-color: #fafbfc;
512 | border: 1px solid #c6cbd1;
513 | border-bottom-color: #959da5;
514 | border-radius: 3px;
515 | box-shadow: inset 0 -1px 0 #959da5;
516 | color: #444d56;
517 | display: inline-block;
518 | font-size: 11px;
519 | line-height: 10px;
520 | padding: 3px 5px;
521 | vertical-align: middle;
522 | }
523 |
524 | .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
525 | font-weight: 600;
526 | line-height: 1.25;
527 | margin-bottom: 16px;
528 | margin-top: 24px;
529 | }
530 |
531 | .markdown-body h1 {
532 | font-size: 2em;
533 | }
534 |
535 | .markdown-body h2 {
536 | font-size: 1.5em;
537 | }
538 |
539 | .markdown-body h3 {
540 | font-size: 1.25em;
541 | }
542 |
543 | .markdown-body h4 {
544 | font-size: 1.2em;
545 | }
546 |
547 | .markdown-body h5 {
548 | font-size: 0.875em;
549 | }
550 |
551 | .markdown-body h6 {
552 | color: #6a737d;
553 | font-size: 0.85em;
554 | }
555 |
556 | .markdown-body ol, .markdown-body ul {
557 | padding-left: 2em;
558 | }
559 |
560 | .markdown-body ol ol, .markdown-body ol ul, .markdown-body ul ol, .markdown-body ul ul {
561 | margin-bottom: 0;
562 | margin-top: 0;
563 | }
564 |
565 | .markdown-body li {
566 | word-wrap: break-all;
567 | }
568 |
569 | .markdown-body li>p {
570 | margin-top: 16px;
571 | }
572 |
573 | .markdown-body li+li {
574 | margin-top: 0.25em;
575 | }
576 |
577 | .markdown-body dl {
578 | padding: 0;
579 | }
580 |
581 | .markdown-body dl dt {
582 | font-size: 1em;
583 | font-style: italic;
584 | font-weight: 600;
585 | margin-top: 16px;
586 | padding: 0;
587 | }
588 |
589 | .markdown-body dl dd {
590 | margin-bottom: 16px;
591 | padding: 0 16px;
592 | }
593 |
594 | .markdown-body table {
595 | display: block;
596 | overflow: auto;
597 | width: 100%;
598 | }
599 |
600 | .markdown-body table th {
601 | font-weight: 600;
602 | }
603 |
604 | .markdown-body table td, .markdown-body table th {
605 | border: 1px solid #dfe2e5;
606 | padding: 6px 13px;
607 | }
608 |
609 | .markdown-body table tr {
610 | background-color: #fff;
611 | border-top: 1px solid #c6cbd1;
612 | }
613 |
614 | .markdown-body table tr:nth-child(2n) {
615 | background-color: #f6f8fa;
616 | }
617 |
618 | .markdown-body img {
619 | background-color: #fff;
620 | box-sizing: content-box;
621 | max-width: 600px;
622 | display: block;
623 | margin: 0 auto;
624 | }
625 |
626 | .markdown-body img[align="right"] {
627 | padding-left: 20px;
628 | }
629 |
630 | .markdown-body img[align="left"] {
631 | padding-right: 20px;
632 | }
633 |
634 | .markdown-body .commit-tease-sha {
635 | color: #444d56;
636 | display: inline-block;
637 | font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, monospace;
638 | font-size: 90%;
639 | }
640 |
641 | .markdown-body .blob-wrapper {
642 | border-bottom-left-radius: 3px;
643 | border-bottom-right-radius: 3px;
644 | overflow-x: auto;
645 | overflow-y: hidden;
646 | }
647 |
648 | .markdown-body .blob-wrapper-embedded {
649 | max-height: 240px;
650 | overflow-y: auto;
651 | }
652 |
653 | .markdown-body .blob-num {
654 | -moz-user-select: none;
655 | -ms-user-select: none;
656 | -webkit-user-select: none;
657 | color: rgba(27, 31, 35, 0.3);
658 | cursor: pointer;
659 | font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, monospace;
660 | font-size: 12px;
661 | line-height: 20px;
662 | min-width: 50px;
663 | padding-left: 10px;
664 | padding-right: 10px;
665 | text-align: right;
666 | user-select: none;
667 | vertical-align: top;
668 | white-space: nowrap;
669 | width: 1%;
670 | }
671 |
672 | .markdown-body .blob-num:hover {
673 | color: rgba(27, 31, 35, 0.6);
674 | }
675 |
676 | .markdown-body .blob-num:before {
677 | content: attr(data-line-number);
678 | }
679 |
680 | .markdown-body .blob-code {
681 | line-height: 20px;
682 | padding-left: 10px;
683 | padding-right: 10px;
684 | position: relative;
685 | vertical-align: top;
686 | }
687 |
688 | /* .markdown-body .blob-code-inner {
689 | color: #24292e;
690 | font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
691 | monospace;
692 | font-size: 12px;
693 | overflow: visible;
694 | white-space: pre;
695 | word-wrap: normal;
696 | } */
697 |
698 | .markdown-body .pl-token.active, .markdown-body .pl-token:hover {
699 | background: #ffea7f;
700 | cursor: pointer;
701 | }
702 |
703 | .markdown-body kbd {
704 | background-color: #fafbfc;
705 | border: 1px solid #d1d5da;
706 | border-bottom-color: #c6cbd1;
707 | border-radius: 3px;
708 | box-shadow: inset 0 -1px 0 #c6cbd1;
709 | color: #444d56;
710 | display: inline-block;
711 | font: 11px SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, monospace;
712 | line-height: 10px;
713 | padding: 3px 5px;
714 | vertical-align: middle;
715 | }
716 |
717 | .markdown-body :checked+.radio-label {
718 | border-color: #0366d6;
719 | position: relative;
720 | z-index: 1;
721 | }
722 |
723 | .markdown-body .tab-size[data-tab-size="1"] {
724 | -moz-tab-size: 1;
725 | tab-size: 1;
726 | }
727 |
728 | .markdown-body .tab-size[data-tab-size="2"] {
729 | -moz-tab-size: 2;
730 | tab-size: 2;
731 | }
732 |
733 | .markdown-body .tab-size[data-tab-size="3"] {
734 | -moz-tab-size: 3;
735 | tab-size: 3;
736 | }
737 |
738 | .markdown-body .tab-size[data-tab-size="4"] {
739 | -moz-tab-size: 4;
740 | tab-size: 4;
741 | }
742 |
743 | .markdown-body .tab-size[data-tab-size="5"] {
744 | -moz-tab-size: 5;
745 | tab-size: 5;
746 | }
747 |
748 | .markdown-body .tab-size[data-tab-size="6"] {
749 | -moz-tab-size: 6;
750 | tab-size: 6;
751 | }
752 |
753 | .markdown-body .tab-size[data-tab-size="7"] {
754 | -moz-tab-size: 7;
755 | tab-size: 7;
756 | }
757 |
758 | .markdown-body .tab-size[data-tab-size="8"] {
759 | -moz-tab-size: 8;
760 | tab-size: 8;
761 | }
762 |
763 | .markdown-body .tab-size[data-tab-size="9"] {
764 | -moz-tab-size: 9;
765 | tab-size: 9;
766 | }
767 |
768 | .markdown-body .tab-size[data-tab-size="10"] {
769 | -moz-tab-size: 10;
770 | tab-size: 10;
771 | }
772 |
773 | .markdown-body .tab-size[data-tab-size="11"] {
774 | -moz-tab-size: 11;
775 | tab-size: 11;
776 | }
777 |
778 | .markdown-body .tab-size[data-tab-size="12"] {
779 | -moz-tab-size: 12;
780 | tab-size: 12;
781 | }
782 |
783 | .markdown-body .task-list-item {
784 | list-style-type: none;
785 | }
786 |
787 | .markdown-body .task-list-item+.task-list-item {
788 | margin-top: 3px;
789 | }
790 |
791 | .markdown-body .task-list-item input {
792 | margin: 0 0.2em 0.25em -1.6em;
793 | vertical-align: middle;
794 | }
795 |
796 | .markdown-body hr {
797 | border-bottom-color: #eee;
798 | }
799 |
800 | .markdown-body .pl-0 {
801 | padding-left: 0 !important;
802 | }
803 |
804 | .markdown-body .pl-1 {
805 | padding-left: 4px !important;
806 | }
807 |
808 | .markdown-body .pl-2 {
809 | padding-left: 8px !important;
810 | }
811 |
812 | .markdown-body .pl-3 {
813 | padding-left: 16px !important;
814 | }
815 |
816 | .markdown-body .pl-4 {
817 | padding-left: 24px !important;
818 | }
819 |
820 | .markdown-body .pl-5 {
821 | padding-left: 32px !important;
822 | }
823 |
824 | .markdown-body .pl-6 {
825 | padding-left: 40px !important;
826 | }
827 |
828 | .markdown-body .pl-7 {
829 | padding-left: 48px !important;
830 | }
831 |
832 | .markdown-body .pl-8 {
833 | padding-left: 64px !important;
834 | }
835 |
836 | .markdown-body .pl-9 {
837 | padding-left: 80px !important;
838 | }
839 |
840 | .markdown-body .pl-10 {
841 | padding-left: 96px !important;
842 | }
843 |
844 | .markdown-body .pl-11 {
845 | padding-left: 112px !important;
846 | }
847 |
848 | .markdown-body .pl-12 {
849 | padding-left: 128px !important;
850 | }
851 |
852 | .markdown-body {
853 | line-height: 1.6rem;
854 | }
855 |
856 | /* 代码相关 */
857 |
858 | /* 小代码块 */
859 |
860 | .markdown-body :not(pre)>code {
861 | margin: 0 2px;
862 | padding: 0.2em 0.4em;
863 | border-radius: 4px;
864 | font-family: monospace, Monaco, courier, monospace !important;
865 | font-size: 0.9em;
866 | font-weight: bold;
867 | color: #4c566a;
868 | background-color: #eceff4;
869 | transition: background-color 400ms ease-in-out 0s;
870 | }
871 |
872 | /* 以下是代码块中的内容 */
873 |
874 | .markdown-body pre>code {
875 | background: transparent;
876 | border: 0;
877 | font-size: 100%;
878 | margin: 0;
879 | padding: 0;
880 | white-space: pre;
881 | word-break: normal;
882 | overflow: visible;
883 | max-width: auto;
884 | display: inline;
885 | }
886 |
887 | .markdown-body .hljs {
888 | margin-bottom: 16px;
889 | }
890 |
891 | .markdown-body pre {
892 | /* margin-bottom: 16px !important; */
893 | font-family: "monospace", "consolas", "Monaco";
894 | font-size: 15px;
895 | font-weight: bold;
896 | border-radius: 10px;
897 | line-height: 1.45;
898 | overflow: auto;
899 | padding: 16px;
900 | word-break: normal;
901 | }
--------------------------------------------------------------------------------
/frontend/src/assets/css/nord.css:
--------------------------------------------------------------------------------
1 | /*
2 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 | title Nord highlight.js +
4 | project nord-highlightjs +
5 | version 0.1.0 +
6 | repository https://github.com/arcticicestudio/nord-highlightjs +
7 | author Arctic Ice Studio +
8 | email development@arcticicestudio.com +
9 | copyright Copyright (C) 2017 +
10 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 |
12 | [References]
13 | Nord
14 | https://github.com/arcticicestudio/nord
15 | highlight.js
16 | http://highlightjs.readthedocs.io/en/latest/style-guide.html
17 | http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html
18 | */
19 |
20 | pre {
21 | display: block;
22 | overflow-x: auto;
23 | padding: 0.5em;
24 | background: #2e3440;
25 | }
26 |
27 | pre>code {
28 | color: #D8DFE9;
29 | }
30 |
31 | .hljs, .hljs-subst {
32 | color: #d8dee9;
33 | }
34 |
35 | .hljs-selector-tag {
36 | color: #81a1c1;
37 | }
38 |
39 | .hljs-selector-id {
40 | color: #8fbcbb;
41 | font-weight: bold;
42 | }
43 |
44 | .hljs-selector-class {
45 | color: #8fbcbb;
46 | }
47 |
48 | .hljs-selector-attr {
49 | color: #8fbcbb;
50 | }
51 |
52 | .hljs-selector-pseudo {
53 | color: #88c0d0;
54 | }
55 |
56 | .hljs-addition {
57 | background-color: rgba(163, 190, 140, 0.5);
58 | }
59 |
60 | .hljs-deletion {
61 | background-color: rgba(191, 97, 106, 0.5);
62 | }
63 |
64 | .hljs-built_in, .hljs-type {
65 | color: #8fbcbb;
66 | }
67 |
68 | .hljs-class {
69 | color: #8fbcbb;
70 | }
71 |
72 | .hljs-function {
73 | color: #88c0d0;
74 | }
75 |
76 | .hljs-function>.hljs-title {
77 | color: #88c0d0;
78 | }
79 |
80 | .hljs-keyword, .hljs-literal, .hljs-symbol {
81 | color: #81a1c1;
82 | }
83 |
84 | .hljs-number {
85 | color: #b48ead;
86 | }
87 |
88 | .hljs-regexp {
89 | color: #ebcb8b;
90 | }
91 |
92 | .hljs-string {
93 | color: #a3be8c;
94 | }
95 |
96 | .hljs-title {
97 | color: #8fbcbb;
98 | }
99 |
100 | .hljs-params {
101 | color: #d8dee9;
102 | }
103 |
104 | .hljs-bullet {
105 | color: #81a1c1;
106 | }
107 |
108 | .hljs-code {
109 | color: #8fbcbb;
110 | }
111 |
112 | .hljs-emphasis {
113 | font-style: italic;
114 | }
115 |
116 | .hljs-formula {
117 | color: #8fbcbb;
118 | }
119 |
120 | .hljs-strong {
121 | font-weight: bold;
122 | }
123 |
124 | .hljs-link:hover {
125 | text-decoration: underline;
126 | }
127 |
128 | .hljs-quote {
129 | color: #4c566a;
130 | }
131 |
132 | .hljs-comment {
133 | color: #8896b0;
134 | }
135 |
136 | .hljs-doctag {
137 | color: #8fbcbb;
138 | }
139 |
140 | .hljs-meta, .hljs-meta-keyword {
141 | color: #5e81ac;
142 | }
143 |
144 | .hljs-meta-string {
145 | color: #a3be8c;
146 | }
147 |
148 | .hljs-attr {
149 | color: #8fbcbb;
150 | }
151 |
152 | .hljs-attribute {
153 | color: #d8dee9;
154 | }
155 |
156 | .hljs-builtin-name {
157 | color: #81a1c1;
158 | }
159 |
160 | .hljs-name {
161 | color: #81a1c1;
162 | }
163 |
164 | .hljs-section {
165 | color: #88c0d0;
166 | }
167 |
168 | .hljs-tag {
169 | color: #81a1c1;
170 | }
171 |
172 | .hljs-variable {
173 | color: #d8dee9;
174 | }
175 |
176 | .hljs-template-variable {
177 | color: #d8dee9;
178 | }
179 |
180 | .hljs-template-tag {
181 | color: #5e81ac;
182 | }
183 |
184 | .abnf .hljs-attribute {
185 | color: #88c0d0;
186 | }
187 |
188 | .abnf .hljs-symbol {
189 | color: #ebcb8b;
190 | }
191 |
192 | .apache .hljs-attribute {
193 | color: #88c0d0;
194 | }
195 |
196 | .apache .hljs-section {
197 | color: #81a1c1;
198 | }
199 |
200 | .arduino .hljs-built_in {
201 | color: #88c0d0;
202 | }
203 |
204 | .aspectj .hljs-meta {
205 | color: #d08770;
206 | }
207 |
208 | .aspectj>.hljs-title {
209 | color: #88c0d0;
210 | }
211 |
212 | .bnf .hljs-attribute {
213 | color: #8fbcbb;
214 | }
215 |
216 | .clojure .hljs-name {
217 | color: #88c0d0;
218 | }
219 |
220 | .clojure .hljs-symbol {
221 | color: #ebcb8b;
222 | }
223 |
224 | .coq .hljs-built_in {
225 | color: #88c0d0;
226 | }
227 |
228 | .cpp .hljs-meta-string {
229 | color: #8fbcbb;
230 | }
231 |
232 | .css .hljs-built_in {
233 | color: #88c0d0;
234 | }
235 |
236 | .css .hljs-keyword {
237 | color: #d08770;
238 | }
239 |
240 | .diff .hljs-meta {
241 | color: #8fbcbb;
242 | }
243 |
244 | .ebnf .hljs-attribute {
245 | color: #8fbcbb;
246 | }
247 |
248 | .glsl .hljs-built_in {
249 | color: #88c0d0;
250 | }
251 |
252 | .groovy .hljs-meta:not(:first-child) {
253 | color: #d08770;
254 | }
255 |
256 | .haxe .hljs-meta {
257 | color: #d08770;
258 | }
259 |
260 | .java .hljs-meta {
261 | color: #d08770;
262 | }
263 |
264 | .ldif .hljs-attribute {
265 | color: #8fbcbb;
266 | }
267 |
268 | .lisp .hljs-name {
269 | color: #88c0d0;
270 | }
271 |
272 | .lua .hljs-built_in {
273 | color: #88c0d0;
274 | }
275 |
276 | .moonscript .hljs-built_in {
277 | color: #88c0d0;
278 | }
279 |
280 | .nginx .hljs-attribute {
281 | color: #88c0d0;
282 | }
283 |
284 | .nginx .hljs-section {
285 | color: #5e81ac;
286 | }
287 |
288 | .pf .hljs-built_in {
289 | color: #88c0d0;
290 | }
291 |
292 | .processing .hljs-built_in {
293 | color: #88c0d0;
294 | }
295 |
296 | .scss .hljs-keyword {
297 | color: #81a1c1;
298 | }
299 |
300 | .stylus .hljs-keyword {
301 | color: #81a1c1;
302 | }
303 |
304 | .swift .hljs-meta {
305 | color: #d08770;
306 | }
307 |
308 | .vim .hljs-built_in {
309 | color: #88c0d0;
310 | font-style: italic;
311 | }
312 |
313 | .yaml .hljs-meta {
314 | color: #d08770;
315 | }
--------------------------------------------------------------------------------
/frontend/src/assets/css/prism.css:
--------------------------------------------------------------------------------
1 | /* PrismJS 1.19.0
2 | https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+antlr4+apacheconf+apl+applescript+aql+arduino+arff+asciidoc+asm6502+aspnet+autohotkey+autoit+bash+basic+batch+bbcode+bison+bnf+brainfuck+brightscript+bro+c+concurnas+csharp+cpp+cil+coffeescript+cmake+clojure+crystal+csp+css-extras+d+dart+diff+django+dns-zone-file+docker+ebnf+eiffel+ejs+elixir+elm+etlua+erb+erlang+fsharp+firestore-security-rules+flow+fortran+ftl+gcode+gdscript+gedcom+gherkin+git+glsl+gml+go+graphql+groovy+haml+handlebars+haskell+haxe+hcl+http+hpkp+hsts+ichigojam+icon+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jolie+jq+jsdoc+js-extras+js-templates+json+jsonp+json5+julia+keyman+kotlin+latex+latte+less+lilypond+liquid+lisp+livescript+lolcode+lua+makefile+markdown+markup-templating+matlab+mel+mizar+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+nasm+neon+nginx+nim+nix+nsis+objectivec+ocaml+opencl+oz+parigp+parser+pascal+pascaligo+pcaxis+perl+php+phpdoc+php-extras+plsql+powershell+processing+prolog+properties+protobuf+pug+puppet+pure+python+q+qml+qore+r+jsx+tsx+renpy+reason+regex+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smalltalk+smarty+solidity+soy+sparql+splunk-spl+sqf+sql+stylus+swift+tap+tcl+textile+toml+tt2+turtle+twig+typescript+t4-cs+t4-vb+t4-templating+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+wasm+wiki+xeora+xojo+xquery+yaml+zig&plugins=show-language+toolbar+copy-to-clipboard */
3 | /**
4 | * prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML
5 | * Based on https://github.com/chriskempson/tomorrow-theme
6 | * @author Rose Pritchard
7 | */
8 |
9 | code[class*="language-"],
10 | pre[class*="language-"] {
11 | color: #ccc !important;
12 | background: none !important;
13 | font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace !important;
14 | font-size: 1em !important;
15 | text-align: left !important;
16 | white-space: pre !important;
17 | word-spacing: normal !important;
18 | word-break: normal !important;
19 | word-wrap: normal !important;
20 | line-height: 1.5 !important;
21 |
22 | -moz-tab-size: 4 !important;
23 | -o-tab-size: 4 !important;
24 | tab-size: 4 !important;
25 |
26 | -webkit-hyphens: none !important;
27 | -moz-hyphens: none !important;
28 | -ms-hyphens: none !important;
29 | hyphens: none !important;
30 | }
31 |
32 | /* Code blocks */
33 | pre[class*="language-"] {
34 | padding: 1em !important;
35 | margin: 0.5em 0 !important;
36 | overflow: auto !important;
37 | }
38 |
39 | :not(pre) > code[class*="language-"],
40 | pre[class*="language-"] {
41 | background: #2d2d2d !important;
42 | }
43 |
44 | /* Inline code */
45 | :not(pre) > code[class*="language-"] {
46 | padding: 0.1em;
47 | border-radius: 0.3em;
48 | white-space: normal;
49 | }
50 |
51 | .token.comment,
52 | .token.block-comment,
53 | .token.prolog,
54 | .token.doctype,
55 | .token.cdata {
56 | color: #999;
57 | }
58 |
59 | .token.punctuation {
60 | color: #ccc;
61 | }
62 |
63 | .token.tag,
64 | .token.attr-name,
65 | .token.namespace,
66 | .token.deleted {
67 | color: #e2777a;
68 | }
69 |
70 | .token.function-name {
71 | color: #6196cc;
72 | }
73 |
74 | .token.boolean,
75 | .token.number,
76 | .token.function {
77 | color: #f08d49;
78 | }
79 |
80 | .token.property,
81 | .token.class-name,
82 | .token.constant,
83 | .token.symbol {
84 | color: #f8c555;
85 | }
86 |
87 | .token.selector,
88 | .token.important,
89 | .token.atrule,
90 | .token.keyword,
91 | .token.builtin {
92 | color: #cc99cd;
93 | }
94 |
95 | .token.string,
96 | .token.char,
97 | .token.attr-value,
98 | .token.regex,
99 | .token.variable {
100 | color: #7ec699;
101 | }
102 |
103 | .token.operator,
104 | .token.entity,
105 | .token.url {
106 | color: #67cdcc;
107 | }
108 |
109 | .token.important,
110 | .token.bold {
111 | font-weight: bold;
112 | }
113 | .token.italic {
114 | font-style: italic;
115 | }
116 |
117 | .token.entity {
118 | cursor: help;
119 | }
120 |
121 | .token.inserted {
122 | color: green;
123 | }
124 |
125 | div.code-toolbar {
126 | position: relative;
127 | }
128 |
129 | div.code-toolbar > .toolbar {
130 | position: absolute;
131 | top: 0.3em;
132 | right: 0.2em;
133 | transition: opacity 0.3s ease-in-out;
134 | opacity: 0;
135 | }
136 |
137 | div.code-toolbar:hover > .toolbar {
138 | opacity: 1;
139 | }
140 |
141 | /* Separate line b/c rules are thrown out if selector is invalid.
142 | IE11 and old Edge versions don't support :focus-within. */
143 | div.code-toolbar:focus-within > .toolbar {
144 | opacity: 1;
145 | }
146 |
147 | div.code-toolbar > .toolbar .toolbar-item {
148 | display: inline-block;
149 | }
150 |
151 | div.code-toolbar > .toolbar a {
152 | cursor: pointer;
153 | }
154 |
155 | div.code-toolbar > .toolbar button {
156 | background: none;
157 | border: 0;
158 | color: inherit;
159 | font: inherit;
160 | line-height: normal;
161 | overflow: visible;
162 | padding: 0;
163 | -webkit-user-select: none; /* for button */
164 | -moz-user-select: none;
165 | -ms-user-select: none;
166 | }
167 |
168 | div.code-toolbar > .toolbar a,
169 | div.code-toolbar > .toolbar button,
170 | div.code-toolbar > .toolbar span {
171 | color: #bbb;
172 | font-size: 0.8em;
173 | padding: 0 0.5em;
174 | background: #f5f2f0;
175 | background: rgba(224, 224, 224, 0.2);
176 | box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2);
177 | border-radius: 0.5em;
178 | }
179 |
180 | div.code-toolbar > .toolbar a:hover,
181 | div.code-toolbar > .toolbar a:focus,
182 | div.code-toolbar > .toolbar button:hover,
183 | div.code-toolbar > .toolbar button:focus,
184 | div.code-toolbar > .toolbar span:hover,
185 | div.code-toolbar > .toolbar span:focus {
186 | color: inherit;
187 | text-decoration: none;
188 | }
189 |
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Black.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Black.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Black.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Black.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-BlackItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-BlackItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-BlackItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-BlackItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Bold.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Bold.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-BoldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-BoldItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-BoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-BoldItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraBold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraBold.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraBold.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraBoldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraBoldItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraBoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraBoldItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraLight.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraLight.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraLight.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraLight.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraLightItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraLightItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ExtraLightItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ExtraLightItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Italic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Italic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Light.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Light.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-LightItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-LightItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-LightItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-LightItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Medium.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Medium.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-MediumItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-MediumItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-MediumItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-MediumItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Regular.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Regular.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-SemiBold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-SemiBold.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-SemiBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-SemiBold.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-SemiBoldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-SemiBoldItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-SemiBoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-SemiBoldItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Thin.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Thin.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-Thin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-Thin.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ThinItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ThinItalic.woff
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-ThinItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-ThinItalic.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-italic.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-italic.var.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter-roman.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter-roman.var.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/Inter.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/font/Inter/Inter.var.woff2
--------------------------------------------------------------------------------
/frontend/src/assets/font/Inter/inter.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Inter';
3 | font-style: normal;
4 | font-weight: 100;
5 | font-display: swap;
6 | src: url("Inter-Thin.woff2?v=3.13") format("woff2"),
7 | url("Inter-Thin.woff?v=3.13") format("woff");
8 | }
9 | @font-face {
10 | font-family: 'Inter';
11 | font-style: italic;
12 | font-weight: 100;
13 | font-display: swap;
14 | src: url("Inter-ThinItalic.woff2?v=3.13") format("woff2"),
15 | url("Inter-ThinItalic.woff?v=3.13") format("woff");
16 | }
17 |
18 | @font-face {
19 | font-family: 'Inter';
20 | font-style: normal;
21 | font-weight: 200;
22 | font-display: swap;
23 | src: url("Inter-ExtraLight.woff2?v=3.13") format("woff2"),
24 | url("Inter-ExtraLight.woff?v=3.13") format("woff");
25 | }
26 | @font-face {
27 | font-family: 'Inter';
28 | font-style: italic;
29 | font-weight: 200;
30 | font-display: swap;
31 | src: url("Inter-ExtraLightItalic.woff2?v=3.13") format("woff2"),
32 | url("Inter-ExtraLightItalic.woff?v=3.13") format("woff");
33 | }
34 |
35 | @font-face {
36 | font-family: 'Inter';
37 | font-style: normal;
38 | font-weight: 300;
39 | font-display: swap;
40 | src: url("Inter-Light.woff2?v=3.13") format("woff2"),
41 | url("Inter-Light.woff?v=3.13") format("woff");
42 | }
43 | @font-face {
44 | font-family: 'Inter';
45 | font-style: italic;
46 | font-weight: 300;
47 | font-display: swap;
48 | src: url("Inter-LightItalic.woff2?v=3.13") format("woff2"),
49 | url("Inter-LightItalic.woff?v=3.13") format("woff");
50 | }
51 |
52 | @font-face {
53 | font-family: 'Inter';
54 | font-style: normal;
55 | font-weight: 400;
56 | font-display: swap;
57 | src: url("Inter-Regular.woff2?v=3.13") format("woff2"),
58 | url("Inter-Regular.woff?v=3.13") format("woff");
59 | }
60 | @font-face {
61 | font-family: 'Inter';
62 | font-style: italic;
63 | font-weight: 400;
64 | font-display: swap;
65 | src: url("Inter-Italic.woff2?v=3.13") format("woff2"),
66 | url("Inter-Italic.woff?v=3.13") format("woff");
67 | }
68 |
69 | @font-face {
70 | font-family: 'Inter';
71 | font-style: normal;
72 | font-weight: 500;
73 | font-display: swap;
74 | src: url("Inter-Medium.woff2?v=3.13") format("woff2"),
75 | url("Inter-Medium.woff?v=3.13") format("woff");
76 | }
77 | @font-face {
78 | font-family: 'Inter';
79 | font-style: italic;
80 | font-weight: 500;
81 | font-display: swap;
82 | src: url("Inter-MediumItalic.woff2?v=3.13") format("woff2"),
83 | url("Inter-MediumItalic.woff?v=3.13") format("woff");
84 | }
85 |
86 | @font-face {
87 | font-family: 'Inter';
88 | font-style: normal;
89 | font-weight: 600;
90 | font-display: swap;
91 | src: url("Inter-SemiBold.woff2?v=3.13") format("woff2"),
92 | url("Inter-SemiBold.woff?v=3.13") format("woff");
93 | }
94 | @font-face {
95 | font-family: 'Inter';
96 | font-style: italic;
97 | font-weight: 600;
98 | font-display: swap;
99 | src: url("Inter-SemiBoldItalic.woff2?v=3.13") format("woff2"),
100 | url("Inter-SemiBoldItalic.woff?v=3.13") format("woff");
101 | }
102 |
103 | @font-face {
104 | font-family: 'Inter';
105 | font-style: normal;
106 | font-weight: 700;
107 | font-display: swap;
108 | src: url("Inter-Bold.woff2?v=3.13") format("woff2"),
109 | url("Inter-Bold.woff?v=3.13") format("woff");
110 | }
111 | @font-face {
112 | font-family: 'Inter';
113 | font-style: italic;
114 | font-weight: 700;
115 | font-display: swap;
116 | src: url("Inter-BoldItalic.woff2?v=3.13") format("woff2"),
117 | url("Inter-BoldItalic.woff?v=3.13") format("woff");
118 | }
119 |
120 | @font-face {
121 | font-family: 'Inter';
122 | font-style: normal;
123 | font-weight: 800;
124 | font-display: swap;
125 | src: url("Inter-ExtraBold.woff2?v=3.13") format("woff2"),
126 | url("Inter-ExtraBold.woff?v=3.13") format("woff");
127 | }
128 | @font-face {
129 | font-family: 'Inter';
130 | font-style: italic;
131 | font-weight: 800;
132 | font-display: swap;
133 | src: url("Inter-ExtraBoldItalic.woff2?v=3.13") format("woff2"),
134 | url("Inter-ExtraBoldItalic.woff?v=3.13") format("woff");
135 | }
136 |
137 | @font-face {
138 | font-family: 'Inter';
139 | font-style: normal;
140 | font-weight: 900;
141 | font-display: swap;
142 | src: url("Inter-Black.woff2?v=3.13") format("woff2"),
143 | url("Inter-Black.woff?v=3.13") format("woff");
144 | }
145 | @font-face {
146 | font-family: 'Inter';
147 | font-style: italic;
148 | font-weight: 900;
149 | font-display: swap;
150 | src: url("Inter-BlackItalic.woff2?v=3.13") format("woff2"),
151 | url("Inter-BlackItalic.woff?v=3.13") format("woff");
152 | }
153 |
154 | /* -------------------------------------------------------
155 | Variable font.
156 | Usage:
157 |
158 | html { font-family: 'Inter', sans-serif; }
159 | @supports (font-variation-settings: normal) {
160 | html { font-family: 'Inter var', sans-serif; }
161 | }
162 | */
163 | @font-face {
164 | font-family: 'Inter var';
165 | font-weight: 100 900;
166 | font-display: swap;
167 | font-style: normal;
168 | font-named-instance: 'Regular';
169 | src: url("Inter-roman.var.woff2?v=3.13") format("woff2");
170 | }
171 | @font-face {
172 | font-family: 'Inter var';
173 | font-weight: 100 900;
174 | font-display: swap;
175 | font-style: italic;
176 | font-named-instance: 'Italic';
177 | src: url("Inter-italic.var.woff2?v=3.13") format("woff2");
178 | }
179 |
180 |
181 | /* --------------------------------------------------------------------------
182 | [EXPERIMENTAL] Multi-axis, single variable font.
183 |
184 | Slant axis is not yet widely supported (as of February 2019) and thus this
185 | multi-axis single variable font is opt-in rather than the default.
186 |
187 | When using this, you will probably need to set font-variation-settings
188 | explicitly, e.g.
189 |
190 | * { font-variation-settings: "slnt" 0deg }
191 | .italic { font-variation-settings: "slnt" 10deg }
192 |
193 | */
194 | @font-face {
195 | font-family: 'Inter var experimental';
196 | font-weight: 100 900;
197 | font-display: swap;
198 | font-style: oblique 0deg 10deg;
199 | src: url("Inter.var.woff2?v=3.13") format("woff2");
200 | }
201 |
--------------------------------------------------------------------------------
/frontend/src/assets/img/card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/frontend/src/assets/img/card.png
--------------------------------------------------------------------------------
/frontend/src/components/Article/Highlight.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import hljs from 'highlight.js/lib/core';
4 | import bash from 'highlight.js/lib/languages/bash';
5 | import clike from 'highlight.js/lib/languages/c-like';
6 | import css from 'highlight.js/lib/languages/css';
7 | import dockerfile from 'highlight.js/lib/languages/dockerfile';
8 | import go from 'highlight.js/lib/languages/go';
9 | import ini from 'highlight.js/lib/languages/ini';
10 | import java from 'highlight.js/lib/languages/java';
11 | import javascript from 'highlight.js/lib/languages/javascript';
12 | import json from 'highlight.js/lib/languages/json';
13 | import python from 'highlight.js/lib/languages/python';
14 | import rust from 'highlight.js/lib/languages/rust';
15 | import shell from 'highlight.js/lib/languages/shell';
16 | import sql from 'highlight.js/lib/languages/sql';
17 | import vim from 'highlight.js/lib/languages/vim';
18 | import xml from 'highlight.js/lib/languages/xml';
19 | import yaml from 'highlight.js/lib/languages/yaml';
20 |
21 | hljs.registerLanguage('bash', bash);
22 | hljs.registerLanguage('dockerfile', dockerfile);
23 | hljs.registerLanguage('json', json);
24 | hljs.registerLanguage('javascript', javascript);
25 | hljs.registerLanguage('java', java);
26 | hljs.registerLanguage('ini', ini);
27 | hljs.registerLanguage('go', go);
28 | hljs.registerLanguage('python', python);
29 | hljs.registerLanguage('css', css);
30 | hljs.registerLanguage('yaml', yaml);
31 | hljs.registerLanguage('xml', xml);
32 | hljs.registerLanguage('sql', sql);
33 | hljs.registerLanguage('shell', shell);
34 | hljs.registerLanguage('vim', vim);
35 | hljs.registerLanguage('cpp', clike);
36 | hljs.registerLanguage('c', clike);
37 | hljs.registerLanguage('rust', rust);
38 |
39 | class Highlight extends Component {
40 | constructor(props) {
41 | super(props);
42 | this.nodeRef = React.createRef();
43 | }
44 |
45 | componentDidMount() {
46 | this.highlight();
47 | }
48 |
49 | componentDidUpdate() {
50 | this.highlight();
51 | }
52 |
53 | highlight = () => {
54 | if (this.nodeRef) {
55 | const nodes = this.nodeRef.current.querySelectorAll('pre code');
56 | nodes.forEach((node) => {
57 | hljs.highlightBlock(node);
58 | });
59 | }
60 | }
61 |
62 | render() {
63 | const { content } = this.props;
64 | return (
65 |
66 | );
67 | }
68 | }
69 |
70 |
71 | export default Highlight;
--------------------------------------------------------------------------------
/frontend/src/components/Article/Latex.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class Latex extends React.Component {
4 | constructor(props) {
5 | super(props);
6 | this.node = React.createRef();
7 | }
8 |
9 | componentDidMount() {
10 | this.renderMath();
11 | }
12 |
13 | componentDidUpdate() {
14 | this.renderMath();
15 | }
16 |
17 | renderMath() {
18 | window.MathJax.Hub.Queue([
19 | "Typeset",
20 | window.MathJax.Hub,
21 | this.node.current
22 | ]);
23 | }
24 |
25 | render() {
26 | // const { text } = this.props;
27 | return {this.props.children}
;
28 | }
29 | }
30 |
31 | export default Latex;
--------------------------------------------------------------------------------
/frontend/src/components/Common/NotFound.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | lighten,
5 | makeStyles,
6 | } from '@material-ui/core/styles';
7 | import SentimentVeryDissatisfiedIcon
8 | from '@material-ui/icons/SentimentVeryDissatisfied';
9 |
10 | const useStyles = makeStyles(theme=>({
11 | icon:{
12 | fontSize: "160px",
13 | },
14 | emptyContainer: {
15 | bottom: "0",
16 | height: "500",
17 | margin: "auto",
18 | width: "500",
19 | color: lighten(theme.palette.text.disabled,0.4),
20 | textAlign: "center",
21 | paddingTop: "20px"
22 | },
23 | emptyInfoBig: {
24 | fontSize: "35px",
25 | color: lighten(theme.palette.text.disabled,0.4),
26 | },
27 | }));
28 |
29 | export default function NotFound(props) {
30 | const classes = useStyles();
31 | return (
32 |
33 |
34 |
35 | {props.msg}
36 |
37 |
38 |
39 | )
40 | }
--------------------------------------------------------------------------------
/frontend/src/components/Common/Snackbar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import classNames from 'classnames';
4 | import PropTypes from 'prop-types';
5 | import { connect } from 'react-redux';
6 |
7 | import {
8 | IconButton,
9 | Snackbar,
10 | SnackbarContent,
11 | withStyles,
12 | } from '@material-ui/core';
13 | import CheckCircleIcon from '@material-ui/icons/CheckCircle';
14 | import CloseIcon from '@material-ui/icons/Close';
15 | import ErrorIcon from '@material-ui/icons/Error';
16 | import InfoIcon from '@material-ui/icons/Info';
17 | import WarningIcon from '@material-ui/icons/Warning';
18 |
19 | const mapStateToProps = state => {
20 | return {
21 | snackbar: state.snackbar,
22 | }
23 | }
24 |
25 | const mapDispatchToProps = () => { return {} }
26 | const variantIcon = {
27 | success: CheckCircleIcon,
28 | warning: WarningIcon,
29 | error: ErrorIcon,
30 | info: InfoIcon,
31 | };
32 |
33 | const styles1 = theme => ({
34 | success: {
35 | backgroundColor: "#58BA82",
36 | },
37 | error: {
38 | backgroundColor: "#CC573A",
39 | },
40 | info: {
41 | backgroundColor: theme.palette.primary.dark,
42 | },
43 | warning: {
44 | backgroundColor: "#81A1C1",
45 | },
46 | icon: {
47 | fontSize: 20,
48 | },
49 | iconVariant: {
50 | opacity: 0.9,
51 | marginRight: theme.spacing(1),
52 | },
53 | message: {
54 | display: 'flex',
55 | alignItems: 'center',
56 | fontSize: "1.1em"
57 | },
58 | })
59 |
60 | function MySnackbarContent(props) {
61 | const { classes, className, message, onClose, variant, ...other } = props;
62 | const Icon = variantIcon[variant];
63 |
64 | return (
65 |
70 |
71 | {message}
72 |
73 | }
74 | action={[
75 |
82 |
83 | ,
84 | ]}
85 | {...other}
86 | />
87 | );
88 | }
89 | MySnackbarContent.propTypes = {
90 | classes: PropTypes.object.isRequired,
91 | className: PropTypes.string,
92 | message: PropTypes.node,
93 | onClose: PropTypes.func,
94 | variant: PropTypes.oneOf(['success', 'warning', 'error', 'info']).isRequired,
95 | };
96 |
97 | const MySnackbarContentWrapper = withStyles(styles1)(MySnackbarContent);
98 | const styles = theme => ({
99 | margin: {
100 | margin: theme.spacing(1),
101 | },
102 | })
103 | class SnackbarCompoment extends Component {
104 |
105 | state = {
106 | open: false,
107 | }
108 |
109 | componentWillReceiveProps = (nextProps) => {
110 | if (nextProps.snackbar.toggle !== this.props.snackbar.toggle) {
111 | this.setState({ open: true });
112 | }
113 | }
114 |
115 | handleClose = () => {
116 | this.setState({ open: false });
117 | }
118 |
119 | render() {
120 |
121 | return (
122 |
131 |
136 |
137 | );
138 | }
139 |
140 | }
141 |
142 | const MessageBar = connect(
143 | mapStateToProps,
144 | mapDispatchToProps
145 | )(withStyles(styles)(SnackbarCompoment))
146 |
147 | export default MessageBar
--------------------------------------------------------------------------------
/frontend/src/components/Login/Login.js:
--------------------------------------------------------------------------------
1 | import React, {useCallback, useState,} from 'react';
2 |
3 | import {useDispatch} from 'react-redux';
4 | import {useHistory} from 'react-router-dom';
5 |
6 | import Avatar from '@material-ui/core/Avatar';
7 | import Button from '@material-ui/core/Button';
8 | import Container from '@material-ui/core/Container';
9 | import CssBaseline from '@material-ui/core/CssBaseline';
10 | import Grid from '@material-ui/core/Grid';
11 | import Link from '@material-ui/core/Link';
12 | import {makeStyles, withStyles,} from '@material-ui/core/styles';
13 | import TextField from '@material-ui/core/TextField';
14 | import Typography from '@material-ui/core/Typography';
15 | import LockOutlinedIcon from '@material-ui/icons/LockOutlined';
16 |
17 | import {setLoginStatus, toggleSnackbar,} from '../../actions';
18 | import API from '../../middleware/Api';
19 | import Auth from '../../middleware/Auth';
20 |
21 | const CssTextField = withStyles({
22 | root: {
23 | '& label.Mui-focused': {
24 | color: '#5E81AC',
25 | },
26 | '& .MuiInput-underline:after': {
27 | borderBottomColor: '#5E81AC',
28 | },
29 | '& .MuiOutlinedInput-root': {
30 | '&:hover fieldset': {
31 | borderColor: '#5E81AC',
32 | },
33 | '&.Mui-focused fieldset': {
34 | borderColor: '#5E81AC',
35 | },
36 | },
37 | },
38 | })(TextField);
39 |
40 | const useStyles = makeStyles((theme) => ({
41 | paper: {
42 | marginTop: theme.spacing(22),
43 | display: 'flex',
44 | flexDirection: 'column',
45 | alignItems: 'center',
46 | },
47 | avatar: {
48 | margin: theme.spacing(1),
49 | backgroundColor: "#86C0D2",
50 | },
51 | form: {
52 | width: '100%', // Fix IE 11 issue.
53 | marginTop: theme.spacing(1),
54 | },
55 | submit: {
56 | margin: theme.spacing(3, 0, 2),
57 | backgroundColor: "#86C1D3",
58 | "&:hover": {
59 | backgroundColor: "#5E81AC",
60 | color: "#FFFFFF",
61 | },
62 | },
63 | text: {
64 | color: "#5E81AC",
65 | fontWeight: "bold"
66 | }
67 | }));
68 |
69 | export default function Login() {
70 | const [email, setEmail] = useState("");
71 | const [password, setPassword] = useState("");
72 | const [loading, setLoading] = useState(false);
73 |
74 | const dispatch = useDispatch();
75 | const ToggleSnackbar = useCallback(
76 | (vertical, horizontal, msg, color) =>
77 | dispatch(toggleSnackbar(vertical, horizontal, msg, color)),
78 | [dispatch]
79 | );
80 | const SetLoginStatus = useCallback(
81 | status => dispatch(setLoginStatus(status)),
82 | [dispatch]
83 | );
84 |
85 | let history = useHistory();
86 | const classes = useStyles();
87 |
88 | const login = e => {
89 | e.preventDefault();
90 | API.post("/user/login", {
91 | userName: email,
92 | password: password,
93 | }).then(response => {
94 | setLoading(false);
95 | // 本地保存用户登录状态和数据
96 | Auth.authenticate(response.data);
97 | // 全局状态
98 | SetLoginStatus(true);
99 | history.push("/home")
100 | ToggleSnackbar("top", "center", "登录成功", "success");
101 | window.location.reload();
102 | }).catch(error => {
103 | setLoading(false);
104 | ToggleSnackbar("top", "center", error.message, "warning");
105 | })
106 | }
107 |
108 | return (
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | Login
117 |
118 |
173 |
174 | {/*
175 |
176 | */}
177 |
178 | );
179 | }
--------------------------------------------------------------------------------
/frontend/src/components/Tag/Tag.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 |
3 | import PropTypes from 'prop-types';
4 | import {connect} from 'react-redux';
5 | import {withRouter} from 'react-router-dom';
6 |
7 | import {faMarkdown} from '@fortawesome/free-brands-svg-icons';
8 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
9 | import Box from '@material-ui/core/Box';
10 | import Chip from '@material-ui/core/Chip';
11 | import Divider from '@material-ui/core/Divider';
12 | import List from '@material-ui/core/List';
13 | import ListItem from '@material-ui/core/ListItem';
14 | import ListItemIcon from '@material-ui/core/ListItemIcon';
15 | import ListItemText from '@material-ui/core/ListItemText';
16 | import {withStyles, withTheme,} from '@material-ui/core/styles';
17 | import Switch from '@material-ui/core/Switch';
18 | import Typography from '@material-ui/core/Typography';
19 |
20 | import {
21 | setArticleList,
22 | setCurrentTag,
23 | setCurrentTopTag,
24 | setSecondTags,
25 | setSubTags,
26 | setTopTags,
27 | toggleSnackbar,
28 | } from '../../actions';
29 | import API from '../../middleware/Api';
30 | import {lastOfArray} from '../../utils';
31 |
32 | const mapStateToProps = (state) => {
33 | return {
34 | currentRepo: state.repo.currentRepo,
35 | currentTopTag: state.tag.currentTopTag,
36 | currentTag: state.tag.currentTag,
37 | topTags: state.tag.topTags,
38 | secondTags: state.tag.secondTags,
39 | subTags: state.tag.subTags,
40 | articleList: state.tag.articleList,
41 | };
42 | };
43 |
44 | const mapDispatchToProps = (dispatch) => {
45 | return {
46 | setCurrentTopTag: (currentTopTag) => {
47 | dispatch(setCurrentTopTag(currentTopTag));
48 | },
49 | setCurrentTag: (currentTag) => {
50 | dispatch(setCurrentTag(currentTag));
51 | },
52 | setTopTags: (topTags) => {
53 | dispatch(setTopTags(topTags));
54 | },
55 | setSecondTags: (secondTags) => {
56 | dispatch(setSecondTags(secondTags));
57 | },
58 | setSubTags: (subTags) => {
59 | dispatch(setSubTags(subTags));
60 | },
61 | setArticleList: (articleList) => {
62 | dispatch(setArticleList(articleList));
63 | },
64 | toggleSnackbar: (vertical, horizontal, msg, color) => {
65 | dispatch(toggleSnackbar(vertical, horizontal, msg, color));
66 | }
67 | };
68 | };
69 | const styles = (theme) => ({
70 | mid: {
71 | marginTop: "30px",
72 | flex: "1 0",
73 | },
74 | midWrapper: {
75 | margin: "auto",
76 | maxWidth: "1000px",
77 | minHeight: "70vh",
78 | padding: "20px 60px 20px 60px",
79 | backgroundColor: "#FFFFFF",
80 | color: "#4C566A",
81 | boxShadow:
82 | "0 4px 6px rgba(184,194,215,0.25), 0 5px 7px rgba(184,194,215,0.1)",
83 | borderRadius: "8px",
84 | marginBottom: "50px",
85 | },
86 | title: {
87 | marginTop: "30px",
88 | verticalAlign: "middle",
89 | textIndent: "0.5em",
90 | },
91 | view: {
92 | marginTop: "20px",
93 | marginBottom: "20px",
94 | },
95 | swicher: {
96 | fontSize: "15px",
97 | float: "right",
98 | marginRight: "30px",
99 | },
100 | chips: {
101 | display: "flex",
102 | justifyContent: "left",
103 | flexWrap: "wrap",
104 | padding: theme.spacing(0.5),
105 | },
106 | chip: {
107 | margin: theme.spacing(1),
108 | backgroundColor: "#E5E9F0",
109 | color: "#4C566A",
110 | fontSize: "15px",
111 | "&:hover": {
112 | backgroundColor: "#86C1D3",
113 | color: "#FFFFFF",
114 | },
115 | "&:focus": {
116 | backgroundColor: "#86C1D3",
117 | color: "#FFFFFF",
118 | },
119 | // fontWeight: "bold",
120 | },
121 | chipSelected: {
122 | margin: theme.spacing(1),
123 | backgroundColor: "#86C1D3",
124 | color: "#FFFFFF",
125 | textShadow: "0 0 .9px #FFF, 0 0 .9px #FFF",
126 | fontSize: "15px",
127 | "&:hover": {
128 | // backgroundColor: "#D8DEE9",
129 | },
130 | "&:focus": {
131 | backgroundColor: "#86C1D3",
132 | color: "#FFFFFF",
133 | },
134 | },
135 | topChip: {
136 | margin: theme.spacing(1),
137 | backgroundColor: "#E5E9F0",
138 | color: "#4C566A",
139 | fontSize: "15px",
140 | "&:hover": {
141 | backgroundColor: "#658BB9",
142 | color: "#FFFFFF",
143 | },
144 | "&:focus": {
145 | backgroundColor: "#658BB9",
146 | color: "#FFFFFF",
147 | },
148 | // fontWeight: "bold",
149 | },
150 | topChipSelected: {
151 | margin: theme.spacing(1),
152 | backgroundColor: "#5E81AC",
153 | color: "#FFFFFF",
154 | textShadow: "0 0 .9px #FFF, 0 0 .9px #FFF",
155 | fontSize: "15px",
156 | "&:hover": {
157 | // backgroundColor: "#D8DEE9",
158 | },
159 | "&:focus": {
160 | backgroundColor: "#658BB9",
161 | color: "#FFFFFF",
162 | },
163 | },
164 | });
165 |
166 | const TagUnfoldSwitch = withStyles({
167 | switchBase: {
168 | color: "#FFFFFF",
169 | "&$checked": {
170 | color: "#86C2D4",
171 | },
172 | "&$checked + $track": {
173 | backgroundColor: "#86C2D4",
174 | },
175 | },
176 | checked: {},
177 | thumb: {},
178 | track: {
179 | backgroundColor: "#4E5668",
180 | },
181 | })(Switch);
182 |
183 | class TagsComponent extends Component {
184 | constructor(props) {
185 | super(props);
186 | this.state = {
187 | flat: false,
188 | };
189 | }
190 |
191 | loadTopTags() {
192 | API.get("/tags", { params: { repoName: this.props.currentRepo } }).then((response) => {
193 | if (response.data !== null) {
194 | this.props.setTopTags(response.data);
195 | this.props.setSecondTags([]);
196 | this.props.setSubTags([]);
197 | this.props.setCurrentTag("");
198 | this.props.setCurrentTopTag("");
199 | this.props.setArticleList([]);
200 | }
201 | }).catch(error => {
202 | this.props.toggleSnackbar(
203 | "top",
204 | "center",
205 | error.message,
206 | "error"
207 | );
208 | });
209 | }
210 | componentDidMount() {
211 | document.title = "Vinki"
212 | if (this.props.currentRepo !== "" && this.props.topTags.length === 0) {
213 | this.loadTopTags();
214 | }
215 | }
216 | componentDidUpdate(prevProps, prevState) {
217 | // 1. 当一级标签为空时,刷新内容
218 | if (
219 | this.props.currentRepo !== "" &&
220 | this.props.topTags.length === 0
221 | ) {
222 | this.loadTopTags();
223 | return;
224 | }
225 | // 2. 当仓库变更时,刷新内容
226 | let refreshTopTags = false;
227 | if (this.props.currentRepo !== "") {
228 | if (
229 | prevProps.currentRepo === "" ||
230 | this.props.currentRepo !== prevProps.currentRepo
231 | ) {
232 | refreshTopTags = true;
233 | }
234 | }
235 | if (refreshTopTags) {
236 | this.loadTopTags();
237 | }
238 | }
239 |
240 | handleSwitch = (event) => {
241 | // setState({...state, [event.target.name]: event.target.checked});
242 | this.setState({ flat: event.target.checked });
243 | if (event.target.checked === true) {
244 | API.get("/tag", {
245 | params: {
246 | flat: true,
247 | repoName: this.props.currentRepo,
248 | tagName: this.props.currentTopTag
249 | }
250 | }).then(response => {
251 | this.props.setSecondTags(response.data.SubTags);
252 | this.props.setSubTags([]);
253 | this.props.setArticleList(response.data.ArticleInfos);
254 | }
255 | ).catch(error => {
256 | this.props.toggleSnackbar(
257 | "top",
258 | "center",
259 | error.message,
260 | "error"
261 | );
262 | })
263 | } else {
264 | API.get("/tag", {
265 | params: {
266 | flat: false,
267 | repoName: this.props.currentRepo,
268 | tagName: this.props.currentTopTag
269 | }
270 | }).then(response => {
271 | this.props.setSecondTags(response.data.SubTags);
272 | this.props.setArticleList(response.data.ArticleInfos);
273 | }
274 | ).catch(error => {
275 | this.props.toggleSnackbar(
276 | "top",
277 | "center",
278 | error.message,
279 | "error"
280 | );
281 | })
282 | }
283 | };
284 |
285 | handleTagClick = (event, tag, type) => {
286 | this.setState((state) => ({ flat: false }));
287 | this.props.setCurrentTag(tag);
288 | API.get("/tag", {
289 | params: {
290 | flat: false,
291 | repoName: this.props.currentRepo,
292 | tagName: tag,
293 | }
294 | }).then(response => {
295 | if (response.data == null) {
296 | return;
297 | }
298 | if (type === "top") {
299 | this.props.setCurrentTopTag(tag);
300 | if (response.data.SubTags) {
301 | this.props.setSecondTags(response.data.SubTags);
302 | } else {
303 | this.props.setSecondTags([]);
304 | }
305 | this.props.setSubTags([]);
306 | } else {
307 | if (this.state.flat === false) {
308 | this.props.setSubTags(response.data.SubTags);
309 | }
310 | }
311 | this.props.setArticleList(response.data.ArticleInfos);
312 | }).catch(error => {
313 | this.props.toggleSnackbar(
314 | "top",
315 | "center",
316 | error.message,
317 | "error"
318 | );
319 | });
320 | };
321 |
322 | handleArticleClick = (event, articleName) => {
323 | this.props.history.push(`/article/${this.props.currentRepo}/${this.props.currentTag}/${articleName}`);
324 | };
325 |
326 | render() {
327 | const { classes } = this.props;
328 |
329 | const generateTags = (tagList, type) => {
330 | let l = [];
331 | for (let i = 0; i < tagList.length; i++) {
332 | let className = "";
333 | if (tagList[i] === this.props.currentTopTag) {
334 | className = classes.topChipSelected;
335 | } else if (tagList[i] === this.props.currentTag) {
336 | className = classes.chipSelected;
337 | } else if (type === "top") {
338 | className = classes.topChip;
339 | } else {
340 | className = classes.chip;
341 | }
342 | l.push(
343 | this.handleTagClick(event, tagList[i], type)}
349 | />
350 | );
351 | }
352 | return l;
353 | };
354 |
355 | const generateArticles = (articleList) => {
356 | let l = [];
357 | for (let i = 0; i < articleList.length; i++) {
358 | l.push(
359 | {
363 | this.handleArticleClick(event, articleList[i]);
364 | }}
365 | >
366 |
367 |
371 |
372 |
380 |
381 | );
382 | }
383 | return {l}
;
384 | };
385 | return (
386 |
387 |
388 |
389 | {this.props.topTags.length > 0 && (
390 |
391 |
396 |
397 | 标签
398 |
399 |
400 | {generateTags(this.props.topTags, "top")}
401 |
402 |
403 | )}
404 | {this.props.secondTags.length > 0 && (
405 |
406 |
407 |
408 |
413 | 子标签
414 |
415 | 展开
416 |
421 |
422 |
423 |
424 | {generateTags(this.props.secondTags, "second")}
425 |
426 |
427 |
428 | )}
429 | {this.props.subTags.length > 0 && (
430 |
431 |
432 |
433 |
434 | {generateTags(this.props.subTags, "second")}
435 |
436 |
437 |
438 | )}
439 |
440 | {this.props.articleList.length > 0 && (
441 |
442 |
443 |
444 |
449 | 文档列表
450 |
451 | {generateArticles(this.props.articleList)}
452 |
453 |
454 | )}
455 |
456 |
457 |
458 | );
459 | }
460 | }
461 |
462 | TagsComponent.propTypes = {
463 | classes: PropTypes.object.isRequired,
464 | theme: PropTypes.object.isRequired,
465 | };
466 |
467 | const Tags = connect(
468 | mapStateToProps,
469 | mapDispatchToProps
470 | )(withTheme(withStyles(styles)(withRouter(TagsComponent))));
471 |
472 | export default Tags;
473 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import './assets/css/index.css';
2 |
3 | // import './assets/js/prism';
4 | // import 'nord-highlightjs';
5 | import React from 'react';
6 | import ReactDOM from 'react-dom';
7 |
8 | import { Provider } from 'react-redux';
9 | import {
10 | HashRouter as Router,
11 | Route,
12 | Switch,
13 | } from 'react-router-dom';
14 | import { createStore } from 'redux';
15 |
16 | import App from './App';
17 | import {
18 | InitConfig,
19 | UpdateConfig,
20 | } from './middleware/Init';
21 | import vinkiApp from './reducers';
22 | import * as serviceWorker from './serviceWorker';
23 |
24 | // make app work offline and load faster
25 | serviceWorker.register();
26 |
27 | // 初始化配置
28 | const defaultConfig = InitConfig()
29 |
30 | let store = createStore(
31 | vinkiApp,
32 | defaultConfig,
33 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
34 | );
35 |
36 | // 从服务端更新配置,并保存到全局状态中
37 | UpdateConfig(store)
38 |
39 | ReactDOM.render(
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | ,
49 | document.getElementById("root")
50 | );
51 |
--------------------------------------------------------------------------------
/frontend/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/frontend/src/middleware/Api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | import Auth from './Auth';
4 |
5 | // export let baseURL = "http://localhost:6167/api/v1"
6 | export let baseURL = "/api/v1";
7 |
8 | export const getBaseURL = () => {
9 | return baseURL;
10 | };
11 |
12 | const instance = axios.create({
13 | baseURL: getBaseURL(),
14 | withCredentials: true,
15 | crossDomain: true,
16 | });
17 |
18 | function AppError(message, code, error) {
19 | this.code = code;
20 | this.message = message || '未知错误';
21 | this.message += error ? (" " + error) : "";
22 | this.stack = (new Error()).stack;
23 | }
24 |
25 | AppError.prototype = Object.create(Error.prototype);
26 | AppError.prototype.constructor = AppError;
27 |
28 | instance.interceptors.response.use(
29 | function (response) {
30 | response.rawData = response.data;
31 | response.data = response.data.data;
32 | if (response.rawData.code !== 200) {
33 | // 认证错误:设置认证状态为登出,重定向至登录页面
34 | if (response.rawData.code === 401) {
35 | Auth.Signout()
36 | window.location.href = "/#/login";
37 | }
38 | // 非管理员,重定向至主页
39 | if (response.rawData.code === 2000) {
40 | window.location.href = "/#/home";
41 | }
42 | // 错误都要抛出 AppError
43 | throw new AppError(response.rawData.msg, response.rawData.code, response.rawData.error);
44 | }
45 | return response;
46 | },
47 | function (error) {
48 | return Promise.reject(error);
49 | }
50 | );
51 |
52 | export default instance;
53 |
--------------------------------------------------------------------------------
/frontend/src/middleware/Auth.js:
--------------------------------------------------------------------------------
1 | const Auth = {
2 | isAuthenticated: false,
3 | authenticate(user) {
4 | Auth.SetUser(user);
5 | Auth.isAuthenticated = true;
6 | },
7 | GetUser() {
8 | return JSON.parse(localStorage.getItem("user"));
9 | },
10 | SetUser(user) {
11 | localStorage.setItem("user", JSON.stringify(user))
12 | },
13 | Check() {
14 | if (Auth.isAuthenticated) {
15 | return true;
16 | }
17 | // 缓存中有user信息,暂且视为已登录
18 | if (Auth.GetUser("user") !== null) {
19 | return Auth.GetUser().id !== 0;
20 | }
21 | return false
22 | },
23 | Signout() {
24 | Auth.isAuthenticated = false;
25 | let oldUser = Auth.GetUser();
26 | oldUser.id = 0;
27 | localStorage.setItem("user", JSON.stringify(oldUser));
28 | }
29 | }
30 |
31 | export default Auth;
--------------------------------------------------------------------------------
/frontend/src/middleware/AuthRoute.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | Redirect,
5 | Route,
6 | } from 'react-router-dom';
7 |
8 | import Auth from './Auth';
9 |
10 | function AuthRoute({ children, ...rest }) {
11 | return (
12 |
15 | Auth.Check() ? (
16 | children
17 | ) : (
18 |
24 | )
25 | }
26 | />
27 | );
28 | }
29 |
30 | export default AuthRoute
--------------------------------------------------------------------------------
/frontend/src/middleware/Init.js:
--------------------------------------------------------------------------------
1 | import API from '../middleware/Api';
2 | import Auth from './Auth';
3 |
4 | var config = {
5 | // 0. 站点设置
6 | siteConfig: {
7 | title: "Vinki",
8 | theme: {
9 | common: { black: "#000", white: "#fff" },
10 | background: { paper: "#fff", default: "#fafafa" },
11 | primary: {
12 | light: "#7986cb",
13 | main: "#4E5668",
14 | dark: "#303f9f",
15 | contrastText: "#fff"
16 | },
17 | secondary: {
18 | light: "#ff4081",
19 | main: "#f50057",
20 | dark: "#c51162",
21 | contrastText: "#fff"
22 | },
23 | error: {
24 | light: "#e57373",
25 | main: "#f44336",
26 | dark: "#d32f2f",
27 | contrastText: "#fff"
28 | },
29 | text: {
30 | primary: "rgba(0, 0, 0, 0.87)",
31 | secondary: "rgba(0, 0, 0, 0.54)",
32 | disabled: "rgba(0, 0, 0, 0.38)",
33 | hint: "rgba(0, 0, 0, 0.38)"
34 | },
35 | },
36 | },
37 | // 1. 仓库信息
38 | repo: {
39 | repos: [], // 仓库列表
40 | currentRepo: "" // 当前仓库信息
41 | },
42 | // 2. 标签相关信息
43 | tag: {
44 | currentTag: "", // 当前标签信息
45 | currentTopTag: "", // 当前一级标签
46 | topTags: [], // 一级标签列表
47 | secondTags: [], // 二级标签列表
48 | subTags: [], // 子标签
49 | articleList: [], // 文章信息列表
50 | },
51 | snackbar: {
52 | toggle: false,
53 | vertical: "top",
54 | horizontal: "center",
55 | msg: "",
56 | color: ""
57 | },
58 | isLogin: false,
59 | userInfo: null,
60 | }
61 |
62 | export function InitConfig() {
63 | // 先要去本地缓存中查找相应信息
64 | let user = Auth.GetUser()
65 | if (user !== null && user.id !== 0) {
66 | Auth.authenticate(user)
67 | }
68 | // 初始化全局登录状态(与Auth一致)
69 | config.isLogin = Auth.Check()
70 | return config
71 | }
72 |
73 | export function UpdateConfig(store) {
74 | API.get("/site/config").then(response => {
75 | if (response.data !== undefined) {
76 | // 更新登录状态
77 | Auth.authenticate(response.data)
78 | }
79 | }).catch(error => { })
80 | }
--------------------------------------------------------------------------------
/frontend/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | const vinkiApp = (state = [], action) => {
2 | switch (action.type) {
3 | case 'TOGGLE_SNACKBAR':
4 | return Object.assign({}, state, {
5 | snackbar: Object.assign({}, state.snackbar, {
6 | toggle: !state.snackbar.toggle,
7 | vertical: action.vertical,
8 | horizontal: action.horizontal,
9 | msg: action.msg,
10 | color: action.color,
11 | }),
12 | });
13 | case 'SET_LOGIN_STATUS':
14 | return {
15 | ...state,
16 | isLogin: action.status,
17 | }
18 | case 'SET_REPOS':
19 | return Object.assign({}, state, {
20 | repo: Object.assign({}, state.repo, {
21 | repos: action.repos,
22 | })
23 | })
24 | case 'SET_CURRENT_REPO':
25 | return Object.assign({}, state, {
26 | repo: Object.assign("", state.repo, {
27 | currentRepo: action.currentRepo,
28 | })
29 | })
30 | case 'SET_TOP_TAGS':
31 | return Object.assign({}, state, {
32 | tag: Object.assign({}, state.tag, {
33 | topTags: action.topTags,
34 | })
35 | })
36 | case 'SET_SECOND_TAGS':
37 | return Object.assign({}, state, {
38 | tag: Object.assign({}, state.tag, {
39 | secondTags: action.secondTags,
40 | })
41 | })
42 | case 'SET_SUB_TAGS':
43 | return Object.assign({}, state, {
44 | tag: Object.assign({}, state.tag, {
45 | subTags: action.subTags,
46 | })
47 | })
48 | case 'SET_CURRENT_TOP_TAG':
49 | return Object.assign({}, state, {
50 | tag: Object.assign({}, state.tag, {
51 | currentTopTag: action.currentTopTag,
52 | })
53 | })
54 | case 'SET_CURRENT_TAG':
55 | return Object.assign({}, state, {
56 | tag: Object.assign({}, state.tag, {
57 | currentTag: action.currentTag,
58 | })
59 | })
60 | case 'SET_ARTICLE_LIST':
61 | return Object.assign({}, state, {
62 | tag: Object.assign({}, state.tag, {
63 | articleList: action.articleList,
64 | })
65 | })
66 | default:
67 | return state
68 | }
69 | }
70 |
71 | export default vinkiApp
--------------------------------------------------------------------------------
/frontend/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/frontend/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/frontend/src/utils/index.js:
--------------------------------------------------------------------------------
1 | export const isEmptyObject = obj => {
2 | for (var n in obj) {
3 | return false
4 | }
5 | return true;
6 | }
7 |
8 | export const lastOfArray = arr => {
9 | if (arr.length > 0) {
10 | return arr[arr.length - 1]
11 | } else {
12 | return ""
13 | }
14 | }
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/louisun/vinki
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/fatih/color v1.9.0
7 | github.com/gin-contrib/cors v1.3.1
8 | github.com/gin-contrib/sessions v0.0.3
9 | github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
10 | github.com/gin-gonic/gin v1.5.0
11 | github.com/jinzhu/configor v1.1.1
12 | github.com/jinzhu/gorm v1.9.12
13 | github.com/louisun/heyspace v0.0.0-20210605090938-e43b1432ab01
14 | github.com/louisun/markdown v0.0.0-20200704095430-42a367159036
15 | github.com/mattn/go-isatty v0.0.12 // indirect
16 | github.com/panjf2000/ants/v2 v2.4.1
17 | github.com/rakyll/statik v0.1.7
18 | github.com/sirupsen/logrus v1.4.2
19 | github.com/speps/go-hashids v2.0.0+incompatible
20 | github.com/stretchr/testify v1.5.1 // indirect
21 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 // indirect
22 | gopkg.in/go-playground/validator.v9 v9.29.1
23 | )
24 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
3 | github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
4 | github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
5 | github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
6 | github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
7 | github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
8 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
9 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
10 | github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
11 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
15 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
16 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
17 | github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
18 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
19 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
20 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
21 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
22 | github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA=
23 | github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk=
24 | github.com/gin-contrib/sessions v0.0.3 h1:PoBXki+44XdJdlgDqDrY5nDVe3Wk7wDV/UCOuLP6fBI=
25 | github.com/gin-contrib/sessions v0.0.3/go.mod h1:8C/J6cad3Il1mWYYgtw0w+hqasmpvy25mPkXdOgeB9I=
26 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
27 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
28 | github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2 h1:xLG16iua01X7Gzms9045s2Y2niNpvSY/Zb1oBwgNYZY=
29 | github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2/go.mod h1:VhW/Ch/3FhimwZb8Oj+qJmdMmoB8r7lmJ5auRjm50oQ=
30 | github.com/gin-gonic/gin v1.5.0 h1:fi+bqFAx/oLK54somfCtEZs9HeH1LHVoEPUgARpTqyc=
31 | github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
32 | github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
33 | github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
34 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
35 | github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
36 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
37 | github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
38 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
39 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
40 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
41 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
42 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
43 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
44 | github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
45 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
46 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
47 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
48 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
49 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
50 | github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
51 | github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
52 | github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
53 | github.com/jinzhu/configor v1.1.1 h1:gntDP+ffGhs7aJ0u8JvjCDts2OsxsI7bnz3q+jC+hSY=
54 | github.com/jinzhu/configor v1.1.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
55 | github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q=
56 | github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs=
57 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
58 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
59 | github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
60 | github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
61 | github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo=
62 | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
63 | github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
64 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
65 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
66 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
67 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
68 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
69 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
70 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
71 | github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
72 | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
73 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
74 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
75 | github.com/louisun/heyspace v0.0.0-20191225110356-251aac088b89 h1:3MKkMxRQAJBDyPsnG43X6hLOLXe2T0iE24QlD89tb1M=
76 | github.com/louisun/heyspace v0.0.0-20191225110356-251aac088b89/go.mod h1:hY7V/iVB/qgtU7lp7MAG4s25v/IcjycDMG/plYUSKjY=
77 | github.com/louisun/heyspace v0.0.0-20201117145228-56b4a8ec3535 h1:6me1LxIxl4lKO51Sx740NP98ihxn21WjH86V0fuhkGo=
78 | github.com/louisun/heyspace v0.0.0-20201117145228-56b4a8ec3535/go.mod h1:hY7V/iVB/qgtU7lp7MAG4s25v/IcjycDMG/plYUSKjY=
79 | github.com/louisun/heyspace v0.0.0-20210605090938-e43b1432ab01 h1:Vu5hSz3Z3UUF3nJ1c6p+y/BZO5ZV+qXS6GILYaOM39U=
80 | github.com/louisun/heyspace v0.0.0-20210605090938-e43b1432ab01/go.mod h1:F6Iu1lBL4GxBGrdYBrJgz0imrUUQ2Grgg4TwX9XHvgI=
81 | github.com/louisun/markdown v0.0.0-20200704095430-42a367159036 h1:noScfekksMrvTaZgLTwLFEZkIEdzTZUszUVl7enROps=
82 | github.com/louisun/markdown v0.0.0-20200704095430-42a367159036/go.mod h1:mTCK+CRuQfBWDeOK3wJxI04wzLFrDwpsUAgX60bFry8=
83 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
84 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
85 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
86 | github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg=
87 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
88 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
89 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
90 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
91 | github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw=
92 | github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
93 | github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
94 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
95 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
96 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
97 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
98 | github.com/otiai10/copy v1.0.2 h1:DDNipYy6RkIkjMwy+AWzgKiNTyj2RUI9yEMeETEpVyc=
99 | github.com/otiai10/copy v1.0.2/go.mod h1:c7RpqBkwMom4bYTSkLSym4VSJz/XtncWRAj/J4PEIMY=
100 | github.com/otiai10/copy v1.2.0 h1:HvG945u96iNadPoG2/Ja2+AUJeW5YuFQMixq9yirC+k=
101 | github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
102 | github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
103 | github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
104 | github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
105 | github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
106 | github.com/panjf2000/ants v1.3.0 h1:8pQ+8leaLc9lys2viEEr8md0U4RN6uOSUCE9bOYjQ9M=
107 | github.com/panjf2000/ants/v2 v2.4.1 h1:7RtUqj5lGOw0WnZhSKDZ2zzJhaX5490ZW1sUolRXCxY=
108 | github.com/panjf2000/ants/v2 v2.4.1/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
109 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
110 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
111 | github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438 h1:jnz/4VenymvySjE+Ez511s0pqVzkUOmr1fwCVytNNWk=
112 | github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
113 | github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ=
114 | github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc=
115 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
116 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
117 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
118 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
119 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
120 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
121 | github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
122 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
123 | github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw=
124 | github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
125 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
126 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
127 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
128 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
129 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
130 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
131 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
132 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
133 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
134 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
135 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
136 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
137 | github.com/urfave/cli/v2 v2.0.0 h1:+HU9SCbu8GnEUFtIBfuUNXN39ofWViIEJIp6SURMpCg=
138 | github.com/urfave/cli/v2 v2.0.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
139 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
140 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
141 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
142 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
143 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM=
144 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
145 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
146 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
147 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
148 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
149 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
150 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
151 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
152 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ=
153 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
154 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
155 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
156 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY=
157 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
158 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
159 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
160 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
161 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
162 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
163 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
164 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
165 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
166 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
167 | gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
168 | gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
169 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
170 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
171 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
172 | gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
173 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
174 |
--------------------------------------------------------------------------------
/images/article.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/images/article.jpg
--------------------------------------------------------------------------------
/images/home.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/images/home.jpg
--------------------------------------------------------------------------------
/images/login.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/images/login.jpg
--------------------------------------------------------------------------------
/images/repo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/images/repo.jpg
--------------------------------------------------------------------------------
/images/search.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/louisun/Vinki/c285f8cb72eea0715b156d1e5f3a6c91fba735aa/images/search.jpg
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 |
7 | "github.com/louisun/vinki/service"
8 |
9 | "github.com/louisun/vinki/bootstrap"
10 | "github.com/louisun/vinki/pkg/conf"
11 | "github.com/louisun/vinki/pkg/utils"
12 | "github.com/louisun/vinki/routers"
13 | )
14 |
15 | func initConfig() {
16 | var confPath string
17 |
18 | flag.StringVar(&confPath, "c", "./conf/config.yml", "configuration file")
19 | flag.Parse()
20 | bootstrap.Init(confPath)
21 | }
22 |
23 | func initRepository() {
24 | err := service.RefreshDatabase()
25 | if err != nil {
26 | utils.Log().Fatalf("initRepository failed: %v", err)
27 | }
28 | }
29 |
30 | func main() {
31 | // 初始化配置
32 | initConfig()
33 | // 初始化文档库
34 | initRepository()
35 | // 初始化路由
36 | engine := routers.InitRouter()
37 |
38 | utils.Log().Infof("Listening: %d", conf.GlobalConfig.System.Port)
39 |
40 | if err := engine.Run(fmt.Sprintf(":%d", conf.GlobalConfig.System.Port)); err != nil {
41 | utils.Log().Errorf("无法启动服务端口 [%d]: %s", conf.GlobalConfig.System.Port, err)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/middleware/auth.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-contrib/sessions"
5 | "github.com/gin-gonic/gin"
6 | "github.com/louisun/vinki/model"
7 | "github.com/louisun/vinki/pkg/serializer"
8 | "github.com/louisun/vinki/pkg/utils"
9 | )
10 |
11 | // RequireAuth 需要登录
12 | func RequireAuth() gin.HandlerFunc {
13 | return func(c *gin.Context) {
14 | if userCtx, _ := c.Get("user"); userCtx != nil {
15 | if user, ok := userCtx.(*model.User); ok {
16 | if user.Status == model.STATUS_BANNED {
17 | c.JSON(200, serializer.CreateErrorResponse(serializer.CodeForbidden, "该用户已被禁用", nil))
18 | return
19 | }
20 |
21 | c.Next()
22 |
23 | return
24 | }
25 | }
26 |
27 | c.JSON(200, serializer.GetUnauthorizedResponse())
28 |
29 | c.Abort()
30 | }
31 | }
32 |
33 | // InitCurrentUserIfExists 设置登录用户
34 | func InitCurrentUserIfExists() gin.HandlerFunc {
35 | return func(c *gin.Context) {
36 | session := sessions.Default(c)
37 | // Login 会在 session 中 Set user_id
38 | uid := session.Get("user_id")
39 | if uid != nil {
40 | user, err := model.GetAvailableUserByID(uid)
41 | if err == nil {
42 | c.Set("user", &user)
43 | }
44 | }
45 |
46 | c.Next()
47 | }
48 | }
49 |
50 | // RequireAdmin 判断用户是否是管理员
51 | func RequireAdmin() gin.HandlerFunc {
52 | return func(c *gin.Context) {
53 | user, _ := c.Get("user")
54 | if !user.(*model.User).IsAdmin {
55 | c.JSON(200, serializer.CreateErrorResponse(serializer.CodeAdminRequired, "非管理员无法操作", nil))
56 | c.Abort()
57 | }
58 |
59 | c.Next()
60 | }
61 | }
62 |
63 | // CheckPermission 判断用户是否已激活,确认对应访问权限
64 | func CheckPermission() gin.HandlerFunc {
65 | return func(c *gin.Context) {
66 | userCtx, _ := c.Get("user")
67 | user := userCtx.(*model.User)
68 | // 管理员直接允许
69 | if user.IsAdmin {
70 | c.Next()
71 | return
72 | }
73 |
74 | if user.Status == model.STATUS_NOT_ACTIVE {
75 | c.JSON(200, serializer.CreateErrorResponse(serializer.CodeActiveRequired, "账号需要激活,请向管理员申请", nil))
76 | c.Abort()
77 |
78 | return
79 | }
80 |
81 | if user.Status == model.STATUS_APPLYING {
82 | c.JSON(200, serializer.CreateErrorResponse(serializer.CodeActiveRequired, "已申请访问,请耐心等待", nil))
83 | c.Abort()
84 |
85 | return
86 | }
87 | // 判断是否在访问操作权限之外的 Repo
88 | repoName := c.Query("repoName")
89 | if repoName != "" {
90 | if !utils.IsInList(user.RepoNames, repoName) {
91 | c.JSON(200, serializer.CreateErrorResponse(serializer.CodeForbidden, "无权限访问", nil))
92 | c.Abort()
93 |
94 | return
95 | }
96 | }
97 |
98 | c.Next()
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/middleware/option.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | func IsFunctionEnabled(functionKey string) gin.HandlerFunc {
6 | return func(c *gin.Context) {
7 | // TODO 站点配置相关的功能启用状态
8 | c.Next()
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/middleware/session.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-contrib/sessions"
5 | "github.com/gin-contrib/sessions/memstore"
6 | "github.com/gin-gonic/gin"
7 | )
8 |
9 | // Store session存储
10 | var Store memstore.Store
11 |
12 | const WeekSeconds = 7 * 86400
13 |
14 | func Session(secret string) gin.HandlerFunc {
15 | Store = memstore.NewStore([]byte(secret))
16 | Store.Options(sessions.Options{HttpOnly: true, MaxAge: WeekSeconds, Path: "/"})
17 |
18 | return sessions.Sessions("vinki-session", Store)
19 | }
20 |
--------------------------------------------------------------------------------
/model/article.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "regexp"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/jinzhu/gorm"
9 | )
10 |
11 | var numberRegex = regexp.MustCompile(`^(\d+)\..*`)
12 |
13 | // Article 文章
14 | type Article struct {
15 | ID uint64 `gorm:"primary_key"`
16 | Title string `gorm:"type:varchar(100);index:title;not null"` // 文章标题
17 | Path string `gorm:"type:varchar(200);not null"` // 文件路径
18 | RawPath string `gorm:"type:varchar(200);not null"` // 原始文件路径
19 | HTML string `gorm:"type:text"` // Markdown 渲染后的 HTML
20 | TagName string
21 | RepoName string
22 | Tag Tag `gorm:"foreignkey:TagName;association_foreignkey:Name;PRELOAD:false;save_associations:false"` // 标签
23 | Repo Repo `gorm:"foreignkey:RepoName;association_foreignkey:Name;PRELOAD:false;save_associations:false"` // 仓库
24 | }
25 |
26 | // ArticleTagInfo 标签-文章
27 | type ArticleTagInfo struct {
28 | TagName string `gorm:"column:tag_name" json:"tag"`
29 | ArticleName string `gorm:"column:title" json:"article"`
30 | }
31 |
32 | type Articles []string
33 |
34 | func (a Articles) Len() int {
35 | return len(a)
36 | }
37 |
38 | func (a Articles) Swap(i, j int) {
39 | a[i], a[j] = a[j], a[i]
40 | }
41 |
42 | func (a Articles) Less(i, j int) bool {
43 | matchA := numberRegex.FindStringSubmatch(a[i])
44 | matchB := numberRegex.FindStringSubmatch(a[j])
45 | var nA = -1
46 | var nB = -1
47 | if matchA != nil {
48 | nA, _ = strconv.Atoi(matchA[1])
49 | }
50 | if matchB != nil {
51 | nB, _ = strconv.Atoi(matchB[1])
52 | }
53 | if nA != -1 && nB != -1 {
54 | return nA < nB
55 | } else if nA != -1 && nB == -1 {
56 | return true
57 | } else if nA == -1 && nB != -1 {
58 | return false
59 | } else {
60 | return a[i] < a[j]
61 | }
62 | }
63 |
64 | func (Article) TableName() string {
65 | return "article"
66 | }
67 |
68 | // GetArticle 通过仓库名、标签名、文章名获取 Article
69 | func GetArticle(repoName string, tagName string, articleName string) (Article, error) {
70 | var article Article
71 | result := DB.Where("repo_name = ? AND tag_name = ? AND title = ?", repoName, tagName, articleName).First(&article)
72 | return article, result.Error
73 | }
74 |
75 | // GetArticlesBySearchParam 通过仓库名、文章名搜索 Articles
76 | func GetArticlesBySearchParam(repoName string, articleName string) ([]ArticleTagInfo, error) {
77 | var articles []ArticleTagInfo
78 | pattern := "%" + articleName + "%"
79 | result := DB.Model(&Article{}).Where("repo_name = ? AND title LIKE ?", repoName, pattern).
80 | Select("title, tag_name").Order("`title`, length(`title`)").Scan(&articles)
81 | return articles, result.Error
82 | }
83 |
84 | // GetArticleList 根据仓库名和标签名获取 Article 列表信息
85 | func GetArticleList(repoName string, tagName string) ([]string, error) {
86 | articles := make([]string, 0)
87 | result := DB.Model(&Article{}).Where("repo_name = ? AND tag_name = ?", repoName, tagName).
88 | Pluck("title", &articles)
89 | if result.Error != nil && result.Error != gorm.ErrRecordNotFound {
90 | return articles, result.Error
91 | }
92 | return articles, nil
93 | }
94 |
95 | // TruncateArticles 清空 Article 表
96 | func TruncateArticles() error {
97 | err := DB.Model(&Article{}).Delete(&Article{}).Error
98 | if err != nil {
99 | return err
100 | }
101 | return nil
102 | }
103 |
104 | // DeleteArticlesByRepo 删除该 Repo 下的 Article
105 | func DeleteArticlesByRepo(repoName string) error {
106 | result := DB.Where("repo_name = ?", repoName).Delete(&Article{})
107 | return result.Error
108 | }
109 |
110 | // DeleteArticlesByTagID 删除该 Tag 下的 Article
111 | func DeleteArticlesByTag(repoName string, tagName string) error {
112 | result := DB.Where("repo_name = ? AND tag_name = ?", repoName, tagName).Delete(&Article{})
113 | return result.Error
114 | }
115 |
116 | // AddArticle 添加 Article
117 | func AddArticle(article *Article) error {
118 | err := DB.Create(article).Error
119 | return err
120 | }
121 |
122 | // AddArticles 批量添加 Articles
123 | func AddArticles(articles []*Article) error {
124 | DB.LogMode(false)
125 | defer func() {
126 | if gin.Mode() == gin.TestMode {
127 | DB.LogMode(true)
128 | }
129 | }()
130 | tx := DB.Begin()
131 | defer func() {
132 | if r := recover(); r != nil {
133 | tx.Rollback()
134 | }
135 | }()
136 | if err := tx.Error; err != nil {
137 | return err
138 | }
139 | for _, article := range articles {
140 | if err := tx.Create(article).Error; err != nil {
141 | tx.Rollback()
142 | return err
143 | }
144 | }
145 | return tx.Commit().Error
146 | }
147 |
148 | // UpdateArticle 更新文章
149 | func UpdateArticle(id uint64, title string, html string) error {
150 | return DB.Model(&Article{}).Where("id = ?", id).Updates(map[string]interface{}{
151 | "title": title,
152 | "html": html,
153 | }).Error
154 | }
155 |
--------------------------------------------------------------------------------
/model/init.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/louisun/vinki/pkg/conf"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/jinzhu/gorm"
11 | _ "github.com/jinzhu/gorm/dialects/sqlite"
12 | "github.com/louisun/vinki/pkg/utils"
13 | )
14 |
15 | var DB *gorm.DB
16 |
17 | func Init() {
18 | utils.Log().Info("初始化数据库连接")
19 |
20 | var (
21 | db *gorm.DB
22 | err error
23 | )
24 |
25 | if gin.Mode() == gin.TestMode {
26 | // 测试环境使用内存数据库
27 | utils.Log().Info(utils.RelativePath(conf.GlobalConfig.Database.DBFile))
28 | db, err = gorm.Open("sqlite3", utils.RelativePath(conf.GlobalConfig.Database.DBFile))
29 | //db, err = gorm.Open("sqlite3", ":memory:")
30 | } else {
31 | if conf.GlobalConfig.Database.Type == "UNSET" {
32 | // 未指定数据库时,使用 SQLite
33 | db, err = gorm.Open("sqlite3", utils.RelativePath(conf.GlobalConfig.Database.DBFile))
34 | } else {
35 | // 指定数据库
36 | db, err = gorm.Open(conf.GlobalConfig.Database.Type, fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
37 | conf.GlobalConfig.Database.Host,
38 | conf.GlobalConfig.Database.Port,
39 | conf.GlobalConfig.Database.User,
40 | conf.GlobalConfig.Database.Password,
41 | conf.GlobalConfig.Database.DBName),
42 | )
43 | }
44 | }
45 |
46 | if err != nil {
47 | utils.Log().Panicf("连接数据库失败,%s", err)
48 | }
49 |
50 | if conf.GlobalConfig.System.Debug {
51 | db.LogMode(true)
52 | } else {
53 | db.LogMode(false)
54 | }
55 | db.DB().SetMaxIdleConns(50)
56 | db.DB().SetMaxOpenConns(100)
57 | db.DB().SetConnMaxLifetime(time.Second * 30)
58 | DB = db
59 |
60 | // 数据表迁移
61 | migration()
62 | }
63 |
--------------------------------------------------------------------------------
/model/migration.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "github.com/fatih/color"
5 | "github.com/jinzhu/gorm"
6 | "github.com/louisun/vinki/pkg/conf"
7 | "github.com/louisun/vinki/pkg/utils"
8 | )
9 |
10 | // migration 初始化数据表
11 | func migration() {
12 | utils.Log().Info("正在初始化数据表...")
13 | if conf.GlobalConfig.Database.Type == "mysql" {
14 | DB = DB.Set("gorm:table_options", "ENGINE=InnoDB")
15 | }
16 | // 自动迁移模式
17 | DB.AutoMigrate(&Repo{}, &Tag{}, &Article{}, &User{})
18 |
19 | // 创建管理员
20 | addAdmin()
21 | utils.Log().Info("数据表初始化完成")
22 |
23 | }
24 |
25 | func addAdmin() {
26 | _, err := GetUserByID(1)
27 |
28 | if gorm.IsRecordNotFoundError(err) {
29 | password := "vinkipass"
30 | adminUser := User{
31 | Email: "admin@vinki.org",
32 | NickName: "Renzo",
33 | IsAdmin: true,
34 | Status: STATUS_ACTIVE,
35 | }
36 | if err = adminUser.SetPassword("vinkipass"); err != nil {
37 | utils.Log().Panicf("无法设置管理员密码, %s", err)
38 | }
39 | if err = DB.Create(&adminUser).Error; err != nil {
40 | utils.Log().Panicf("无法创建管理员用户, %s", err)
41 | }
42 |
43 | c := color.New(color.FgWhite).Add(color.BgBlack).Add(color.Bold)
44 | utils.Log().Info("初始管理员账号:" + c.Sprint("admin@vinki.org"))
45 | utils.Log().Info("初始管理员密码:" + c.Sprint(password))
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/model/repo.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Repo struct {
4 | ID uint64 `gorm:"primary_key"`
5 | Name string `gorm:"type:varchar(50);unique_idx"`
6 | Path string `gorm:"type:varchar(200);not null"` // 根目录路径
7 | }
8 |
9 | func (Repo) TableName() string {
10 | return "repo"
11 | }
12 |
13 | func AddRepo(repo *Repo) error {
14 | result := DB.Create(repo)
15 | return result.Error
16 | }
17 |
18 | func GetAllRepoNames() ([]string, error) {
19 | l := make([]string, 0, 10)
20 | result := DB.Model(&Repo{}).Pluck("name", &l)
21 | return l, result.Error
22 | }
23 |
24 | func DeleteRepo(repoName string) error {
25 | result := DB.Where("name = ?", repoName).Delete(&Repo{})
26 | return result.Error
27 | }
28 |
29 | func TruncateRepo() (err error) {
30 | result := DB.Delete(&Repo{})
31 | return result.Error
32 | }
33 |
--------------------------------------------------------------------------------
/model/tag.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "database/sql"
5 |
6 | "github.com/jinzhu/gorm"
7 | )
8 |
9 | // Tag 标签
10 | type Tag struct {
11 | ID uint64 `gorm:"primary_key"`
12 | Path string `gorm:"type:varchar(200);index:path;not null"` // Tag 目录路径
13 | Name string `gorm:"type:varchar(200);index:name;not null"` // 标签名称(多级拼合)
14 | ParentPath sql.NullString `gorm:"type:varchar(200);index:parent_path;default: null"` // 父标签路径
15 | RepoName string
16 | Repo Repo `gorm:"foreignkey:RepoName;association_foreignkey:Name;PRELOAD:false;save_associations:false"`
17 | }
18 |
19 | func (Tag) TableName() string {
20 | return "tag"
21 | }
22 |
23 | // TagView 标签视图
24 | type TagView struct {
25 | Name string
26 | SubTags []string // 子标签列表
27 | }
28 |
29 | // GetTagsByRepo 根据 repo 名获取所有 Tag 信息
30 | func GetTagsByRepoName(repoName string) ([]Tag, error) {
31 | var tags []Tag
32 | result := DB.Where("repo_name = ?", repoName).Order("repoName").Find(&tags)
33 | return tags, result.Error
34 | }
35 |
36 | func GetTag(repoName, tagName string) (Tag, error) {
37 | var tag Tag
38 | result := DB.Where("repo_name = ? and name = ?", repoName, tagName).First(&tag)
39 | return tag, result.Error
40 | }
41 |
42 | // GetTagsBySearchName 根据 repo 名和 tagName 搜索 Tags
43 | func GetTagsBySearchName(repoName, tagName string) ([]string, error) {
44 | var tags []string
45 | pattern := "%" + tagName + "%"
46 | result := DB.Model(&Tag{}).Where("repo_name = ? AND name LIKE ?", repoName, pattern).Order("length(`name`)").Pluck("name", &tags)
47 | return tags, result.Error
48 | }
49 |
50 | // GetRootTagsByRepo 根据 repo 名获取所有一级标签的 Tag 信息
51 | func GetRootTagsByRepo(repoName string) ([]Tag, error) {
52 | var tags []Tag
53 | result := DB.Where("repo_name = ? AND parent_path IS NULL", repoName).Order("name").Find(&tags)
54 | return tags, result.Error
55 | }
56 |
57 | // GetTopTagInfosByRepo 根据 repo 名获取所有一级标签的名称
58 | func GetTopTagInfosByRepo(repoName string) ([]string, error) {
59 | tags := make([]string, 0)
60 | result := DB.Model(&Tag{}).Where("repo_name = ? AND parent_path IS NULL", repoName).
61 | Order("name").Pluck("name", &tags)
62 | return tags, result.Error
63 | }
64 |
65 | // GetTagView 通过 repoName 和 tagName 获取 TagView
66 | func GetTagView(repoName string, tagName string) (TagView, error) {
67 | var tagView TagView
68 | var tag Tag
69 | // 本标签信息
70 | result := DB.Where("repo_name = ? AND name = ?", repoName, tagName).Select("name, path").Find(&tag)
71 | if result.Error != nil {
72 | return tagView, result.Error
73 | }
74 | // 一级子标签列表
75 | subTags := make([]string, 0)
76 | result = DB.Model(&Tag{}).Where("parent_path = ?", tag.Path).Pluck("name", &subTags)
77 | if result.Error != nil && result.Error != gorm.ErrRecordNotFound {
78 | return tagView, result.Error
79 | }
80 | tagView.Name = tag.Name
81 | tagView.SubTags = subTags
82 | return tagView, nil
83 | }
84 |
85 | // GetFlatTagView 通过 TagID 获取平铺的 TagView
86 | func GetFlatTagView(repoName string, tagName string) (TagView, error) {
87 | var tag Tag
88 | var list []string
89 | var tagView TagView
90 | // 本标签信息
91 | result := DB.Where("repo_name = ? AND name = ?", repoName, tagName).Select("name, path").Find(&tag)
92 | if result.Error != nil {
93 | return tagView, result.Error
94 | }
95 | // 一级子标签列表
96 | var subTags []Tag
97 | result = DB.Model(&Tag{}).Where("parent_path = ?", tag.Path).Select("name, path").Find(&subTags)
98 | if result.Error != nil && result.Error != gorm.ErrRecordNotFound {
99 | return tagView, result.Error
100 | }
101 | // 多级子标签列表
102 | for _, subTag := range subTags {
103 | list = append(list, subTag.Name)
104 | traverseTagInfo(&list, &subTag)
105 | }
106 | tagView.Name = tag.Name
107 | tagView.SubTags = list
108 | return tagView, nil
109 | }
110 |
111 | func traverseTagInfo(list *[]string, parentTag *Tag) {
112 | var subTags []Tag
113 | result := DB.Model(&Tag{}).Where("parent_path = ?", parentTag.Path).Select("name, path").Find(&subTags)
114 | // 无子标签也立即返回
115 | if result.Error != nil {
116 | return
117 | }
118 | for _, tag := range subTags {
119 | *list = append(*list, tag.Name)
120 | }
121 | for _, tag := range subTags {
122 | traverseTagInfo(list, &tag)
123 | }
124 | }
125 |
126 | // TruncateTags 清空 Tag 表
127 | func TruncateTags() error {
128 | result := DB.Delete(&Tag{})
129 | return result.Error
130 | }
131 |
132 | // DeleteTagsByRepo 清空某个 repo 下的 Tags
133 | func DeleteTagsByRepo(repoName string) error {
134 | result := DB.Where("repo_name = ?", repoName).Delete(&Tag{})
135 | return result.Error
136 | }
137 |
138 | // DeleteTag 清空某个 repo 下的 tag
139 | func DeleteTag(repoName string, tagName string) error {
140 | result := DB.Where("repo_name = ? and tag = ?", repoName, tagName).Delete(&Tag{})
141 | return result.Error
142 | }
143 |
144 | // AddTag 添加 Tag
145 | func AddTag(tag Tag) error {
146 | err := DB.Create(&tag).Error
147 | return err
148 | }
149 |
150 | // AddTags 批量添加 Tags
151 | func AddTags(tags []*Tag) error {
152 | tx := DB.Begin()
153 | defer func() {
154 | if r := recover(); r != nil {
155 | tx.Rollback()
156 | }
157 | }()
158 | if err := tx.Error; err != nil {
159 | return err
160 | }
161 | for _, tag := range tags {
162 | if err := tx.Create(tag).Error; err != nil {
163 | tx.Rollback()
164 | return err
165 | }
166 | }
167 | return tx.Commit().Error
168 | }
169 |
--------------------------------------------------------------------------------
/model/user.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "crypto/sha1"
5 | "encoding/hex"
6 | "encoding/json"
7 | "errors"
8 | "strings"
9 |
10 | "github.com/louisun/vinki/pkg/utils"
11 | )
12 |
13 | const (
14 | // 未激活
15 | STATUS_NOT_ACTIVE = iota
16 | // 申请中
17 | STATUS_APPLYING
18 | // 激活
19 | STATUS_ACTIVE
20 | // 禁用
21 | STATUS_BANNED
22 | )
23 |
24 | // User 用户
25 | type User struct {
26 | ID uint64 `gorm:"primary_key"`
27 | Email string `gorm:"type:varchar(100);unique_index"`
28 | NickName string `gorm:"size:30"`
29 | Password string `json:"-"`
30 | IsAdmin bool
31 | Repos string `json:"-"`
32 | CurrentRepoName string `json:"current_repo"`
33 | RepoNames []string `gorm:"-" json:"repos"`
34 | ApplyMessage string `json:"-"`
35 | Status int
36 | }
37 |
38 | type UserApplyInfo struct {
39 | ID uint64
40 | NickName string
41 | ApplyMessage string
42 | }
43 |
44 | func (User) TableName() string {
45 | return "users"
46 | }
47 |
48 | // GetUserByID 根据 ID 获取 User
49 | func GetUserByID(ID uint64) (User, error) {
50 | var user User
51 | result := DB.First(&user, ID)
52 |
53 | return user, result.Error
54 | }
55 |
56 | // GetAvailableUserByID 用ID获取可登录用户
57 | func GetAvailableUserByID(ID interface{}) (User, error) {
58 | var user User
59 | result := DB.Where("status != ?", STATUS_BANNED).First(&user, ID)
60 |
61 | return user, result.Error
62 | }
63 |
64 | // GetUserByEmail 根据 Email 获取 User
65 | func GetUserByEmail(email string) (User, error) {
66 | var user User
67 | result := DB.Where("email = ?", email).First(&user)
68 |
69 | return user, result.Error
70 | }
71 |
72 | // CreateUser 创建 User
73 | func CreateUser(user *User) error {
74 | result := DB.Create(user)
75 | return result.Error
76 | }
77 |
78 | // SetCurrentRepo 设置用户当前仓库
79 | func SetCurrentRepo(userID uint64, repo string) error {
80 | return DB.Model(&User{}).Where("id = ?", userID).Update("current_repo_name", repo).Error
81 | }
82 |
83 | // GetCurrentRepo 获取用户当前仓库
84 | func GetCurrentRepo(userID uint64) (string, error) {
85 | var user User
86 | err := DB.Model(&User{}).Where("id = ?", userID).First(&user).Error
87 |
88 | if err != nil {
89 | return "", nil
90 | }
91 |
92 | return user.CurrentRepoName, nil
93 | }
94 |
95 | // SetPassword 设置密码
96 | func (user *User) SetPassword(password string) error {
97 | // 随机 salt 值:16位
98 | salt := utils.RandString(16)
99 |
100 | // 计算密码和 salt 组合的 SHA1 摘要
101 | hash := sha1.New()
102 |
103 | _, err := hash.Write([]byte(password + salt))
104 | if err != nil {
105 | return err
106 | }
107 |
108 | bs := hex.EncodeToString(hash.Sum(nil))
109 | // 设置密码为 salt 值和摘要的组合
110 | user.Password = salt + ":" + bs
111 |
112 | return nil
113 | }
114 |
115 | // CheckPassword 校验密码
116 | func (user *User) CheckPassword(password string) (bool, error) {
117 | passwordEncrpt := strings.Split(user.Password, ":")
118 | if len(passwordEncrpt) != 2 {
119 | return false, errors.New("unknown password type")
120 | }
121 |
122 | // 生成摘要,判断密码是否匹配
123 | hash := sha1.New()
124 | _, err := hash.Write([]byte(password + passwordEncrpt[0]))
125 | if err != nil {
126 | return false, err
127 | }
128 |
129 | bs := hex.EncodeToString(hash.Sum(nil))
130 | // 判断哈希是否一致
131 | return bs == passwordEncrpt[1], nil
132 | }
133 |
134 | // SetStatus 设置用户状态
135 | func SetStatus(userID uint64, status int) {
136 | DB.Model(&User{}).Where("id = ?", userID).Update("status", status)
137 | }
138 |
139 | // Update 更新用户
140 | func UpdateUser(userID uint64, val map[string]interface{}) error {
141 | return DB.Model(&User{}).Where("id = ?", userID).Updates(val).Error
142 | }
143 |
144 | // UpdateUserAllowedRepos 授予用户可访问的仓库列表
145 | func UpdateUserAllowedRepos(userID uint64, repos []string) error {
146 | b, err := json.Marshal(&repos)
147 | if err != nil {
148 | return err
149 | }
150 |
151 | result := DB.Model(&User{}).Update(map[string]interface{}{"id": userID, "repos": string(b), "status": STATUS_ACTIVE})
152 |
153 | return result.Error
154 | }
155 |
156 | // AfterFind 钩子:反序列化 Repo 列表
157 | func (user *User) AfterFind() (err error) {
158 | if user.Repos != "" {
159 | err = json.Unmarshal([]byte(user.Repos), &user.RepoNames)
160 | }
161 |
162 | return err
163 | }
164 |
165 | // GetApplyingUserInfo 获取申请的用户信息
166 | func GetApplyingUserInfo() ([]UserApplyInfo, error) {
167 | var users []UserApplyInfo
168 |
169 | result := DB.Model(&User{}).Where("status = ?", STATUS_APPLYING).Select("id, nick_name, apply_message").Scan(&users)
170 |
171 | return users, result.Error
172 | }
173 |
--------------------------------------------------------------------------------
/pkg/conf/config.go:
--------------------------------------------------------------------------------
1 | package conf
2 |
3 | import (
4 | "path/filepath"
5 |
6 | "github.com/jinzhu/configor"
7 | "github.com/louisun/vinki/pkg/utils"
8 | )
9 |
10 | // GlobalConfig 全局配置变量
11 | var GlobalConfig = struct {
12 | Database DatabaseConfig
13 | Redis RedisConfig
14 | System SystemConfig
15 | Repositories []DirectoryConfig
16 | }{}
17 |
18 | // 数据库配置
19 | type DatabaseConfig struct {
20 | Type string `default:"UNSET"` // 数据库类型
21 | User string
22 | Password string
23 | Host string
24 | Port uint
25 | DBName string `default:"vinki"` // 数据库名
26 | DBFile string `default:"vinki.db"` // SQLite 数据库文件名
27 | }
28 |
29 | // Redis 配置
30 | type RedisConfig struct {
31 | Host string
32 | Port uint
33 | DB string `default:"0"`
34 | }
35 |
36 | // 系统配置
37 | type SystemConfig struct {
38 | Debug bool `default:"false"` // 调试模式
39 | Port uint `default:"6166"` // 监听端口
40 | SessionSecret string `default:"session-vinki-2020"`
41 | HashIDSalt string `default:"hash-salt-2020"`
42 | }
43 |
44 | // 目录
45 | type DirectoryConfig struct {
46 | Root string `default:"/vinki/repository"` // 根目录路径
47 | Exclude []string `required:"false"` // 排除的文件、目录列表
48 | Fold []string `required:"false"` // 折叠的目录列表
49 | }
50 |
51 | // Init 从配置文件中初始化配置
52 | func Init(path string) {
53 | if !utils.ExistsFile(path) {
54 | utils.Log().Panicf("配置文件路径不存在: %s", path)
55 | }
56 |
57 | err := configor.Load(&GlobalConfig, path)
58 |
59 | if err != nil {
60 | utils.Log().Panicf("加载配置文件失败, %s", err)
61 | }
62 | }
63 |
64 | // GetDirectoryConfig 获取相应仓库配置
65 | func GetDirectoryConfig(repoName string) *DirectoryConfig {
66 | for _, repo := range GlobalConfig.Repositories {
67 | if filepath.Base(repo.Root) == repoName {
68 | return &repo
69 | }
70 | }
71 |
72 | return nil
73 | }
74 |
--------------------------------------------------------------------------------
/pkg/conf/config_test.go:
--------------------------------------------------------------------------------
1 | package conf
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/louisun/vinki/pkg/utils"
7 | )
8 |
9 | func TestInitConfig(t *testing.T) {
10 | Init("/Users/louisun/go/src/github.com/louisun/vinki/conf/config.yml")
11 | utils.PrettyPrint(&GlobalConfig)
12 | }
13 |
--------------------------------------------------------------------------------
/pkg/serializer/error.go:
--------------------------------------------------------------------------------
1 | package serializer
2 |
3 | const (
4 | CodeSuccess = 200
5 | CodeUnauthorized = 401 // 认证错误
6 | CodeForbidden = 403 // 无权限
7 | CodeInternalError = 1000 // 内部错误
8 | CodeDBError = 1001 // 数据库错误
9 | CodeParamError = 1002 // 参数错误
10 | CodeConditionNotMeet = 1003 // 条件不满足错误
11 | CodeAdminRequired = 2000 // 需要为管理员账号
12 | CodeActiveRequired = 2001 // 需要为激活账号
13 | )
14 |
15 | type ServiceError struct {
16 | Code int
17 | Msg string
18 | RawError error
19 | }
20 |
21 | func NewServiceError(code int, msg string, err error) ServiceError {
22 | return ServiceError{
23 | Code: code,
24 | Msg: msg,
25 | RawError: err,
26 | }
27 | }
28 |
29 | func (se *ServiceError) WrapError(err error) ServiceError {
30 | se.RawError = err
31 | return *se
32 | }
33 |
34 | // 实现 error 接口,返回信息为内部 Msg
35 | func (se ServiceError) Error() string {
36 | return se.Msg
37 | }
38 |
--------------------------------------------------------------------------------
/pkg/serializer/response.go:
--------------------------------------------------------------------------------
1 | package serializer
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/gin-gonic/gin"
7 | "gopkg.in/go-playground/validator.v9"
8 | )
9 |
10 | // 响应体
11 | type Response struct {
12 | Code int `json:"code"` // 响应代码
13 | Data interface{} `json:"data,omitempty"` // 数据
14 | Msg string `json:"msg"` // 消息
15 | Error string `json:"error,omitempty"` // 错误
16 | }
17 |
18 | func CreateSuccessResponse(data interface{}, msg string) Response {
19 | return Response{
20 | Code: CodeSuccess,
21 | Data: data,
22 | Msg: msg,
23 | }
24 | }
25 |
26 | func CreateErrorResponse(errCode int, msg string, err error) Response {
27 | // 如果 err 是 ServiceError 类型,则覆盖参数传入的错误内容
28 | if serviceError, ok := err.(ServiceError); ok {
29 | errCode = serviceError.Code
30 | err = serviceError.RawError
31 | msg = serviceError.Msg
32 | }
33 |
34 | response := Response{
35 | // 无 Data,Error 只在非生产环境设置
36 | Code: errCode,
37 | Msg: msg,
38 | }
39 | if err != nil && gin.Mode() != gin.ReleaseMode {
40 | response.Error = err.Error()
41 | }
42 | return response
43 | }
44 |
45 | func CreateDBErrorResponse(msg string, err error) Response {
46 | if msg == "" {
47 | msg = "数据库操作失败"
48 | }
49 | return CreateErrorResponse(CodeDBError, msg, err)
50 | }
51 |
52 | func CreateInternalErrorResponse(msg string, err error) Response {
53 | if msg == "" {
54 | msg = "系统内部错误"
55 | }
56 | return CreateErrorResponse(CodeInternalError, msg, err)
57 | }
58 |
59 | func CreateGeneralParamErrorResponse(msg string, err error) Response {
60 | if msg == "" {
61 | msg = "参数错误"
62 | }
63 | return CreateErrorResponse(CodeParamError, msg, err)
64 | }
65 |
66 | func CreateParamErrorMsg(filed string, tag string) string {
67 | // 未通过验证的表单域与中文对应
68 | fieldMap := map[string]string{
69 | "UserName": "邮箱",
70 | "Password": "密码",
71 | "NickName": "昵称",
72 | }
73 | // 未通过的规则与中文对应
74 | tagMap := map[string]string{
75 | "required": "不能为空",
76 | "min": "太短",
77 | "max": "太长",
78 | "email": "格式不正确",
79 | }
80 | fieldVal, findField := fieldMap[filed]
81 | tagVal, findTag := tagMap[tag]
82 | if findField && findTag {
83 | // 返回拼接出来的错误信息
84 | return fieldVal + tagVal
85 | }
86 | return ""
87 | }
88 |
89 | func CreateParamErrorResponse(err error) Response {
90 | // 处理 Validator 产生的错误
91 | if ve, ok := err.(validator.ValidationErrors); ok {
92 | for _, e := range ve {
93 | return CreateGeneralParamErrorResponse(
94 | CreateParamErrorMsg(e.Field(), e.Tag()),
95 | err,
96 | )
97 | }
98 | }
99 | // 处理 JSON 类型错误
100 | if _, ok := err.(*json.UnmarshalTypeError); ok {
101 | return CreateGeneralParamErrorResponse("JSON类型不匹配", err)
102 | }
103 | // 其他参数错误
104 | return CreateGeneralParamErrorResponse("参数错误", err)
105 | }
106 |
--------------------------------------------------------------------------------
/pkg/serializer/user.go:
--------------------------------------------------------------------------------
1 | package serializer
2 |
3 | import (
4 | "github.com/louisun/vinki/model"
5 | "github.com/louisun/vinki/pkg/conf"
6 | "github.com/louisun/vinki/pkg/utils"
7 | )
8 |
9 | type UserDTO struct {
10 | ID string `json:"id"`
11 | Email string `json:"user_name"`
12 | NickName string `json:"nickname"`
13 | IsAdmin bool `json:"is_admin"`
14 | Status int `json:"status"`
15 | }
16 |
17 | // GetUnauthorizedResponse 检查登录
18 | func GetUnauthorizedResponse() Response {
19 | return Response{
20 | Code: CodeUnauthorized,
21 | Msg: "未认证",
22 | }
23 | }
24 |
25 | func CreateUserResponse(user *model.User) Response {
26 | data := UserDTO{
27 | ID: utils.GenerateHash(user.ID, utils.UserID, conf.GlobalConfig.System.HashIDSalt),
28 | Email: user.Email,
29 | NickName: user.NickName,
30 | IsAdmin: user.IsAdmin,
31 | Status: user.Status,
32 | }
33 |
34 | return CreateSuccessResponse(data, "")
35 | }
36 |
--------------------------------------------------------------------------------
/pkg/session/session.go:
--------------------------------------------------------------------------------
1 | package session
2 |
3 | import (
4 | "github.com/gin-contrib/sessions"
5 | "github.com/gin-gonic/gin"
6 | "github.com/louisun/vinki/pkg/utils"
7 | )
8 |
9 | // GetSession 从 session 获取 key 对应的值
10 | func GetSession(c *gin.Context, key string) interface{} {
11 | s := sessions.Default(c)
12 | return s.Get(c)
13 | }
14 |
15 | // SetSession 保存键值对到 session 中
16 | func SetSession(c *gin.Context, kvMap map[string]interface{}) {
17 | s := sessions.Default(c)
18 |
19 | for key, value := range kvMap {
20 | s.Set(key, value)
21 | }
22 |
23 | err := s.Save()
24 | if err != nil {
25 | utils.Log().Warningf("无法设置 session: %s", err)
26 | }
27 | }
28 |
29 | // DeleteSession 删除 session
30 | func DeleteSession(c *gin.Context, key string) {
31 | s := sessions.Default(c)
32 | s.Delete(key)
33 | err := s.Save()
34 |
35 | if err != nil {
36 | utils.Log().Warningf("无法删除 session key: %s, err: %s", key, err)
37 | }
38 | }
39 |
40 | // ClearSession 清空 session
41 | func ClearSession(c *gin.Context) {
42 | s := sessions.Default(c)
43 | s.Clear()
44 | err := s.Save()
45 |
46 | if err != nil {
47 | utils.Log().Warningf("无法清空 session: %s", err)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/pkg/utils/common.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "math/rand"
7 | "strconv"
8 | "strings"
9 | )
10 |
11 | // RandString 随机长度字符串
12 | func RandString(n int) string {
13 | var letterRunes = []rune("1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
14 |
15 | b := make([]rune, n)
16 | for i := range b {
17 | b[i] = letterRunes[rand.Intn(len(letterRunes))]
18 | }
19 | return string(b)
20 | }
21 |
22 | // PrettyPrint 以 JSON 格式打印对象结构
23 | func PrettyPrint(i interface{}) {
24 | s, _ := json.MarshalIndent(i, "", "\t")
25 | fmt.Printf("%s\n", s)
26 | }
27 |
28 | // splitIDs 将 ID 列表字符串转换为数值列表
29 | func SplitIDs(s string) []uint64 {
30 | l := strings.Split(s, ",")
31 | var ret []uint64
32 | for _, l := range l {
33 | i, _ := strconv.ParseUint(l, 10, 64)
34 | ret = append(ret, i)
35 | }
36 | return ret
37 | }
38 |
39 | // IsInList 判断项目是否在列表内
40 | func IsInList(list []string, target string) bool {
41 | for _, item := range list {
42 | if item == target {
43 | return true
44 | }
45 | }
46 | return false
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/utils/file.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io/ioutil"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 |
11 | "github.com/louisun/heyspace/space"
12 |
13 | "github.com/louisun/markdown"
14 | "github.com/louisun/markdown/parser"
15 | )
16 |
17 | // FileInfo 文件信息
18 | type FileInfo struct {
19 | BriefName string
20 | Path string
21 | }
22 |
23 | func (fi *FileInfo) String() string {
24 | return fmt.Sprintf("[%s] -> [%s]", fi.BriefName, fi.Path)
25 | }
26 |
27 | // Tag path 到 FileInfo 的映射,用于创建文档
28 | type TagPath2FileInfo map[string][]*FileInfo
29 |
30 | func (m TagPath2FileInfo) String() string {
31 | var s string
32 | for k, v := range m {
33 | s += fmt.Sprintf("tag path: %s\n", k)
34 | for _, fi := range v {
35 | s += fmt.Sprintf("\t%s\n", fi)
36 | }
37 | }
38 | return s
39 | }
40 |
41 | // MarkdownFile2Html 读取本地 Markdown 文件并渲染为 Html bytes
42 | func RenderMarkdown(mdPath string) ([]byte, error) {
43 | if !strings.HasSuffix(mdPath, ".md") {
44 | return nil, errors.New("File suffix is not \".md\"")
45 | }
46 | mdFile, err := os.Open(mdPath)
47 | if err != nil {
48 | Log().Errorf("Open mdFile %s failed: %v", mdPath, err)
49 | return nil, err
50 | }
51 | defer mdFile.Close()
52 | md, err := ioutil.ReadAll(mdFile)
53 | if err != nil {
54 | Log().Errorf("Read mdFile %s failed", mdPath)
55 | return nil, err
56 | }
57 | mdStr := string(md)
58 | spaceHandler := space.NewMarkdownHandler(&mdStr)
59 | mdBytes := []byte(spaceHandler.HandleText())
60 | // CommonExtensions 排除 NoIntraEmphasis(否则会导致 * 后面跟部分字符无法解析)
61 | extensions := parser.CommonExtensions ^ parser.NoIntraEmphasis
62 | p := parser.NewWithExtensions(extensions)
63 | html := markdown.ToHTML(mdBytes, p, nil)
64 | return html, nil
65 | }
66 |
67 | // ExistsPath 判断所给路径是否存在
68 | func ExistsPath(path string) bool {
69 | _, err := os.Stat(path)
70 | if err != nil {
71 | if os.IsExist(err) {
72 | return true
73 | }
74 | return false
75 | }
76 | return true
77 | }
78 |
79 | // IsDir 判断路径是否是目录
80 | func IsDir(path string) bool {
81 | stat, err := os.Stat(path)
82 | if err != nil {
83 | return false
84 | }
85 | return stat.IsDir()
86 | }
87 |
88 | // IsFile 判断路径是否是文件
89 | func IsFile(path string) bool {
90 | stat, err := os.Stat(path)
91 | if err != nil {
92 | return false
93 | }
94 | return !stat.IsDir()
95 | }
96 |
97 | // ExistsDir 判断是否存在该目录
98 | func ExistsDir(path string) bool {
99 | return ExistsPath(path) && IsDir(path)
100 | }
101 |
102 | // ExistsFile 判断是否存在该文件
103 | func ExistsFile(path string) bool {
104 | return ExistsPath(path) && IsFile(path)
105 | }
106 |
107 | // IsDirectoryInList 判断目录是否在列表中
108 | func IsDirectoryInList(directory string, list []string) bool {
109 | for _, t := range list {
110 | if directory == t {
111 | return true
112 | }
113 | }
114 | return false
115 | }
116 |
117 | // 获取运行环境的相对路径
118 | func RelativePath(name string) string {
119 | if filepath.IsAbs(name) {
120 | return name
121 | }
122 | e, _ := os.Executable()
123 | return filepath.Join(filepath.Dir(e), name)
124 | }
125 |
--------------------------------------------------------------------------------
/pkg/utils/hash.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/speps/go-hashids"
7 | )
8 |
9 | // ID类型
10 | const (
11 | UserID = iota // 用户
12 | )
13 |
14 | var (
15 | // ErrTypeNotMatch ID类型不匹配
16 | ErrTypeNotMatch = errors.New("ID类型不匹配")
17 | )
18 |
19 | // HashEncode 对给定数据计算HashID
20 | func HashEncode(values []int, salt string) (string, error) {
21 | hd := hashids.NewData()
22 | hd.Salt = salt
23 |
24 | h, err := hashids.NewWithData(hd)
25 | if err != nil {
26 | return "", err
27 | }
28 |
29 | id, err := h.Encode(values)
30 | if err != nil {
31 | return "", err
32 | }
33 | return id, nil
34 | }
35 |
36 | // HashDecode 对给定数据计算原始数据
37 | func HashDecode(hashString string, salt string) ([]int, error) {
38 | hd := hashids.NewData()
39 | hd.Salt = salt
40 |
41 | h, err := hashids.NewWithData(hd)
42 | if err != nil {
43 | return []int{}, err
44 | }
45 |
46 | return h.DecodeWithError(hashString)
47 |
48 | }
49 |
50 | // GenerateHash 计算数据库内主键对应的HashID
51 | func GenerateHash(id uint64, pkType int, salt string) string {
52 | v, _ := HashEncode([]int{int(id), pkType}, salt)
53 | return v
54 | }
55 |
56 | // GetOriginID 计算HashID对应的数据库ID
57 | func GetOriginID(hashString string, pkType int, salt string) (uint, error) {
58 | v, _ := HashDecode(hashString, salt)
59 | if len(v) != 2 || v[1] != pkType {
60 | return 0, ErrTypeNotMatch
61 | }
62 | return uint(v[0]), nil
63 | }
64 |
--------------------------------------------------------------------------------
/pkg/utils/log.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/sirupsen/logrus"
8 | )
9 |
10 | var DefaultLog = NewLog(logrus.DebugLevel, "vinki.log")
11 |
12 | func Log() *logrus.Logger {
13 | return DefaultLog
14 | }
15 |
16 | type LogrusFileHook struct {
17 | file *os.File
18 | flag int
19 | chmod os.FileMode
20 | formatter *logrus.TextFormatter
21 | }
22 |
23 | func NewLog(level logrus.Level, outputFile string) *logrus.Logger {
24 | log := &logrus.Logger{
25 | Out: os.Stdout,
26 | Formatter: &logrus.TextFormatter{ForceColors: true, TimestampFormat: "2006-01-02 15:04:05", FullTimestamp: true},
27 | Hooks: make(logrus.LevelHooks),
28 | // Minimum level to log at (5 is most verbose (debug), 0 is panic)
29 | Level: level,
30 | }
31 | fileHook, err := NewLogrusFileHook(outputFile, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0666)
32 | if err == nil {
33 | log.Hooks.Add(fileHook)
34 | }
35 | return log
36 | }
37 |
38 | func NewLogrusFileHook(file string, flag int, chmod os.FileMode) (*LogrusFileHook, error) {
39 | plainFormatter := &logrus.TextFormatter{DisableColors: true}
40 | logFile, err := os.OpenFile(file, flag, chmod)
41 | if err != nil {
42 | fmt.Fprintf(os.Stderr, "unable to write file on filehook %v", err)
43 | return nil, err
44 | }
45 |
46 | return &LogrusFileHook{logFile, flag, chmod, plainFormatter}, err
47 | }
48 |
49 | // Fire event
50 | func (hook *LogrusFileHook) Fire(entry *logrus.Entry) error {
51 |
52 | plainformat, err := hook.formatter.Format(entry)
53 | line := string(plainformat)
54 | _, err = hook.file.WriteString(line)
55 | if err != nil {
56 | fmt.Fprintf(os.Stderr, "unable to write file on filehook(entry.String)%v", err)
57 | return err
58 | }
59 |
60 | return nil
61 | }
62 |
63 | func (hook *LogrusFileHook) Levels() []logrus.Level {
64 | return []logrus.Level{
65 | logrus.PanicLevel,
66 | logrus.FatalLevel,
67 | logrus.ErrorLevel,
68 | logrus.WarnLevel,
69 | logrus.InfoLevel,
70 | logrus.DebugLevel,
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/routers/router.go:
--------------------------------------------------------------------------------
1 | package routers
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/louisun/vinki/pkg/conf"
7 |
8 | "github.com/louisun/vinki/middleware"
9 |
10 | "github.com/gin-contrib/static"
11 | "github.com/louisun/vinki/bootstrap"
12 |
13 | "github.com/gin-contrib/cors"
14 | "github.com/louisun/vinki/controllers"
15 |
16 | "github.com/gin-gonic/gin"
17 | )
18 |
19 | const maxCorsHour = 12
20 |
21 | func handleCors(r *gin.Engine) {
22 | if gin.Mode() == gin.TestMode {
23 | r.Use(cors.New(cors.Config{
24 | AllowOrigins: []string{"http://localhost:3000"},
25 | AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
26 | AllowHeaders: []string{"Origin", "Content-Length", "Content-Type"},
27 | AllowCredentials: true,
28 | MaxAge: maxCorsHour * time.Hour,
29 | }))
30 | }
31 | }
32 |
33 | // InitRouter 初始化路由
34 | func InitRouter() *gin.Engine {
35 | r := gin.Default()
36 |
37 | handleCors(r)
38 | r.Use(static.Serve("/", bootstrap.StaticFS))
39 |
40 | v1 := r.Group("/api/v1")
41 |
42 | v1.Use(middleware.Session(conf.GlobalConfig.System.SessionSecret))
43 |
44 | v1.Use(middleware.InitCurrentUserIfExists())
45 |
46 | {
47 | site := v1.Group("site")
48 | {
49 | site.GET("ping", controllers.Ping)
50 | site.GET("config", middleware.RequireAuth(), controllers.GetSiteConfig)
51 | }
52 |
53 | user := v1.Group("user")
54 | {
55 | user.POST("login", controllers.UserLogin)
56 | user.POST("logout", controllers.UserLogout)
57 | user.POST("", controllers.UserRegister)
58 | user.PUT("", controllers.UserResetPassword)
59 | user.POST("ban", middleware.RequireAuth(), middleware.RequireAdmin(), controllers.BanUser)
60 | }
61 |
62 | auth := v1.Group("")
63 | auth.Use(middleware.RequireAuth())
64 |
65 | {
66 | admin := auth.Group("admin", middleware.RequireAdmin())
67 | {
68 | admin.POST("refresh/all", controllers.RefreshAll)
69 | admin.POST("refresh/repo", controllers.RefreshByRepo)
70 | admin.POST("refresh/tag", controllers.RefreshByTag)
71 | admin.GET("applications", controllers.GetApplications)
72 | admin.POST("application/activate", controllers.ActivateUser)
73 | admin.POST("application/reject", controllers.RejectUserApplication)
74 | admin.GET("config/repo", controllers.GetCurrentRepo)
75 | admin.POST("config/repo", controllers.SetCurrentRepo)
76 | }
77 | auth.POST("apply", controllers.ApplyForActivate)
78 | auth.GET("search", controllers.Search)
79 |
80 | active := auth.Group("", middleware.CheckPermission())
81 | {
82 | active.GET("repos", controllers.GetRepos)
83 | active.GET("tags", controllers.GetTopTags)
84 | active.GET("tag", controllers.GetTagView)
85 | active.GET("article", controllers.GetArticle)
86 | }
87 | }
88 | }
89 |
90 | return r
91 | }
92 |
--------------------------------------------------------------------------------
/service/article.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/jinzhu/gorm"
5 | "github.com/louisun/vinki/model"
6 | "github.com/louisun/vinki/pkg/serializer"
7 | )
8 |
9 | // ArticleView 文章视图
10 | type ArticleView struct {
11 | Title string
12 | HTML string
13 | }
14 |
15 | // GetArticleDetail 获取文章详情
16 | func GetArticleDetail(repoName string, tagName string, articleName string) serializer.Response {
17 | article, err := model.GetArticle(repoName, tagName, articleName)
18 | if err != nil {
19 | if err == gorm.ErrRecordNotFound {
20 | return serializer.CreateGeneralParamErrorResponse("文章 ID 不存在", err)
21 | }
22 |
23 | return serializer.CreateDBErrorResponse("", err)
24 | }
25 |
26 | view := ArticleView{
27 | Title: article.Title,
28 | HTML: article.HTML,
29 | }
30 |
31 | return serializer.CreateSuccessResponse(view, "")
32 | }
33 |
--------------------------------------------------------------------------------
/service/refresh.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "database/sql"
5 | "errors"
6 | "fmt"
7 | "io/ioutil"
8 | "os"
9 | "path/filepath"
10 | "strings"
11 | "sync"
12 |
13 | "github.com/panjf2000/ants/v2"
14 |
15 | "github.com/louisun/vinki/pkg/serializer"
16 |
17 | "github.com/louisun/vinki/pkg/conf"
18 |
19 | "github.com/louisun/vinki/model"
20 |
21 | "github.com/louisun/vinki/pkg/utils"
22 | )
23 |
24 | type RepoRequest struct {
25 | RepoName string `json:"repoName" binding:"required"`
26 | }
27 |
28 | type RepoTagRequest struct {
29 | RepoName string `form:"repoName" json:"repoName" binding:"required"`
30 | TagName string `form:"tagName" json:"tagName" binding:"required"`
31 | }
32 |
33 | type articleTask struct {
34 | FileInfo *utils.FileInfo
35 | RepoName string
36 | TagName string
37 | }
38 |
39 | const (
40 | ConcurrentSize = 50
41 | TagSeparator = "|"
42 | )
43 |
44 | // handleArticleTask 处理一个标签下的 Articles
45 | func handleArticleTask(articleTask articleTask, articleChan chan *model.Article) {
46 | htmlBytes, err := utils.RenderMarkdown(articleTask.FileInfo.Path)
47 | if err != nil {
48 | w := fmt.Errorf("RenderMarkdown failed: %w", err)
49 | utils.Log().Errorf("%v", w)
50 | }
51 |
52 | article := model.Article{
53 | Title: articleTask.FileInfo.BriefName,
54 | Path: articleTask.FileInfo.Path,
55 | HTML: string(htmlBytes),
56 | TagName: articleTask.TagName,
57 | RepoName: articleTask.RepoName,
58 | }
59 |
60 | articleChan <- &article
61 | }
62 |
63 | // loadLocalRepo 加载本地仓库数据到数据库
64 | func loadLocalRepo(r conf.DirectoryConfig) error {
65 | if utils.ExistsDir(r.Root) {
66 | var (
67 | repoPath = r.Root
68 | tagPath2Name = make(map[string]string)
69 | )
70 |
71 | if strings.HasSuffix(repoPath, "/") {
72 | repoPath = strings.TrimSuffix(repoPath, "/")
73 | }
74 |
75 | repo := model.Repo{
76 | Name: filepath.Base(repoPath),
77 | Path: repoPath,
78 | }
79 | // 创建 repo
80 | if err := model.AddRepo(&repo); err != nil {
81 | w := fmt.Errorf("addRepo failed: %w", err)
82 | utils.Log().Errorf("%v", w)
83 |
84 | return w
85 | }
86 | // 遍历 repo,获取标签路径列表、标签对应的文件列表字典
87 | tagPathList, tagPath2FileListMap, err := traverseRepo(r)
88 |
89 | if err != nil {
90 | w := fmt.Errorf("traverseRepo failed: %w", err)
91 | utils.Log().Errorf("%v", w)
92 |
93 | return w
94 | }
95 |
96 | tags := make([]*model.Tag, 0, len(tagPathList))
97 | // 根据 tag 路径列表构造 Tag
98 | for _, tp := range tagPathList {
99 | parentpath := filepath.Dir(tp)
100 | tagname := strings.Join(strings.Split(strings.TrimPrefix(tp, repo.Path+"/"), "/"), TagSeparator)
101 |
102 | if parentpath == repoPath {
103 | // 一级目录
104 | tags = append(tags, &model.Tag{
105 | Path: tp,
106 | RepoName: repo.Name,
107 | Name: tagname,
108 | })
109 | } else {
110 | // 子目录
111 | tags = append(tags, &model.Tag{
112 | Path: tp,
113 | RepoName: repo.Name,
114 | Name: tagname,
115 | ParentPath: sql.NullString{
116 | String: parentpath,
117 | Valid: true,
118 | },
119 | })
120 | }
121 | }
122 | // 3. 创建 Tag
123 | if err := model.AddTags(tags); err != nil {
124 | w := fmt.Errorf("addTags failed: %w", err)
125 | utils.Log().Errorf("%v", w)
126 | return w
127 | }
128 | // 保存 tag 路径和标签名的映射关系
129 | for _, tag := range tags {
130 | tagPath2Name[tag.Path] = tag.Name
131 | }
132 |
133 | p, err := ants.NewPool(ConcurrentSize)
134 | if err != nil {
135 | w := fmt.Errorf("create goroutine pool failed: %w", err)
136 | utils.Log().Errorf("%v", w)
137 | return w
138 | }
139 | defer p.Release()
140 | var tasks []articleTask
141 | // 4. 构造 Article 并创建 articles: 遍历 tagPath2FileListMap
142 | for tagPath, fileInfos := range tagPath2FileListMap {
143 | for _, fileInfo := range fileInfos {
144 | tasks = append(tasks, articleTask{
145 | fileInfo,
146 | repo.Name,
147 | tagPath2Name[tagPath],
148 | })
149 | }
150 | }
151 |
152 | var (
153 | wg sync.WaitGroup
154 | articleChan = make(chan *model.Article, len(tasks))
155 | articles = make([]*model.Article, 0, len(tasks))
156 | )
157 |
158 | for _, task := range tasks {
159 | wg.Add(1)
160 |
161 | t := task
162 | _ = p.Submit(func() {
163 | defer wg.Done()
164 | handleArticleTask(t, articleChan)
165 | })
166 | }
167 |
168 | wg.Wait()
169 | close(articleChan)
170 |
171 | for article := range articleChan {
172 | articles = append(articles, article)
173 | }
174 |
175 | err = model.AddArticles(articles)
176 | if err != nil {
177 | w := fmt.Errorf("AddArticles failed: %w", err)
178 | utils.Log().Errorf("%v", w)
179 | return w
180 | }
181 | } else {
182 | err := fmt.Errorf("repo path not exist: %s", r.Root)
183 | utils.Log().Error(err)
184 |
185 | return err
186 | }
187 |
188 | return nil
189 | }
190 |
191 | // loadLocalTag 加载本地标签的文章数据到数据库
192 | func loadLocalTag(tag model.Tag) error {
193 | fileInfos, err := getArticlesInTag(tag)
194 | if err != nil {
195 | w := fmt.Errorf("getArticlesInTag failed: %w", err)
196 | utils.Log().Errorf("%v", w)
197 | return w
198 | }
199 |
200 | articles := make([]*model.Article, 0, len(fileInfos))
201 |
202 | for _, fileInfo := range fileInfos {
203 | var htmlBytes []byte
204 | // Markdown 渲染
205 | htmlBytes, err := utils.RenderMarkdown(fileInfo.Path)
206 | if err != nil {
207 | w := fmt.Errorf("RenderMarkdown failed: %w", err)
208 | utils.Log().Errorf("%v", w)
209 | return w
210 | }
211 |
212 | articles = append(articles, &model.Article{
213 | RepoName: tag.RepoName,
214 | TagName: tag.Name,
215 | Title: fileInfo.BriefName,
216 | Path: fileInfo.Path,
217 | HTML: string(htmlBytes),
218 | })
219 | }
220 |
221 | err = model.AddArticles(articles)
222 | if err != nil {
223 | w := fmt.Errorf("addArticles failed: %w", err)
224 | utils.Log().Errorf("%v", w)
225 | return w
226 | }
227 |
228 | return nil
229 | }
230 |
231 | // RefreshGlobal 全局刷新
232 | func RefreshGlobal() serializer.Response {
233 | err := RefreshDatabase()
234 | if err != nil {
235 | return serializer.CreateInternalErrorResponse("同步仓库失败", err)
236 | }
237 |
238 | return serializer.CreateSuccessResponse("", "同步仓库成功")
239 | }
240 |
241 | func RefreshDatabase() error {
242 | // 清空所有数据库
243 | if err := clearAll(); err != nil {
244 | w := fmt.Errorf("clearAll failed: %w", err)
245 | utils.Log().Errorf("%v", w)
246 | return w
247 | }
248 | // 遍历每个 Repo 配置项
249 | var repos []string
250 | for _, r := range conf.GlobalConfig.Repositories {
251 | repos = append(repos, filepath.Base(r.Root))
252 |
253 | err := loadLocalRepo(r)
254 | if err != nil {
255 | w := fmt.Errorf("loadLocalRepo failed: %w", err)
256 | utils.Log().Errorf("%v", w)
257 |
258 | return w
259 | }
260 | }
261 | // 授予管理员所有仓库的访问权限
262 | err := model.UpdateUserAllowedRepos(1, repos)
263 | if err != nil {
264 | w := fmt.Errorf("UpdateUserAllowedRepos to admin failed: %w", err)
265 | utils.Log().Errorf("%v", w)
266 |
267 | return w
268 | }
269 |
270 | return nil
271 | }
272 |
273 | // RefreshRepo 只刷新特定的Repo
274 | func RefreshRepo(repoName string) serializer.Response {
275 | // 获取该 repo 的配置信息
276 | cfg := conf.GetDirectoryConfig(repoName)
277 | if cfg == nil {
278 | return serializer.CreateParamErrorResponse(errors.New("repo not exist"))
279 | }
280 |
281 | // 清空该 repo 相关的数据
282 | err := clearRepo(repoName)
283 | if err != nil {
284 | return serializer.CreateDBErrorResponse("", err)
285 | }
286 |
287 | err = loadLocalRepo(*cfg)
288 | if err != nil {
289 | return serializer.CreateInternalErrorResponse("loadLocalRepo failed", err)
290 | }
291 |
292 | utils.Log().Info("[Success] Refresh Local Repository")
293 |
294 | return serializer.CreateSuccessResponse("", "同步当前仓库成功")
295 | }
296 |
297 | // RefreshTag 只刷新特定的Tag
298 | func RefreshTag(repoName string, tagName string) serializer.Response {
299 | // 获取该 repo 的配置信息
300 | cfg := conf.GetDirectoryConfig(repoName)
301 | if cfg == nil {
302 | return serializer.CreateParamErrorResponse(errors.New("repo not exist"))
303 | }
304 |
305 | // 清空 tag 下的 articles
306 | err := clearTag(repoName, tagName)
307 | if err != nil {
308 | return serializer.CreateDBErrorResponse("", err)
309 | }
310 |
311 | // 获取该目录的文章列表信息,重新生成文章
312 | tag, err := model.GetTag(repoName, tagName)
313 | if err != nil {
314 | return serializer.CreateDBErrorResponse("", err)
315 | }
316 |
317 | err = loadLocalTag(tag)
318 | if err != nil {
319 | return serializer.CreateInternalErrorResponse("loadLocalTag failed", err)
320 | }
321 |
322 | utils.Log().Info("[Success] Refresh Local Tag")
323 |
324 | return serializer.CreateSuccessResponse("", "同步当前标签成功")
325 | }
326 |
327 | // clearAll 清空所有 Article、Tag、Repo
328 | func clearAll() error {
329 | // 清空 tag 数据库
330 | err := model.TruncateTags()
331 | if err != nil {
332 | w := fmt.Errorf("truncate tag db failed: %w", err)
333 | utils.Log().Errorf("%v", w)
334 |
335 | return w
336 | }
337 |
338 | // 清空 repo 数据库
339 | err = model.TruncateRepo()
340 | if err != nil {
341 | w := fmt.Errorf("truncate repo db failed: %w", err)
342 | utils.Log().Errorf("%v", w)
343 |
344 | return w
345 | }
346 |
347 | // 清空 article 数据库
348 | err = model.TruncateArticles()
349 | if err != nil {
350 | w := fmt.Errorf("truncate article db failed: %w", err)
351 | utils.Log().Errorf("%v", w)
352 |
353 | return w
354 | }
355 | return nil
356 | }
357 |
358 | // clearRepo 清空指定 Repo 及以下的 Tag 和 Article
359 | func clearRepo(repoName string) error {
360 | // 清空指定 repo
361 | err := model.DeleteRepo(repoName)
362 | if err != nil {
363 | w := fmt.Errorf("delete repo failed: %w", err)
364 | utils.Log().Errorf("%v", w)
365 |
366 | return w
367 | }
368 | // 清空 repo 下的 tags
369 | err = model.DeleteTagsByRepo(repoName)
370 | if err != nil {
371 | w := fmt.Errorf("delete tags failed: %w", err)
372 | utils.Log().Errorf("%v", w)
373 |
374 | return w
375 | }
376 |
377 | // 清空 repo 下的 articles
378 | err = model.DeleteArticlesByRepo(repoName)
379 | if err != nil {
380 | w := fmt.Errorf("delete articles failed: %w", err)
381 | utils.Log().Errorf("%v", w)
382 |
383 | return w
384 | }
385 |
386 | return nil
387 | }
388 |
389 | // clearTag 清空指定 Tag 下的 Article
390 | func clearTag(repoName string, tagName string) error {
391 | // 清空 repo 下的 articles
392 | err := model.DeleteArticlesByTag(repoName, tagName)
393 | if err != nil {
394 | w := fmt.Errorf("delete articles failed: %w", err)
395 | utils.Log().Errorf("%v", w)
396 |
397 | return w
398 | }
399 |
400 | return nil
401 | }
402 |
403 | // traverseRepo 遍历 Repo 目录,生成标签与文档的映射树
404 | func traverseRepo(repo conf.DirectoryConfig) (tagPaths []string, tagPath2FileListMap utils.TagPath2FileInfo, err error) {
405 | tagPath2FileListMap = make(map[string][]*utils.FileInfo)
406 | var (
407 | root = repo.Root
408 | exclude = repo.Exclude
409 | rootPrefix = root // rootPrefix 用于拆分标签
410 | )
411 |
412 | if !strings.HasSuffix(rootPrefix, "/") {
413 | rootPrefix = rootPrefix + "/"
414 | }
415 |
416 | err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
417 | if info.IsDir() {
418 | // 目录 -> Tag
419 | // 跳过 Exclude 配置项指定的目录
420 | if strings.HasPrefix(info.Name(), ".") || utils.IsDirectoryInList(info.Name(), exclude) {
421 | return filepath.SkipDir
422 | }
423 | // 保存 Tag path
424 | tagPaths = append(tagPaths, path)
425 | } else if strings.HasSuffix(info.Name(), ".md") {
426 | // md 文件 -> Article
427 | // 保存 Tag 路径 -> FileInfo
428 | dir := filepath.Dir(path)
429 |
430 | if paths, ok := tagPath2FileListMap[dir]; ok {
431 | paths = append(paths, &utils.FileInfo{
432 | BriefName: strings.TrimSuffix(info.Name(), ".md"),
433 | Path: path,
434 | })
435 | tagPath2FileListMap[dir] = paths
436 | } else {
437 | paths = make([]*utils.FileInfo, 0, 10)
438 | paths = append(paths, &utils.FileInfo{
439 | BriefName: strings.TrimSuffix(info.Name(), ".md"),
440 | Path: path,
441 | })
442 |
443 | tagPath2FileListMap[dir] = paths
444 | }
445 | }
446 | return nil
447 | })
448 | return
449 | }
450 |
451 | func getArticlesInTag(tag model.Tag) (fileInfoList []*utils.FileInfo, err error) {
452 | var originalInfos []os.FileInfo
453 | originalInfos, err = ioutil.ReadDir(tag.Path)
454 | if err != nil {
455 | return
456 | }
457 |
458 | for _, info := range originalInfos {
459 | if strings.HasSuffix(info.Name(), ".md") {
460 | fileInfoList = append(fileInfoList, &utils.FileInfo{
461 | BriefName: strings.TrimSuffix(info.Name(), ".md"),
462 | Path: filepath.Join(tag.Path, info.Name()),
463 | })
464 | }
465 | }
466 |
467 | return
468 | }
469 |
--------------------------------------------------------------------------------
/service/search.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/louisun/vinki/model"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/louisun/vinki/pkg/serializer"
10 | )
11 |
12 | const (
13 | typeTag = "tag"
14 | typeArticleName = "article"
15 | )
16 |
17 | // Search 搜索
18 | func Search(c *gin.Context) serializer.Response {
19 | searchType := c.Query("type")
20 | repoName := c.Query("repo")
21 | keyword := c.Query("keyword")
22 | if keyword == "" {
23 | return serializer.CreateGeneralParamErrorResponse("", errors.New("搜索关键词不能为空"))
24 | }
25 | switch searchType {
26 | case typeTag:
27 | tags, err := model.GetTagsBySearchName(repoName, keyword)
28 | if err != nil {
29 | return serializer.CreateDBErrorResponse("", err)
30 | }
31 | return serializer.CreateSuccessResponse(tags, "")
32 | case typeArticleName:
33 | articleTagInfos, err := model.GetArticlesBySearchParam(repoName, keyword)
34 | if err != nil {
35 | return serializer.CreateDBErrorResponse("", err)
36 | }
37 | return serializer.CreateSuccessResponse(articleTagInfos, "")
38 | default:
39 | return serializer.CreateGeneralParamErrorResponse("", errors.New("搜索类型不正确"))
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/service/tag.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "sort"
5 |
6 | "github.com/jinzhu/gorm"
7 | "github.com/louisun/vinki/model"
8 | "github.com/louisun/vinki/pkg/serializer"
9 | )
10 |
11 | type tagArticleInfoView struct {
12 | model.TagView
13 | ArticleInfos []string
14 | }
15 |
16 | // GetTopTagInfosByRepo 获取 Repo 的一级 Tag 信息
17 | func GetTopTagInfosByRepo(repoName string) serializer.Response {
18 | tags, err := model.GetTopTagInfosByRepo(repoName)
19 | if err != nil {
20 | if err == gorm.ErrRecordNotFound {
21 | return serializer.CreateGeneralParamErrorResponse("当前仓库无标签可获取", err)
22 | }
23 |
24 | return serializer.CreateDBErrorResponse("", err)
25 | }
26 |
27 | return serializer.CreateSuccessResponse(tags, "")
28 | }
29 |
30 | // GetTagArticleView 根据 TagID 获取 TagArticleInfoView
31 | func GetTagArticleView(repoName string, tagName string, flat bool) serializer.Response {
32 | var (
33 | tagView model.TagView
34 | err error
35 | )
36 |
37 | if flat {
38 | tagView, err = model.GetFlatTagView(repoName, tagName)
39 | } else {
40 | tagView, err = model.GetTagView(repoName, tagName)
41 | }
42 |
43 | if err != nil {
44 | if err == gorm.ErrRecordNotFound {
45 | return serializer.CreateGeneralParamErrorResponse("tag 不存在", err)
46 | }
47 |
48 | return serializer.CreateDBErrorResponse("", err)
49 | }
50 |
51 | articles, err := model.GetArticleList(repoName, tagName)
52 |
53 | if err != nil {
54 | return serializer.CreateDBErrorResponse("", err)
55 | }
56 |
57 | sort.Sort(model.Articles(articles))
58 |
59 | view := tagArticleInfoView{
60 | TagView: tagView,
61 | ArticleInfos: articles,
62 | }
63 |
64 | return serializer.CreateSuccessResponse(view, "")
65 | }
66 |
--------------------------------------------------------------------------------
/service/user.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/louisun/vinki/model"
8 | "github.com/louisun/vinki/pkg/serializer"
9 | "github.com/louisun/vinki/pkg/session"
10 | )
11 |
12 | const InvitationCode = "VINKI_BY_RENZO"
13 |
14 | // UserRegisterRequest 用户注册服务
15 | type UserRegisterRequest struct {
16 | UserName string `form:"userName" json:"userName" binding:"required,email"`
17 | NickName string `form:"nickName" json:"nickName" binding:"required"`
18 | Password string `form:"password" json:"password" binding:"required,min=4,max=64"`
19 | InvitationCode string `form:"invitationCode" json:"invitationCode" binding:"required"`
20 | }
21 |
22 | // UserLoginRequest 用户登录服务
23 | type UserLoginRequest struct {
24 | UserName string `form:"userName" json:"userName" binding:"required,email"`
25 | Password string `form:"password" json:"password" binding:"required,min=4,max=64"`
26 | }
27 |
28 | // UserLogoutRequest 用户登出服务
29 | type UserLogoutRequest struct{}
30 |
31 | // GetApplications
32 | type GetApplicationsRequest struct{}
33 |
34 | // ActivateUserRequest 激活用户服务
35 | type ActivateUserRequest struct {
36 | UserID uint64 `form:"userID" json:"userID" binding:"required"`
37 | Repos []string `form:"repos" json:"repos" binding:"required"`
38 | }
39 |
40 | type RejectUserRequest struct {
41 | UserID uint64 `form:"userID" json:"userID" binding:"required"`
42 | }
43 |
44 | type BanUserRequest struct {
45 | UserID uint64 `form:"userID" json:"userID" binding:"required"`
46 | }
47 |
48 | // ApplyForActivateRequest 用户申请激活服务
49 | type ApplyForActivateRequest struct {
50 | Message string `form:"message" json:"message"`
51 | }
52 |
53 | // UserResetRequest 密码重设服务
54 | type UserResetRequest struct {
55 | Password string `form:"password" json:"password" binding:"required,min=4,max=64"`
56 | NewPassword string `form:"newPassword" json:"newPassword" binding:"required,min=4,max=64"`
57 | }
58 |
59 | // UserSetCurrentRepo 设置当前仓库
60 | type UserSetCurrentRepo struct {
61 | CurrentRepo string `form:"currentRepo" json:"currentRepo" binding:"required"`
62 | }
63 |
64 | // Register 用户注册
65 | func (service *UserRegisterRequest) Register(c *gin.Context) serializer.Response {
66 | if service.InvitationCode != InvitationCode {
67 | return serializer.CreateParamErrorResponse(errors.New("invitation code incorrect"))
68 | }
69 | user := model.User{
70 | Email: service.UserName,
71 | NickName: service.NickName,
72 | }
73 | _ = user.SetPassword(service.Password)
74 | user.Status = model.STATUS_NOT_ACTIVE
75 |
76 | if err := model.CreateUser(&user); err != nil {
77 | return serializer.CreateDBErrorResponse("", err)
78 | }
79 |
80 | return serializer.CreateSuccessResponse("", "注册成功")
81 | }
82 |
83 | // Login 用户登录
84 | func (service *UserLoginRequest) Login(c *gin.Context) serializer.Response {
85 | user, err := model.GetUserByEmail(service.UserName)
86 | if err != nil {
87 | return serializer.CreateErrorResponse(401, "用户邮箱或密码错误", err)
88 | }
89 |
90 | if passwordCorrect, err := user.CheckPassword(service.Password); !passwordCorrect {
91 | return serializer.CreateErrorResponse(serializer.CodeUnauthorized, "用户邮箱或密码错误", err)
92 | }
93 |
94 | if user.Status == model.STATUS_BANNED {
95 | return serializer.CreateErrorResponse(serializer.CodeForbidden, "该用户已被封禁", err)
96 | }
97 |
98 | session.SetSession(c, map[string]interface{}{
99 | "user_id": user.ID,
100 | })
101 |
102 | return serializer.CreateUserResponse(&user)
103 | }
104 |
105 | // Logout 用户登出
106 | func (service *UserLogoutRequest) Logout(c *gin.Context) serializer.Response {
107 | session.DeleteSession(c, "user_id")
108 | return serializer.CreateSuccessResponse("", "登出成功")
109 | }
110 |
111 | // ResetPassword 重置用户密码
112 | func (service *UserResetRequest) ResetPassword(c *gin.Context, user *model.User) serializer.Response {
113 | // 验证旧密码
114 | if passwordCorrect, err := user.CheckPassword(service.Password); !passwordCorrect {
115 | return serializer.CreateErrorResponse(serializer.CodeUnauthorized, "当前用户密码错误,无法重置密码", err)
116 | }
117 | // 设置新密码
118 | if err := user.SetPassword(service.NewPassword); err != nil {
119 | return serializer.CreateErrorResponse(200, "重置密码失败", err)
120 | }
121 |
122 | if err := model.UpdateUser(user.ID, map[string]interface{}{"password": user.Password}); err != nil {
123 | return serializer.CreateDBErrorResponse("重置密码失败", err)
124 | }
125 |
126 | return serializer.CreateSuccessResponse("", "重置密码成功")
127 | }
128 |
129 | // ApplyForActivate 用户向管理员申请激活
130 | func (service *ApplyForActivateRequest) ApplyForActivate(c *gin.Context, user *model.User) serializer.Response {
131 | err := model.UpdateUser(user.ID, map[string]interface{}{"status": model.STATUS_APPLYING, "apply_message": service.Message})
132 | if err != nil {
133 | return serializer.CreateDBErrorResponse("用户申请激活权限失败", err)
134 | }
135 |
136 | return serializer.CreateSuccessResponse("", "已向管理员申请激活,请耐心等待")
137 | }
138 |
139 | // GetApplications 管理员获取用户申请列表
140 | func (service *GetApplicationsRequest) GetApplications() serializer.Response {
141 | applyInfos, err := model.GetApplyingUserInfo()
142 | if err != nil {
143 | return serializer.CreateDBErrorResponse("获取用户激活申请列表失败", err)
144 | }
145 |
146 | return serializer.CreateSuccessResponse(applyInfos, "")
147 | }
148 |
149 | // ActivateUser 管理员激活用户:授予指定仓库访问权限
150 | func (service *ActivateUserRequest) ActivateUser() serializer.Response {
151 | user, err := model.GetUserByID(service.UserID)
152 | if err != nil {
153 | return serializer.CreateDBErrorResponse("获取用户失败", err)
154 | }
155 |
156 | if user.Status != model.STATUS_APPLYING {
157 | return serializer.CreateErrorResponse(serializer.CodeConditionNotMeet, "激活用户权限失败:该用户非申请状态", nil)
158 | }
159 |
160 | err = model.UpdateUserAllowedRepos(user.ID, service.Repos)
161 |
162 | if err != nil {
163 | return serializer.CreateDBErrorResponse("激活用户权限失败", err)
164 | }
165 |
166 | return serializer.CreateSuccessResponse("", "激活用户权限成功")
167 | }
168 |
169 | // RejectUser 管理员拒绝用户申请:取消申请状态
170 | func (service *RejectUserRequest) RejectUser() serializer.Response {
171 | user, err := model.GetUserByID(service.UserID)
172 | if err != nil {
173 | return serializer.CreateDBErrorResponse("获取用户失败", err)
174 | }
175 |
176 | if user.Status != model.STATUS_APPLYING {
177 | return serializer.CreateErrorResponse(serializer.CodeConditionNotMeet, "拒绝用户失败:该用户非申请状态", nil)
178 | }
179 |
180 | err = model.UpdateUser(user.ID, map[string]interface{}{"status": model.STATUS_NOT_ACTIVE, "apply_message": ""})
181 |
182 | if err != nil {
183 | return serializer.CreateDBErrorResponse("拒绝用户失败", err)
184 | }
185 |
186 | return serializer.CreateSuccessResponse("", "拒绝用户成功")
187 | }
188 |
189 | // BanUser 管理员封禁用户
190 | func (service *BanUserRequest) BanUser() serializer.Response {
191 | user, err := model.GetUserByID(service.UserID)
192 | if err != nil {
193 | return serializer.CreateDBErrorResponse("获取用户失败", err)
194 | }
195 |
196 | err = model.UpdateUser(user.ID, map[string]interface{}{"status": model.STATUS_BANNED, "apply_message": ""})
197 |
198 | if err != nil {
199 | return serializer.CreateDBErrorResponse("封禁用户失败", err)
200 | }
201 |
202 | return serializer.CreateSuccessResponse("", "封禁用户成功")
203 | }
204 |
205 | func (service *UserSetCurrentRepo) SetCurrentRepo(userID uint64) serializer.Response {
206 | err := model.SetCurrentRepo(userID, service.CurrentRepo)
207 | if err != nil {
208 | return serializer.CreateInternalErrorResponse("设置当前仓库失败", err)
209 | }
210 |
211 | return serializer.CreateSuccessResponse("", "设置当前仓库成功")
212 |
213 | }
214 |
215 | func GetCurrentRepo(userID uint64) serializer.Response {
216 | repo, err := model.GetCurrentRepo(userID)
217 | if err != nil {
218 | return serializer.CreateInternalErrorResponse("获取当前仓库失败", err)
219 | }
220 |
221 | return serializer.CreateSuccessResponse(repo, "获取当前仓库成功")
222 | }
223 |
--------------------------------------------------------------------------------