├── .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 | ![](./images/login.jpg) 19 | 20 | ![](./images/home.jpg) 21 | 22 | ![](./images/repo.jpg) 23 | 24 | ![](./images/search.jpg) 25 | 26 | ![](./images/article.jpg) 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 |
119 | setEmail(e.target.value)} 130 | autoFocus 131 | /> 132 | setPassword(e.target.value)} 143 | autoComplete="current-password" 144 | /> 145 | {/* } 147 | label="记住我" 148 | /> */} 149 | 160 | 161 | 162 | 163 | 忘记账号 164 | 165 | 166 | 167 | 168 | {"没有账号?来注册吧"} 169 | 170 | 171 | 172 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | --------------------------------------------------------------------------------