diff --git a/.circleci/conditional_config.yml b/.circleci/conditional_config.yml
new file mode 100644
index 000000000..bc8d35eee
--- /dev/null
+++ b/.circleci/conditional_config.yml
@@ -0,0 +1,105 @@
+version: 2.1
+
+orbs:
+ node: circleci/node@5.0.3
+
+executors:
+ base:
+ docker:
+ - image: cimg/base:stable
+ node:
+ docker:
+ - image: 'cimg/node:16.15.1'
+ go:
+ docker:
+ - image: cimg/go:1.19
+
+parameters:
+ trigger-app:
+ type: boolean
+ default: false
+ trigger-ui:
+ type: boolean
+ default: false
+ run-them-all:
+ type: boolean
+ default: false
+
+jobs:
+ ## Backend
+ test_app:
+ executor: go
+ working_directory: ~/go/src/github.com/ArtalkJS/Artalk
+ environment:
+ GO111MODULE: "on"
+ steps:
+ - checkout
+ - run:
+ name: "Print the Go version"
+ command: >
+ go version
+ - restore_cache:
+ keys:
+ - go-mod-1.19-{{ checksum "go.sum" }}
+ - run:
+ name: Install Dependencies
+ command: go mod download
+ - save_cache:
+ key: go-mod-1.19-{{ checksum "go.sum" }}
+ paths:
+ - "~/go/pkg/mod"
+ - run:
+ name: Run tests
+ command: |
+ mkdir -p /tmp/test-reports
+ gotestsum --junitfile /tmp/test-reports/unit-tests.xml
+ - store_test_results:
+ path: /tmp/test-reports
+
+ ## Frontend
+ test_ui:
+ executor: node
+ steps:
+ - node/install
+ - checkout
+ - run:
+ name: Print the node version
+ command: node -v
+ - run:
+ name: Install pnpm
+ command: npm i -g pnpm@7.25.0
+ - run:
+ name: Install Dependencies
+ command: pnpm --dir ./ui install --frozen-lockfile
+ - run:
+ name: Build Artalk
+ command: pnpm --dir ./ui build:all
+
+ all_projects:
+ executor: base
+ steps:
+ - run:
+ command: |
+ echo "all"
+
+workflows:
+ build-app:
+ when:
+ or:
+ - << pipeline.parameters.trigger-app >>
+ - << pipeline.parameters.run-them-all >>
+ jobs:
+ - test_app
+
+ build-ui:
+ when:
+ or:
+ - << pipeline.parameters.trigger-ui >>
+ - << pipeline.parameters.run-them-all >>
+ jobs:
+ - test_ui
+
+ build-shared-other:
+ when: << pipeline.parameters.run-them-all >>
+ jobs:
+ - all_projects
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 826877099..0625d9bb0 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,30 +1,25 @@
+# https://circleci.com/docs/2.0/configuration-reference
version: 2.1
+
+# this allows you to use CircleCI's dynamic configuration
+# https://circleci.com/docs/2.0/dynamic-config/
+setup: true
+
+# the path-filtering orb is required to continue a pipeline based on
+# https://circleci.com/developer/orbs/orb/circleci/path-filtering
orbs:
- node: circleci/node@5.0.3
-jobs:
- build:
- docker:
- - image: 'cimg/node:16.15.1'
- steps:
- - node/install
- - checkout
- - run:
- name: Print the node version
- command: node -v
- - run:
- name: Install pnpm
- command: npm i -g pnpm@7.11.0
- - run:
- name: Install Dependencies
- command: pnpm install --frozen-lockfile
- - run:
- name: Build Artalk
- command: pnpm build:all
+ path-filtering: circleci/path-filtering@0.0.1
workflows:
- tests:
+ setup:
jobs:
- - build:
+ - path-filtering/filter:
+ mapping: |
+ (cmd|internal|server|pkg|test)/.* trigger-app true
+ main.go|go.mod|go.sum|artalk.example.yml trigger-app true
+ (ui)/.* trigger-ui true
+ base-revision: master # git branch name
+ config-path: .circleci/conditional_config.yml
filters:
branches:
ignore:
diff --git a/.editorconfig b/.editorconfig
index 9ea64ee86..f268ed3b5 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -13,8 +13,19 @@ indent_style = space
indent_size = 2
[*.md]
+indent_size = 2
trim_trailing_whitespace = false
[*.yml]
indent_style = space
indent_size = 2
+
+[{Makefile,go.mod,go.sum,*.go,.gitmodules}]
+indent_style = tab
+indent_size = 4
+
+[*.sh]
+indent_size = 4
+
+[Dockerfile]
+indent_size = 4
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..e0871f93f
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "gomod" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 22ad8bc0a..6c65c1a95 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -32,7 +32,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- language: [ 'javascript' ]
+ language: [ 'go', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
@@ -48,11 +48,11 @@ jobs:
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
-
+
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
-
+
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
@@ -61,7 +61,7 @@ jobs:
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- # If the Autobuild fails above, remove it and uncomment the following three lines.
+ # If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 000000000..c789ed074
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,84 @@
+name: CI to Docker Hub
+
+on:
+ push:
+ # branches:
+ # - "master"
+ tags:
+ - "v*"
+ # pull_request:
+ # branches:
+ # - "master"
+
+jobs:
+ docker:
+ if: github.repository == 'ArtalkJS/Artalk'
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@master
+ - run: |
+ git fetch --prune --unshallow --tags -f
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v1
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@master
+
+ # Docker tag 值生成
+ # https://github.com/docker/metadata-action
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v3
+ with:
+ images: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/artalk
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha
+
+ # 编译多平台
+ # https://github.com/docker/setup-qemu-action
+ - name: Set up QEMU
+ id: qemu
+ uses: docker/setup-qemu-action@v2
+ with:
+ platforms: 'amd64,arm64' # 'all'
+
+ # 缓存 Docker
+ - name: Cache Docker layers
+ uses: actions/cache@v2
+ with:
+ path: /tmp/.buildx-cache
+ key: ${{ runner.os }}-buildx-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-buildx-
+
+ # build docker image
+ # https://github.com/docker/build-push-action
+ - name: Build and push
+ id: docker_build
+ uses: docker/build-push-action@v2
+ with:
+ builder: ${{ steps.buildx.outputs.name }}
+ context: ./
+ file: ./Dockerfile
+ platforms: 'linux/amd64,linux/arm64' # ',linux/arm/v7'
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ # 缓存
+ cache-from: type=local,src=/tmp/.buildx-cache
+ cache-to: type=local,dest=/tmp/.buildx-cache
+
+ - name: Image digest
+ run: echo ${{ steps.docker_build.outputs.digest }}
diff --git a/.github/workflows/docs-cn.yml b/.github/workflows/docs-cn.yml
new file mode 100644
index 000000000..bf9096821
--- /dev/null
+++ b/.github/workflows/docs-cn.yml
@@ -0,0 +1,49 @@
+name: China Mirror Deploy
+
+on:
+ push:
+ branches:
+ - "master"
+
+jobs:
+ deploy:
+ if: github.repository == 'ArtalkJS/Artalk'
+
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: ./docs # set for building docs
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Use pnpm
+ uses: pnpm/action-setup@v2.2.2
+ with:
+ version: 7.2.1
+
+ - name: Set node version to 16.x
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16.x
+ registry-url: https://registry.npmjs.org/
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ run: pnpm docs:build
+
+ - name: Deploy
+ uses: TencentCloud/cos-action@v1
+ with:
+ secret_id: ${{ secrets.TENCENT_CLOUD_SECRET_ID }}
+ secret_key: ${{ secrets.TENCENT_CLOUD_SECRET_KEY }}
+ cos_bucket: ${{ secrets.DOCS_CN_COS_BUCKET }}
+ cos_region: ${{ secrets.DOCS_CN_COS_REGION }}
+ local_path: .vitepress/dist/
+ remote_path: /
+ clean: true
diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml
index 89fa2ddc6..9a246570c 100644
--- a/.github/workflows/npm.yml
+++ b/.github/workflows/npm.yml
@@ -10,7 +10,13 @@ jobs:
publish:
# prevents this action from running on forks
if: github.repository == 'ArtalkJS/Artalk'
+
runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: ./ui # set for building UI
+
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -18,7 +24,7 @@ jobs:
- name: Use pnpm
uses: pnpm/action-setup@v2.2.4
with:
- version: 7.11.0
+ version: 7.25.0
- name: Set node version to 16.x
uses: actions/setup-node@v3
@@ -34,7 +40,7 @@ jobs:
run: pnpm build
- name: Publish
- run: cd packages/artalk && pnpm publish --no-git-checks
+ run: pnpm publish -F artalk --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
deleted file mode 100644
index 19cdf7e4f..000000000
--- a/.github/workflows/pages.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-name: GitHub Pages
-
-on:
- push:
- branches:
- - master
- workflow_dispatch:
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Use pnpm
- uses: pnpm/action-setup@v2.2.4
- with:
- version: 7.11.0
-
- - name: Set node version to 16.x
- uses: actions/setup-node@v3
- with:
- node-version: 16.x
- registry-url: https://registry.npmjs.org/
- cache: 'pnpm'
-
- - name: Install dependencies
- run: pnpm install
-
- - name: Build
- run: cd packages/artalk && pnpm predeploy
-
- - name: Deploy
- uses: peaceiris/actions-gh-pages@v3
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_branch: gh-pages
- publish_dir: ./packages/artalk/deploy
- user_name: 'github-actions[bot]'
- user_email: 'github-actions[bot]@users.noreply.github.com'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..70273edac
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,50 @@
+name: Release Build
+
+on:
+ push:
+ tags:
+ - v*
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: checkout code
+ uses: actions/checkout@v3
+ - run: git fetch --prune --unshallow --tags -f
+
+ - name: setup go
+ uses: actions/setup-go@v3
+ with:
+ go-version: '^1.19.4'
+
+ # docker `golang-cross` image cache
+ # `cache-go-cross-v1-19-4`
+ - run: mkdir -p ~/docker-cache
+ - name: docker image cache
+ id: cache-go-cross-v1-19-4
+ uses: actions/cache@v2
+ with:
+ path: ~/docker-cache
+ # Adjust key to meet your cache time requirements e.g.
+ # ${{ hashFiles(*) }} can be useful here to invalidate
+ # cache on file changes
+ key: cache-go-cross-v1-19-4
+
+ - if: steps.cache-go-cross-v1-19-4.outputs.cache-hit != 'true'
+ run: |
+ docker pull ghcr.io/goreleaser/goreleaser-cross:v1.19.4
+ docker save -o ~/docker-cache/golang-cross.tar ghcr.io/goreleaser/goreleaser-cross:v1.19.4
+
+ - if: steps.cache-go-cross-v1-19-4.outputs.cache-hit == 'true'
+ run: docker load -i ~/docker-cache/golang-cross.tar
+
+ # build
+ - name: setup release environment
+ run: |-
+ cp artalk.example.yml artalk.yml
+ echo 'GITHUB_TOKEN=${{secrets.GORELEASER_ACCESS_TOKEN}}' > .release-env
+
+ - name: build and release publish
+ run: make release
diff --git a/.gitignore b/.gitignore
index 83780e9ab..aa3e295cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,45 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Generated files
.DS_Store
-node_modules/
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+.heartbeat
+.idea
+*~
+.goutputstream*
+.c9revisions
+.settings
+.swp
+.tmp
+.env
+.vscode/*.log
+.release-env
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+.pnpm-store
+/sysroot
+.history
# Logs
logs
@@ -9,15 +49,21 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
-# Editor directories and files
-.idea
-.vscode
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-.history
+# frontend
+node_modules
+/ui/**/dist
+
+# docs
+/docs/.vitepress/dist/
+
+# backend
+/artalk.yml
+/config.yml
+
+/bin
+/data
+/local
-dist
-deploy
-local
+# always compile latest version
+/public/sidebar
+/public/dist
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 000000000..32b0eaf3b
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,155 @@
+# Goreleaser Config
+#
+# https://goreleaser.com/quick-start/
+# https://goreleaser.com/customization/
+# https://goreleaser.com/customization/templates/
+# https://goreleaser.com/customization/fury/
+# https://goreleaser.com/customization/announce/telegram/
+
+project_name: artalk
+
+env:
+ - GO111MODULE=on
+ - CGO_ENABLED=1
+
+before:
+ hooks:
+ # install dependencies
+ - make install
+ - make build-frontend
+ - make update
+
+# build multi-platform
+builds:
+ ## Amd 64
+
+ # Linux (amd_64)
+ - id: linux-amd64
+ goos:
+ - linux
+ goarch:
+ - amd64
+ env:
+ - CC=gcc
+ - CXX=g++
+ binary: "{{.ProjectName}}"
+ main: ./main.go
+ ldflags: &common_ldflags |
+ -X github.com/ArtalkJS/Artalk/internal/config.Version={{.Version}}
+ -X github.com/ArtalkJS/Artalk/internal/config.CommitHash={{ .ShortCommit }}
+ -s -w
+
+ # Windows (amd_64)
+ - id: windows-amd64
+ goos:
+ - windows
+ goarch:
+ - amd64
+ env:
+ - CC=x86_64-w64-mingw32-gcc
+ - CXX=x86_64-w64-mingw32-g++
+ binary: "{{.ProjectName}}"
+ main: ./main.go
+ ldflags: *common_ldflags
+ flags:
+ # https://go-review.googlesource.com/c/go/+/224588/
+ # https://github.com/ArtalkJS/Artalk/issues/35
+ - -tags=timetzdata
+
+ # Darwin (amd_64)
+ - id: darwin-amd64
+ goos:
+ - darwin
+ goarch:
+ - amd64
+ env:
+ - CC=o64-clang
+ - CXX=o64-clang++
+ binary: "{{.ProjectName}}"
+ main: ./main.go
+ ldflags: *common_ldflags
+
+ ## Arm
+
+ # Linux (arm_v7)
+ - id: linux-armhf
+ goos:
+ - linux
+ goarch:
+ - arm
+ goarm:
+ - '7'
+ env:
+ - CC=arm-linux-gnueabihf-gcc
+ - CXX=arm-linux-gnueabihf-g++
+ binary: "{{.ProjectName}}"
+ main: ./main.go
+ ldflags: *common_ldflags
+
+ # Linux (arm_64)
+ - id: linux-arm64
+ goos:
+ - linux
+ goarch:
+ - arm64
+ env:
+ - CC=aarch64-linux-gnu-gcc
+ - CXX=aarch64-linux-gnu-g++
+ binary: "{{.ProjectName}}"
+ main: ./main.go
+ ldflags: *common_ldflags
+
+ # Darwin (arm_64)
+ - id: darwin-arm64
+ goos:
+ - darwin
+ goarch:
+ - arm64
+ env:
+ - CC=oa64-clang
+ - CXX=oa64-clang++
+ binary: "{{.ProjectName}}"
+ main: ./main.go
+ ldflags: *common_ldflags
+
+archives:
+ - id: artalk
+ builds:
+ - linux-amd64
+ - darwin-amd64
+ - windows-amd64
+ - linux-armhf
+ - linux-arm64
+ - darwin-arm64
+ name_template: "{{.ProjectName}}_v{{.Version}}_{{.Os}}_{{.Arch}}{{.Arm}}"
+ format: tar.gz
+ format_overrides:
+ - goos: windows
+ format: zip
+ wrap_in_directory: true
+ files:
+ - README*
+ - LICENSE*
+ - artalk.yml
+
+checksum:
+ name_template: 'checksums.txt'
+
+snapshot:
+ name_template: "{{.Version}}-SNAPSHOT-{{.ShortCommit}}"
+
+# changelog
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - '^docs:'
+ - '^test:'
+ - '^chore:'
+
+release:
+ github:
+ owner: ArtalkJS
+ name: Artalk
+ prerelease: auto
+ draft: true
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 0125e1f82..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-language: node_js
-
-node_js:
- - lts/*
-
-script:
- - pnpm run lint
- - pnpm run build
-
-cache:
- npm: false
- directories:
- - "~/.pnpm-store"
-before_install:
- - curl -fsSL https://get.pnpm.io/install.sh | sh -
- - pnpm config set store-dir ~/.pnpm-store
-install:
- - pnpm install
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 000000000..5b65e6208
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,14 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Launch Artalk for Debugging",
+ "type": "go",
+ "request": "launch",
+ "mode": "exec",
+ "program": "${workspaceFolder}/bin/artalk",
+ "preLaunchTask": "Build for debugging",
+ "args": ["server", "-c", "${workspaceFolder}/local/local.yml"]
+ }
+ ],
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..12a96c6ed
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,19 @@
+{
+ "cSpell.words": [
+ "akismet",
+ "artalk",
+ "artran",
+ "artrans",
+ "artransfer",
+ "gomail",
+ "Goreleaser",
+ "gorm",
+ "ldflags",
+ "lfshook",
+ "Metas",
+ "pgsql",
+ "sendmail",
+ "sqlite",
+ "pkged"
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 000000000..0adfa5011
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,14 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Build for debugging",
+ "type": "shell",
+ "command": "make debug-build",
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/BACKEND.md b/BACKEND.md
new file mode 100644
index 000000000..1921921fa
--- /dev/null
+++ b/BACKEND.md
@@ -0,0 +1,75 @@
+
+
+
+
+# Artalk
+
+[](https://circleci.com/gh/ArtalkJS/Artalk/tree/master)
+[](https://github.com/ArtalkJS/Artalk/actions/workflows/dockerhub.yml) [](https://app.fossa.com/projects/git%2Bgithub.com%2FArtalkJS%2FArtalk?ref=badge_shield)
+[](https://hub.docker.com/r/artalk/artalk)
+
+> Artalk: Golang backend of Artalk.
+
+前往:[“**官方文档 · 后端部分**”](https://artalk.js.org/guide/backend/config.html)
+
+---
+
+- 高效快速
+- 异步执行
+- 跨平台兼容
+- 轻量级部署
+
+## Supports
+
+- 运行环境:支持 Linux, Windows, Darwin (x64 + ARM)
+- 数据存储:支持 SQLite, MySQL, PostgreSQL, SQL Server
+- 邮件发送:支持 SMTP, 阿里云邮件, 调用 sendmail 发送邮件
+- 高效缓存:支持 Redis, Memcache, In-Memory (BigCache)
+
+## Build
+
+> 部署可以使用官方提供的 Docker 镜像,见:[后端部署文档](https://artalk.js.org/guide/backend/install.html)
+
+### 编译二进制文件
+
+```sh
+$ make all
+```
+
+编译后二进制文件将输出到 `bin/` 目录下
+
+### Docker Compose 编译运行
+
+```sh
+# 克隆项目
+$ git clone https://github.com/ArtalkJS/Artalk
+$ cd Artalk
+
+# 构建镜像
+$ docker compose build
+
+# 运行
+$ docker compose up -d
+```
+
+### Docker 镜像构建
+
+```sh
+# 克隆项目
+$ git clone https://github.com/ArtalkJS/Artalk
+$ cd Artalk
+
+# 构建镜像
+$ make docker-build
+
+# 发布镜像
+$ make docker-push
+```
+
+## TODOs
+
+Reference to https://github.com/ArtalkJS/Artalk#todos
+
+## License
+
+[](https://app.fossa.com/projects/git%2Bgithub.com%2FArtalkJS%2FArtalk?ref=badge_large)
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..c4ac9b190
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,131 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at artalkjs@gmail.com.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..ab352443c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,83 @@
+# Developer Contributing Guide
+
+This guide is for developers who want to contribute to the project.
+
+## Development Environment
+
+To develop Artalk, including the frontend and backend, you need to install the following tools:
+
+- [Node.js](https://nodejs.org/en/) (>= 16.0.0)
+- [Go](https://golang.org/) (>= 1.19)
+- [Docker](https://www.docker.com/) (>= 20.10.0) (optional)
+- [Docker Compose](https://docs.docker.com/compose/) (>= 1.29.0) (optional)
+
+Then we'll setup the development environment.
+
+### Get the source code
+
+Run the following command to clone the repository:
+
+```sh
+git clone https://github.com/ArtalkJS/Artalk
+```
+
+It is recommended to fork the repository first, and then clone the forked repository.
+
+Enter the directory:
+
+```sh
+cd Artalk
+```
+
+### Build frontend and backend
+
+First, we need to install the dependencies for backend written in Go. Simply run the following command:
+
+```sh
+make debug-build
+```
+
+This will build both the frontend and backend, with debugging symbols.
+
+- **Frontend** will be built under `./ui/packages/artalk` and copied to `./public` directory.
+- **Backend** will be built under `./bin` directory.
+
+### Optional: Use one-key script to run a demo site
+
+If you want to run a demo site, you can use the following command:
+
+```sh
+./scripts/setup-example-site.sh
+```
+
+This script sets up a local example site for testing at `local` folder, with Artalk integrated into its theme.
+
+After running this script, run:
+
+```sh
+./bin/artalk server -c ./local/local.yml
+```
+
+to start the artalk server.
+
+And open in your browser to view the example site.
+
+Here is the default admin account (only created in test mode):
+
+```yaml
+name: "admin"
+email: "admin@test.com"
+password: "admin"
+```
+
+## Translation
+
+Artalk aims to be a multilingual project. If you would like to contribute to the translation, here are some tips:
+
+If you just write some new features or do some fixes/refactoring, use the following command to generate the translation template
+
+```sh
+go run ./internal/i18n/gen -w . -d .
+```
+
+If you're not a programmer and would like to help us improve the translation, you can edit the translation files directly in the `.i18n' directory and then submit a pull request.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..3fa347d27
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,50 @@
+### build Artalk
+FROM golang:1.19.4-alpine3.17 as builder
+
+WORKDIR /source
+
+# install tools
+RUN set -ex \
+ && apk add --no-cache make git gcc musl-dev nodejs bash npm\
+ && npm install -g pnpm@7.25.0
+
+COPY . ./Artalk
+
+# build
+RUN set -ex \
+ && cd ./Artalk \
+ && export VERSION=$(git describe --tags --abbrev=0) \
+ && export COMMIT_SHA=$(git rev-parse --short HEAD) \
+ && make all
+
+### build final image
+FROM alpine:3.15
+
+# we set the timezone `Asia/Shanghai` by default, you can be modified
+# by `docker build --build-arg="TZ=Other_Timezone ..."`
+ARG TZ="Asia/Shanghai"
+
+ENV TZ ${TZ}
+
+COPY --from=builder /source/Artalk/bin/artalk /artalk
+
+RUN apk add --no-cache bash tzdata \
+ && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
+ && echo ${TZ} > /etc/timezone
+
+# move runner script to `/usr/bin/` and create alias
+COPY scripts/docker-artalk-runner.sh /usr/bin/artalk
+RUN chmod +x /usr/bin/artalk \
+ && ln -s /usr/bin/artalk /usr/bin/artalk-go
+
+VOLUME ["/data"]
+
+COPY docker-entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
+
+# expose Artalk default port
+EXPOSE 23366
+
+CMD ["server", "--host", "0.0.0.0", "--port", "23366"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..7a3cd8f49
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,97 @@
+PACKAGE_NAME := github.com/ArtalkJS/Artalk
+BIN_NAME := ./bin/artalk
+VERSION ?= $(shell git describe --tags --abbrev=0)
+COMMIT_HASH := $(shell git rev-parse --short HEAD)
+DEV_VERSION := dev-${COMMIT_HASH}
+GO_VERSION ?= 1.19.4
+
+HAS_RICHGO := $(shell which richgo)
+GOTEST ?= $(if $(HAS_RICHGO), richgo test, go test)
+
+all: install build
+
+install:
+ go mod tidy
+
+build: build-frontend
+ go build \
+ -ldflags "-s -w -X github.com/ArtalkJS/Artalk/internal/config.Version=${VERSION} \
+ -X github.com/ArtalkJS/Artalk/internal/config.CommitHash=${COMMIT_HASH}" \
+ -o $(BIN_NAME) \
+ github.com/ArtalkJS/Artalk
+
+build-frontend:
+ ./scripts/build-frontend.sh
+
+run: all
+ $(BIN_NAME) server $(ARGS)
+
+debug-build:
+ @if [ ! -f "pkged/pkged.go" ]; then \
+ make install; \
+ fi
+ @echo "Building Artalk ${VERSION} for debugging..."
+ @go build \
+ -ldflags " \
+ -X github.com/ArtalkJS/Artalk/internal/config.Version=${VERSION} \
+ -X github.com/ArtalkJS/Artalk/internal/config.CommitHash=${COMMIT_HASH}" \
+ -gcflags "all=-N -l" \
+ -o $(BIN_NAME) \
+ github.com/ArtalkJS/Artalk
+
+dev: debug-build
+ $(BIN_NAME) server $(ARGS)
+
+test:
+ $(GOTEST) -timeout 20m ./internal/...
+
+test-coverage:
+ $(GOTEST) -cover ./...
+
+update-i18n:
+ go generate ./internal/i18n
+
+docker-build:
+ ./scripts/docker-build.sh
+
+docker-push:
+ ./scripts/docker-build.sh --push
+
+release-dry-run:
+ @docker run \
+ --rm \
+ --privileged \
+ -e CGO_ENABLED=1 \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v `pwd`:/go/src/$(PACKAGE_NAME) \
+ -v `pwd`/sysroot:/sysroot \
+ -w /go/src/$(PACKAGE_NAME) \
+ ghcr.io/goreleaser/goreleaser-cross:v${GO_VERSION} \
+ --rm-dist --skip-validate --skip-publish
+
+
+# https://hub.docker.com/r/troian/golang-cross
+# https://github.com/troian/golang-cross
+# https://goreleaser.com/cmd/goreleaser_release/
+# --skip-validate 参数跳过 git checks (由于 pkger 和 .release-env 文件生成)
+release:
+ @if [ ! -f ".release-env" ]; then \
+ echo "\033[91m.release-env is required for release\033[0m";\
+ exit 1;\
+ fi
+ docker run \
+ --rm \
+ --privileged \
+ -e CGO_ENABLED=1 \
+ --env-file .release-env \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v `pwd`:/go/src/$(PACKAGE_NAME) \
+ -v `pwd`/sysroot:/sysroot \
+ -w /go/src/$(PACKAGE_NAME) \
+ ghcr.io/goreleaser/goreleaser-cross:v${GO_VERSION} \
+ release --rm-dist --skip-validate
+
+.PHONY: all install build debug-build build-frontend \
+ run dev test test-coverage \
+ docker-build docker-push \
+ release-dry-run release;
diff --git a/README.en.md b/README.en.md
index 0f556d658..b281055e3 100644
--- a/README.en.md
+++ b/README.en.md
@@ -11,7 +11,7 @@
> 🌌 A Self-hosted comment system
-[简体中文](./README.md) / [Documentation](https://artalk.js.org) / [Releases](https://github.com/ArtalkJS/ArtalkGo/releases) / [ArtalkGo](https://github.com/ArtalkJS/ArtalkGo)
+[简体中文](./README.md) / [Documentation](https://artalk.js.org) / [Releases](https://github.com/ArtalkJS/Artalk/releases) / [Artalk](https://github.com/ArtalkJS/Artalk)
---
@@ -82,14 +82,14 @@ mkdir Artalk
cd Artalk
# Download config template
-curl -L https://raw.githubusercontent.com/ArtalkJS/ArtalkGo/master/artalk-go.example.yml > conf.yml
+curl -L https://raw.githubusercontent.com/ArtalkJS/Artalk/master/artalk.example.yml > conf.yml
docker run -d \
--name artalk \
-p 0.0.0.0:8080:23366 \
-v $(pwd)/conf.yml:/conf.yml \
-v $(pwd)/data:/data \
- artalk/artalk-go
+ artalk/artalk
```
### Docker Compose
@@ -106,7 +106,7 @@ version: "3.5"
services:
artalk:
container_name: artalk
- image: artalk/artalk-go
+ image: artalk/artalk
ports:
- 8080:23366
volumes:
diff --git a/README.md b/README.md
index 70339dfd7..a5d6cda59 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
> 🌌 Golang 自托管评论系统
-[English](./README.en.md) / [官方文档](https://artalk.js.org) / [最新后端](https://github.com/ArtalkJS/ArtalkGo/releases) / [ArtalkGo](https://github.com/ArtalkJS/ArtalkGo)
+[English](./README.en.md) / [官方文档](https://artalk.js.org) / [最新版本](https://github.com/ArtalkJS/Artalk/releases)
---
@@ -68,10 +68,10 @@ new Artalk({
```bash
docker run -d \
- --name artalk-go \
+ --name artalk \
-p 8080:23366 \
-v $(pwd)/data:/data \
- artalk/artalk-go
+ artalk/artalk
```
### Docker Compose
@@ -85,7 +85,7 @@ version: "3.5"
services:
artalk:
container_name: artalk
- image: artalk/artalk-go
+ image: artalk/artalk
ports:
- 8080:23366
volumes:
@@ -114,7 +114,7 @@ docker-compose up -d
## TODOs
-- [x] [Golang 后端](https://github.com/ArtalkJS/ArtalkGo)
+- [x] [Golang 后端](https://github.com/ArtalkJS/Artalk)
- [x] 多数据库支持
- [x] SQLite
- [x] MySQL
diff --git a/artalk.example.simple.yml b/artalk.example.simple.yml
new file mode 100644
index 000000000..1c04d662c
--- /dev/null
+++ b/artalk.example.simple.yml
@@ -0,0 +1,155 @@
+host: "0.0.0.0"
+port: 23366
+app_key: ""
+debug: false
+locale: "en"
+timezone: "Asia/Shanghai"
+site_default: "Default Site"
+login_timeout: 259200
+db:
+ type: "sqlite"
+ file: "./data/artalk.db"
+ table_prefix: ""
+ name: "artalk"
+ host: "localhost"
+ port: 3306
+ user: "root"
+ password: ""
+ charset: "utf8mb4"
+log:
+ enabled: true
+ filename: "./data/artalk.log"
+cache:
+ type: "builtin"
+ expires: 30
+ warm_up: false
+ server: ""
+ redis:
+ network: "tcp"
+ username: ""
+ password: ""
+ db: 0
+trusted_domains: []
+ssl:
+ enabled: false
+ cert_path: ""
+ key_path: ""
+admin_users:
+moderator:
+ pending_default: false
+ api_fail_block: false
+ akismet_key: ""
+ tencent:
+ enabled: false
+ secret_id: ""
+ secret_key: ""
+ region: "ap-guangzhou"
+ aliyun:
+ enabled: false
+ access_key_id: ""
+ access_key_secret: ""
+ region: "cn-shanghai"
+ keywords:
+ enabled: false
+ pending: false
+ files:
+ file_sep: "\n"
+ replac_to: "x"
+captcha:
+ enabled: true
+ always: false
+ action_limit: 3
+ action_reset: 60
+ geetest:
+ enabled: false
+ captcha_id: ""
+ captcha_key: ""
+email:
+ enabled: false
+ send_type: "smtp"
+ send_name: "{{reply_nick}}"
+ send_addr: "noreply@example.com"
+ mail_tpl: "default"
+ smtp:
+ host: "smtp.qq.com"
+ port: 587
+ username: "example@qq.com"
+ password: ""
+ ali_dm:
+ access_key_id: ""
+ access_key_secret: ""
+ account_name: "noreply@example.com"
+img_upload:
+ enabled: true
+ path: "./data/artalk-img/"
+ max_size: 5
+ public_path: null
+ upgit:
+ enabled: false
+ exec: "./upgit -c UPGIT_CONF_FILE_PATH -t /artalk-img"
+ del_local: true
+admin_notify:
+ notify_tpl: "default"
+ noise_mode: false
+ email:
+ enabled: true
+ mail_subject: '[{{site_name}}] Post "{{page_title}}" has new a comment'
+ telegram:
+ enabled: false
+ api_token: ""
+ receivers:
+ - 7777777
+ bark:
+ enabled: false
+ server: "http://day.app/xxxxxxx/"
+ lark:
+ enabled: false
+ webhook_url: ""
+ webhook:
+ enabled: false
+ url: ""
+ ding_talk:
+ enabled: false
+ token: ""
+ secret: ""
+ slack:
+ enabled: false
+ oauth_token: ""
+ receivers:
+ - "CHANNEL_ID"
+ line:
+ enabled: false
+ channel_secret: ""
+ channel_access_token: ""
+ receivers:
+ - "USER_ID_1"
+ - "GROUP_ID_1"
+frontend:
+ placeholder: "What do you think?"
+ noComment: "No comments yet."
+ sendBtn: "Send"
+ editorTravel: true
+ darkMode: false
+ emoticons: "https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json"
+ vote: true
+ voteDown: false
+ uaBadge: true
+ listSort: true
+ pvEl: "#ArtalkPV"
+ countEl: "#ArtalkCount"
+ preview: true
+ flatMode: "auto"
+ nestMax: 2
+ nestSort: DATE_ASC
+ gravatar:
+ mirror: "https://cravatar.cn/avatar/"
+ default: "mp"
+ pagination:
+ pageSize: 20
+ readMore: true
+ autoLoad: true
+ heightLimit:
+ content: 300
+ children: 400
+ reqTimeout: 15000
+ versionCheck: true
diff --git a/artalk.example.yml b/artalk.example.yml
new file mode 100644
index 000000000..43a7111ee
--- /dev/null
+++ b/artalk.example.yml
@@ -0,0 +1,281 @@
+# Listen host
+host: "0.0.0.0"
+# Listen port
+port: 23366
+# Key for generation of JWT token
+app_key: ""
+# Debug mode
+debug: false
+# Language (follow Unicode BCP 47)
+locale: "en"
+# Timezone (see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
+timezone: "Asia/Shanghai"
+# Default site name
+site_default: "Default Site"
+# Login timeout (in seconds)
+login_timeout: 259200
+# Database
+db:
+ # Database type ["sqlite", "mysql", "pgsql", "mssql"]
+ type: "sqlite"
+ # Database file (only for SQLite)
+ file: "./data/artalk.db"
+ # Table prefix (e.g. "atk_")
+ table_prefix: ""
+ # The following is not nessary for SQLite
+ # Database name
+ name: "artalk"
+ # Host address
+ host: "localhost"
+ # Host port
+ port: 3306
+ # Database user
+ user: "root"
+ # Database password
+ password: ""
+ # Database charset
+ charset: "utf8mb4"
+
+# Logging
+log:
+ # Enable logging
+ enabled: true
+ # Log file path
+ filename: "./data/artalk.log"
+# Cache
+cache:
+ # Cache type ["redis", "memcache", "builtin"]
+ type: "builtin"
+ # Cache expiration time (in minutes)
+ expires: 30
+ # Cache warm up (warm up cache when program starts)
+ warm_up: false
+ # Cache server address (e.g. "localhost:6379")
+ server: ""
+ # Redis config
+ redis:
+ # Connection type ["tcp", "unix"]
+ network: "tcp"
+ # Redis username
+ username: ""
+ # Redis password
+ password: ""
+ # Redis database number (e.g. 0)
+ db: 0
+# Trusted domains, e.g. ["https://artalk.example.com:23366"] add url of your site her
+trusted_domains: []
+# SSL
+ssl:
+ # Enable SSL
+ enabled: false
+ # Certificate file path, e.g. "/etc/letsencrypt/live/example.com/fullchain.pem"
+ cert_path: ""
+ # Key file path, e.g. "/etc/letsencrypt/live/example.com/privkey.pem"
+ key_path: ""
+# Admin users
+admin_users:
+ # Remove the following line comment symbol (#) to enable admin users
+ # - name: "admin"
+ # email: "admin@example.com"
+ # badge_color: "#FF6C00"
+# Comment examination before being public
+moderator:
+ # Default pending (new comments need to be approved by admin)
+ pending_default: false
+ # Block when API request fails (set to false to let comments pass when API request fails)
+ api_fail_block: false
+ # Akismet Key
+ # (Akismet anti-spam service, https://akismet.com)
+ akismet_key: ""
+ # Auto review comments with Tencent Cloud Content Security
+ # (https://cloud.tencent.com/document/product/1124/64508)
+ tencent:
+ enabled: false
+ secret_id: ""
+ secret_key: ""
+ region: "ap-guangzhou"
+ # Auto review comments with Aliyun Content Security
+ # (https://help.aliyun.com/document_detail/28417.html)
+ aliyun:
+ enabled: false
+ access_key_id: ""
+ access_key_secret: ""
+ region: "cn-shanghai"
+ # Keyword filter (local offline dictionary)
+ keywords:
+ # Enable keyword filter
+ enabled: false
+ # Set to pending when match
+ pending: false
+ # Dictionary file (support multiple dictionary files)
+ files:
+ file_sep: "\n"
+ replac_to: "x"
+# Captcha
+captcha:
+ # Enable captcha
+ enabled: true
+ # Captcha is required always
+ always: false
+ # Captcha type ["geetest", "image"]
+ # The number of actions required to activate captcha
+ action_limit: 3
+ # Timeout to reset action counter (unit: s, set to -1 to disable)
+ action_reset: 60
+ # Geetest (https://www.geetest.com)
+ geetest:
+ enabled: false
+ captcha_id: ""
+ captcha_key: ""
+# Mail notification
+email:
+ # Enable mail notification
+ enabled: false
+ # Send method ["smtp", "ali_dm", "sendmail"]
+ send_type: "smtp"
+ # Nick name of sender
+ send_name: "{{reply_nick}}"
+ # Email address of sender
+ send_addr: "noreply@example.com"
+ # Mail title
+ # Mail template file (set to file path to use custom template)
+ mail_tpl: "default"
+ # SMTP send (set to "smtp" to enable)
+ smtp:
+ # Email address of sender
+ host: "smtp.qq.com"
+ # Email port
+ port: 587
+ # Email address of sender
+ username: "example@qq.com"
+ # Password
+ password: ""
+ # Aliyun mail push
+ # (set send method to "ali_dm" to enable; see: https://help.aliyun.com/document_detail/29444.html)
+ ali_dm:
+ access_key_id: ""
+ access_key_secret: ""
+ account_name: "noreply@example.com"
+# Image upload for comment
+img_upload:
+ # Enable image upload
+ enabled: true
+ # Image storage
+ path: "./data/artalk-img/"
+ # Image size limit (unit: MB)
+ max_size: 5
+ # Image link base path (default: "/static/images/")
+ public_path: null
+ # Upgit config
+ upgit:
+ # Enable Upgit
+ enabled: false
+ # Command line arguments
+ exec: "./upgit -c UPGIT_CONF_FILE_PATH -t /artalk-img"
+ # Delete local image after upload success
+ del_local: true
+# Multi-push
+admin_notify:
+ # Notification template (set to file path to use custom template)
+ notify_tpl: "default"
+ # Noise mode
+ # noise_mode is disabled by default.
+ # When this option is set to `false`, only messages sent to the administrator will be notified,
+ # such as "user A" replies to "user B", the communication between these two users will not be notified to the administrator.
+ noise_mode: false
+ # Notify admin
+ email:
+ # Enable (can be disabled when using other push methods)
+ enabled: true
+ # Mail title (mail title sent to admin)
+ mail_subject: '[{{site_name}}] Post "{{page_title}}" has new a comment'
+ # Telegram
+ telegram:
+ enabled: false
+ api_token: ""
+ receivers:
+ - 7777777
+ # Bark
+ bark:
+ enabled: false
+ server: "http://day.app/xxxxxxx/"
+ lark:
+ enabled: false
+ webhook_url: ""
+ # WebHook
+ webhook:
+ enabled: false
+ url: ""
+ ding_talk:
+ enabled: false
+ token: ""
+ secret: ""
+ # Slack
+ slack:
+ enabled: false
+ oauth_token: ""
+ receivers:
+ - "CHANNEL_ID"
+ # LINE
+ line:
+ enabled: false
+ channel_secret: ""
+ channel_access_token: ""
+ receivers:
+ - "USER_ID_1"
+ - "GROUP_ID_1"
+# Frontend config
+frontend:
+ # Comment box placeholder
+ placeholder: "What do you think?"
+ # Text to display when there is
+ noComment: "No comments yet."
+ # Text of the send button
+ sendBtn: "Send"
+ editorTravel: true
+ # Dark mode
+ darkMode: false
+ # Emoticons
+ emoticons: "https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json"
+ # Vote button
+ vote: true
+ # Dislike button
+ voteDown: false
+ # User UA badge
+ uaBadge: true
+ # Comment sorting
+ listSort: true
+ # Page PV binding element
+ pvEl: "#ArtalkPV"
+ # Comment count binding element
+ countEl: "#ArtalkCount"
+ # Editor real-time preview
+ preview: true
+ # Flatten mode ["auto", true, false]
+ flatMode: "auto"
+ # Maximum nesting level
+ nestMax: 2
+ # Nesting comment sorting rules ["DATE_ASC", "DATE_DESC", "VOTE_UP_DESC"]
+ nestSort: DATE_ASC
+ # Gravatar
+ gravatar:
+ mirror: "https://cravatar.cn/avatar/"
+ default: "mp"
+ # Comment pagination
+ pagination:
+ # Number of comments per page
+ pageSize: 20
+ # Load more mode (disabled to use pagination bar)
+ readMore: true
+ # Scroll loading
+ autoLoad: true
+ # Content height limit
+ heightLimit:
+ # Comment content height limit (unit: px)
+ content: 300
+ # Sub-comment area height limit (unit: px)
+ children: 400
+ # Request timeout (unit: ms)
+ reqTimeout: 15000
+ # Version check
+ versionCheck: true
diff --git a/artalk.example.zh-CN.yml b/artalk.example.zh-CN.yml
new file mode 100644
index 000000000..b685ad2ba
--- /dev/null
+++ b/artalk.example.zh-CN.yml
@@ -0,0 +1,304 @@
+# 服务器地址
+host: "0.0.0.0"
+
+# 服务器端口
+port: 23366
+
+# 加密密钥
+app_key: ""
+
+# 调试模式
+debug: false
+
+# 语言
+locale: "zh-CN"
+
+# 时间区域
+timezone: "Asia/Shanghai"
+
+# 默认站点名
+site_default: "默认站点"
+
+# 登陆有效时长 (单位:秒)
+login_timeout: 259200
+
+# 数据库
+db:
+ # 数据库类型 ["sqlite", "mysql", "pgsql", "mssql"]
+ type: "sqlite"
+ # 数据库文件 (仅 SQLite 数据库需填写)
+ file: "./data/artalk.db"
+ # 数据库名称
+ name: "artalk"
+ # 数据库地址
+ host: "localhost"
+ # 数据库端口
+ port: 3306
+ # 数据库账户
+ user: "root"
+ # 数据库密码
+ password: ""
+ # 编码格式
+ charset: "utf8mb4"
+ # 表前缀 (例如:"atk_")
+ table_prefix: ""
+
+# 日志
+log:
+ # 启用日志
+ enabled: true
+ # 日志文件路径
+ filename: "./data/artalk.log"
+
+# 缓存
+cache:
+ # 缓存类型 ["redis", "memcache", "builtin"]
+ type: "builtin"
+ # 缓存过期时间 (单位:分钟)
+ expires: 30
+ # 缓存启动预热 (程序启动时预热缓存)
+ warm_up: false
+ # 缓存服务器地址 (例如:"localhost:6379")
+ server: ""
+ # Redis 配置
+ redis:
+ # 连接方式 ["tcp", "unix"]
+ network: "tcp"
+ # 用户名
+ username: ""
+ # 密码
+ password: ""
+ # 数据库编号 (例如使用零号数据库填写 0)
+ db: 0
+
+# 可信域名
+trusted_domains: [] # 例如:["https://artalk.example.com:23366"]
+
+# SSL
+ssl:
+ # 启用 SSL
+ enabled: false
+ # 证书文件路径
+ cert_path: ""
+ # 密钥文件路径
+ key_path: ""
+
+# 管理员账户
+admin_users:
+ # - name: "admin"
+ # email: "admin@example.com"
+ # password: "" # 支持 bcrypt 或 md5 加密,如:"(md5)50c21190c6e4e5418c6a90d2b5031119"
+ # badge_name: "管理员"
+ # badge_color: "#FF6C00"
+
+# 评论审核
+moderator:
+ # 默认待审 (发表新评论需要后台人工审核后才能显示)
+ pending_default: false
+ # API 请求错误时拦截 (关闭此项当请求错误时让评论放行)
+ api_fail_block: false
+ # Akismet Key
+ # (Akismet 反垃圾服务,https://akismet.com)
+ akismet_key: ""
+ # 腾讯云文本内容安全
+ # (https://cloud.tencent.com/document/product/1124/64508)
+ tencent:
+ enabled: false
+ secret_id: ""
+ secret_key: ""
+ region: "ap-guangzhou"
+ # 阿里云内容安全
+ # (https://help.aliyun.com/document_detail/28417.html)
+ aliyun:
+ enabled: false
+ access_key_id: ""
+ access_key_secret: ""
+ region: "cn-shanghai"
+ # 关键词过滤 (本地离线词库)
+ keywords:
+ enabled: false
+ # 匹配成功设为待审状态
+ pending: false
+ # 词库文件 (支持多个词库文件)
+ files:
+ - "./data/词库_1.txt"
+ # 词库文件内容分割符 (例如填写 "\n" 文件中一行一个关键词)
+ file_sep: "\n"
+ # 替换字符
+ replac_to: "x"
+
+# 验证码
+captcha:
+ # 启用验证码
+ enabled: true
+ # 总是需要验证码
+ always: false
+ # 激活验证码所需操作次数
+ action_limit: 3
+ # 重置操作计数器超时 (单位:s, 设为 -1 不重置)
+ action_reset: 60
+ # Geetest 极验 (https://www.geetest.com)
+ geetest:
+ enabled: false
+ captcha_id: ""
+ captcha_key: ""
+
+# 邮件通知
+email:
+ # 启用邮件通知
+ enabled: false
+ # 发送方式 ["smtp", "ali_dm", "sendmail"]
+ send_type: "smtp"
+ # 发信人昵称
+ send_name: "{{reply_nick}}"
+ # 发信人地址
+ send_addr: "noreply@example.com"
+ # 邮件标题
+ mail_subject: "[{{site_name}}] 您收到了来自 @{{reply_nick}} 的回复"
+ # 邮件模板文件 (填入文件路径使用自定义模板)
+ mail_tpl: "default"
+ # SMTP 发送 (启用请将发送方式设为 "smtp")
+ smtp:
+ # 发件地址
+ host: "smtp.qq.com"
+ # 发件端口
+ port: 587
+ # 用户名
+ username: "example@qq.com"
+ # 密码
+ password: ""
+ # 阿里云邮件推送
+ # (启用请将发送方式设为 "ali_dm";参考:https://help.aliyun.com/document_detail/29444.html)
+ ali_dm:
+ access_key_id: ""
+ access_key_secret: ""
+ account_name: "noreply@example.com"
+
+# 图片上传
+img_upload:
+ # 启用图片上传
+ enabled: true
+ # 图片存放路径
+ path: "./data/artalk-img/"
+ # 图片大小限制 (单位:MB)
+ max_size: 5
+ # 图片链接基础路径 (默认为 "/static/images/")
+ public_path: null
+ # Upgit 配置
+ # (使用 Upgit 将图片上传到 GitHub 或图床:https://github.com/pluveto/upgit)
+ upgit:
+ # 启用 Upgit
+ enabled: false
+ # 命令行参数
+ exec: "./upgit -c -t /artalk-img"
+ # 上传后删除本地的图片
+ del_local: true
+
+# 多元推送
+admin_notify:
+ # 通知模版 (填入文件路径使用自定义模板)
+ notify_tpl: "default"
+ # 嘈杂模式
+ noise_mode: false
+ # 邮件通知管理员
+ email:
+ # 开启 (当使用其他推送方式时,可以关闭管理员邮件通知)
+ enabled: true
+ # 邮件标题 (发送给管理员的邮件标题)
+ mail_subject: "[{{site_name}}] 您的文章「{{page_title}}」有新回复"
+ # Telegram
+ telegram:
+ enabled: false
+ api_token: ""
+ receivers:
+ - 7777777
+ # Bark
+ bark:
+ enabled: false
+ server: "http://day.app/xxxxxxx/"
+ # 飞书
+ lark:
+ enabled: false
+ webhook_url: ""
+ # WebHook
+ webhook:
+ enabled: false
+ url: ""
+ # 钉钉
+ ding_talk:
+ enabled: false
+ token: ""
+ secret: ""
+ # Slack
+ slack:
+ enabled: false
+ oauth_token: ""
+ receivers:
+ - "CHANNEL_ID"
+ # LINE
+ line:
+ enabled: false
+ channel_secret: ""
+ channel_access_token: ""
+ receivers:
+ - "USER_ID_1"
+ - "GROUP_ID_1"
+
+# 前端配置
+frontend:
+ # 评论框占位文字
+ placeholder: "键入内容..."
+ # 无评论显示文字
+ noComment: "「此时无声胜有声」"
+ # 发送按钮文字
+ sendBtn: "发送评论"
+ # 评论框旅行
+ editorTravel: true
+ # 暗黑模式
+ darkMode: false
+ # 表情包
+ emoticons: "https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json"
+ # 投票按钮
+ vote: true
+ # 反对按钮
+ voteDown: false
+ # 用户 UA 徽标
+ uaBadge: true
+ # 评论排序功能
+ listSort: true
+ # 页面 PV 绑定元素
+ pvEl: "#ArtalkPV"
+ # 评论数绑定元素
+ countEl: "#ArtalkCount"
+ # 编辑器实时预览功能
+ preview: true
+ # 平铺模式 ["auto", true, false]
+ flatMode: "auto"
+ # 最大嵌套层数
+ nestMax: 2
+ # 嵌套评论排序规则 ["DATE_ASC", "DATE_DESC", "VOTE_UP_DESC"]
+ nestSort: DATE_ASC
+ # 头像
+ gravatar:
+ # Gravatar 镜像地址
+ mirror: "https://cravatar.cn/avatar/"
+ # 默认头像
+ default: "mp"
+ # 评论分页
+ pagination:
+ # 每页评论数
+ pageSize: 20
+ # 加载更多模式 (关闭则使用分页条)
+ readMore: true
+ # 滚动加载
+ autoLoad: true
+ # 内容限高
+ heightLimit:
+ # 评论内容限高 (单位:px)
+ content: 300
+ # 子评论区域限高 (单位:px)
+ children: 400
+ # 请求超时 (单位:毫秒)
+ reqTimeout: 15000
+ # 版本检测
+ versionCheck: true
diff --git a/cmd/admin.go b/cmd/admin.go
new file mode 100644
index 000000000..b8dc314d8
--- /dev/null
+++ b/cmd/admin.go
@@ -0,0 +1,114 @@
+package cmd
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "syscall"
+
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "golang.org/x/term"
+)
+
+var adminCmd = &cobra.Command{
+ Use: "admin",
+ Short: "Create or edit an administrator account",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ core.LoadCore(cfgFile, workDir)
+
+ fmt.Println("--------------------------------")
+ fmt.Println(" " + i18n.T("Create admin account"))
+ fmt.Println("--------------------------------")
+
+ username, email, password, err := credentials()
+ if err != nil {
+ logrus.Fatal(err)
+ }
+
+ findUser := query.FindUser(username, email)
+ if !findUser.IsEmpty() {
+ findUser.SetPasswordEncrypt(password)
+ if err := query.UpdateUser(&findUser); err != nil {
+ logrus.Fatal(err)
+ }
+
+ logrus.Info(i18n.T("{{name}} already exists", map[string]interface{}{"name": i18n.T("Account")}) +
+ ", " + i18n.T("Password updated"))
+ return
+ }
+
+ user := entity.User{
+ Name: username,
+ Email: email,
+ IsAdmin: true,
+ BadgeName: i18n.T("Admin"),
+ BadgeColor: "#FF6C00",
+ }
+ user.SetPasswordEncrypt(password)
+
+ if err := query.CreateUser(&user); err != nil {
+ logrus.Fatal(err)
+ }
+
+ fmt.Println("--------------------------------")
+ fmt.Println(" Name: " + username)
+ fmt.Println(" Mail: " + email)
+ fmt.Println("--------------------------------")
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(adminCmd)
+}
+
+func credentials() (string, string, string, error) {
+ reader := bufio.NewReader(os.Stdin)
+
+ fmt.Print(i18n.T("Enter {{name}}", map[string]interface{}{"name": i18n.T("Username")}) + ": ")
+ username, err := reader.ReadString('\n')
+ if err != nil {
+ return "", "", "", err
+ }
+
+ fmt.Print(i18n.T("Enter {{name}}", map[string]interface{}{"name": i18n.T("Email")}) + ": ")
+ email, err := reader.ReadString('\n')
+ if err != nil {
+ return "", "", "", err
+ }
+ if !utils.ValidateEmail(strings.TrimSpace(email)) {
+ return "", "", "", errors.New("invalid email format")
+ }
+
+ fmt.Print(i18n.T("Enter {{name}}", map[string]interface{}{"name": i18n.T("Password")}) + ": ")
+ bytePassword, err := term.ReadPassword(int(syscall.Stdin))
+ if err != nil {
+ return "", "", "", err
+ }
+
+ fmt.Println()
+ fmt.Print(i18n.T("Retype {{name}}", map[string]interface{}{"name": i18n.T("Password")}) + ": " + ": ")
+ byteRePassword, err := term.ReadPassword(int(syscall.Stdin))
+ if err != nil {
+ return "", "", "", err
+ }
+
+ fmt.Println()
+
+ password := strings.TrimSpace(string(bytePassword))
+ rePassword := strings.TrimSpace(string(byteRePassword))
+
+ if rePassword != password {
+ return "", "", "", errors.New("inconsistent password input")
+ }
+
+ return strings.TrimSpace(username), strings.TrimSpace(email), password, nil
+}
diff --git a/cmd/export.go b/cmd/export.go
new file mode 100644
index 000000000..06a781ecc
--- /dev/null
+++ b/cmd/export.go
@@ -0,0 +1,75 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/artransfer"
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var exportCmd = &cobra.Command{
+ Use: "export",
+ Aliases: []string{},
+ Short: "Artransfer - Export",
+ Long: "\n# Artransfer - Export\n\n See the documentation to learn more: https://artalk.js.org/guide/transfer.html",
+ Run: func(cmd *cobra.Command, args []string) {
+ core.LoadCore(cfgFile, workDir)
+
+ jsonStr, err := artransfer.ExportArtransString()
+ if err != nil {
+ logrus.Fatal(err)
+ }
+
+ if len(args) < 1 || args[0] == "" {
+ // write to stdout
+ fmt.Println(jsonStr)
+ } else {
+ filename := args[0]
+
+ // make sure is abs path
+ filename, err := filepath.Abs(filename)
+ if err != nil {
+ logrus.Fatal(err)
+ }
+
+ // check dir
+ stat, err := os.Stat(filename)
+ if err == nil {
+ if stat.IsDir() {
+ filename = path.Join(filename, "backup-"+time.Now().Format("20060102-150405")+".artrans")
+ }
+ }
+
+ // mkdir -p
+ if err := utils.EnsureDir(filepath.Dir(filename)); err != nil {
+ logrus.Fatal(err)
+ }
+
+ // touch
+ f, err := os.Create(filename)
+ if err != nil {
+ logrus.Fatal(err)
+ }
+
+ // >
+ _, err2 := f.WriteString(jsonStr)
+ if err2 != nil {
+ logrus.Fatal(err2)
+ }
+
+ logrus.Info(i18n.T("Export complete") + ": " + filename)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(exportCmd)
+}
diff --git a/cmd/gen.go b/cmd/gen.go
new file mode 100644
index 000000000..6cc72d6cf
--- /dev/null
+++ b/cmd/gen.go
@@ -0,0 +1,41 @@
+package cmd
+
+import (
+ "os"
+
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var genCmd = &cobra.Command{
+ Use: "gen ",
+ Short: "A collection of several useful generators",
+ Long: "Generate some content\ne.g. `artalk gen config ./artalk.yml`",
+ Args: cobra.RangeArgs(1, 2),
+ Run: func(cmd *cobra.Command, args []string) {
+ // change working directory
+ if workDir != "" {
+ if err := os.Chdir(workDir); err != nil {
+ logrus.Fatal("change working directory error ", err)
+ }
+ }
+
+ var (
+ specificPath string
+ isForce bool
+ )
+ if len(args) > 1 {
+ specificPath = args[1]
+ }
+ isForce, _ = cmd.Flags().GetBool("force")
+
+ core.Gen(args[0], specificPath, isForce)
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(genCmd)
+
+ flagPV(genCmd, "force", "f", false, "Force overwrite an existing file")
+}
diff --git a/cmd/import.go b/cmd/import.go
new file mode 100644
index 000000000..8267d834f
--- /dev/null
+++ b/cmd/import.go
@@ -0,0 +1,42 @@
+package cmd
+
+import (
+ "errors"
+ "os"
+
+ "github.com/ArtalkJS/Artalk/internal/artransfer"
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var importCmd = &cobra.Command{
+ Use: "import ",
+ Aliases: []string{},
+ Short: "Artransfer - Import",
+ Long: "\n# Artransfer - Import\n\n See the documentation to learn more: https://artalk.js.org/guide/transfer.html",
+ Args: cobra.MinimumNArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ core.LoadCore(cfgFile, workDir) // load core
+
+ parcelFile := args[0]
+ if _, err := os.Stat(parcelFile); errors.Is(err, os.ErrNotExist) {
+ logrus.Fatal(i18n.T("{{name}} not found", map[string]interface{}{"name": i18n.T("File")}))
+ }
+
+ payload := args[1:]
+ payload = append(payload, "json_file:"+parcelFile)
+
+ // import Artrans
+ artransfer.RunImportArtrans(payload)
+
+ logrus.Info(i18n.T("Import complete"))
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(importCmd)
+
+ flagPV(importCmd, "assumeyes", "y", false, "Automatically answer yes for all questions.")
+}
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644
index 000000000..244c2937e
--- /dev/null
+++ b/cmd/root.go
@@ -0,0 +1,77 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/spf13/cobra"
+)
+
+var Version = config.Version + `/` + config.CommitHash
+
+var Banner = `
+ ________ ________ _________ ________ ___ ___ __
+|\ __ \|\ __ \|\___ ___\\ __ \|\ \ |\ \|\ \
+\ \ \|\ \ \ \|\ \|___ \ \_\ \ \|\ \ \ \ \ \ \/ /|_
+ \ \ __ \ \ _ _\ \ \ \ \ \ __ \ \ \ \ \ ___ \
+ \ \ \ \ \ \ \\ \| \ \ \ \ \ \ \ \ \ \____\ \ \\ \ \
+ \ \__\ \__\ \__\\ _\ \ \__\ \ \__\ \__\ \_______\ \__\\ \__\
+ \|__|\|__|\|__|\|__| \|__| \|__|\|__|\|_______|\|__| \|__|
+
+Artalk (` + Version + `)
+
+ -> A Selfhosted Comment System.
+ -> https://artalk.js.org
+`
+
+var (
+ cfgFile string
+ workDir string
+)
+
+var rootCmd = &cobra.Command{
+ Use: "artalk",
+ Short: "Artalk: A Fast, Slight & Delightful Comment System",
+ Long: Banner,
+ Version: Version,
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println(Banner)
+ fmt.Print("-------------------------------\n\n")
+ fmt.Println("NOTE: add `-h` flag to show help about any command.")
+ },
+}
+
+func Execute() {
+ cobra.CheckErr(rootCmd.Execute())
+}
+
+func init() {
+ rootCmd.SetVersionTemplate("Artalk ({{printf \"%s\" .Version}})\n")
+ rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults are './artalk.yml')")
+ rootCmd.PersistentFlags().StringVarP(&workDir, "workdir", "w", "", "program working directory (defaults are './')")
+
+ // Version Command
+ versionCmd := &cobra.Command{
+ Use: "version",
+ Short: "Output Version Information",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("Artalk (" + Version + ")")
+ },
+ }
+ rootCmd.AddCommand(versionCmd)
+
+ // Config Command
+ configCmd := &cobra.Command{
+ Use: "config",
+ Short: "Output Config Information",
+ Run: func(cmd *cobra.Command, args []string) {
+ core.LoadConfOnly(cfgFile, workDir)
+ buf, _ := json.MarshalIndent(config.Instance, "", " ")
+ fmt.Println(string(buf))
+ },
+ }
+ rootCmd.AddCommand(configCmd)
+
+}
diff --git a/cmd/server.go b/cmd/server.go
new file mode 100644
index 000000000..0f0675988
--- /dev/null
+++ b/cmd/server.go
@@ -0,0 +1,67 @@
+package cmd
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/ArtalkJS/Artalk/server"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/logger"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var serverCmd = &cobra.Command{
+ Use: "server",
+ Aliases: []string{"serve"},
+ Short: "Start the server",
+ Long: Banner,
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ core.LoadCore(cfgFile, workDir)
+
+ fmt.Println(Banner)
+ fmt.Print("-------------------------------\n\n")
+
+ // create fiber app
+ app := fiber.New(fiber.Config{
+ // @see https://github.com/gofiber/fiber/issues/426
+ // @see https://github.com/gofiber/fiber/issues/185
+ Immutable: true,
+ })
+
+ // logger
+ app.Use(logger.New(logger.Config{
+ Format: "[${status}] ${method} ${path} ${latency} ${ip} ${reqHeader:X-Request-ID} ${referer} ${ua}\n",
+ Output: io.Discard,
+ Done: func(c *fiber.Ctx, logString []byte) {
+ statusOK := c.Response().StatusCode() >= 200 && c.Response().StatusCode() <= 302
+ if !statusOK {
+ logrus.StandardLogger().WriterLevel(logrus.ErrorLevel).Write(logString)
+ } else {
+ logrus.StandardLogger().WriterLevel(logrus.DebugLevel).Write(logString)
+ }
+ },
+ }))
+
+ // init router
+ server.Init(app)
+
+ // listen
+ listenAddr := fmt.Sprintf("%s:%d", config.Instance.Host, config.Instance.Port)
+ if config.Instance.SSL.Enabled {
+ app.ListenTLS(listenAddr, config.Instance.SSL.CertPath, config.Instance.SSL.KeyPath)
+ } else {
+ app.Listen(listenAddr)
+ }
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(serverCmd)
+
+ flagPV(serverCmd, "host", "", "0.0.0.0", "Listening IP")
+ flagPV(serverCmd, "port", "", 23366, "Listening port")
+}
diff --git a/cmd/upgrade.go b/cmd/upgrade.go
new file mode 100644
index 000000000..c27e5cd2f
--- /dev/null
+++ b/cmd/upgrade.go
@@ -0,0 +1,68 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/blang/semver"
+ "github.com/rhysd/go-github-selfupdate/selfupdate"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var upgradeCmd = &cobra.Command{
+ Use: "upgrade",
+ Aliases: []string{"update"},
+ Short: "Upgrade to the latest version",
+ Long: "Upgrade Artalk to the latest version, \n update source is GitHub Releases, \n update need to restart Artalk to take effect.",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ // loadCore()
+ core.LoadConfOnly(cfgFile, workDir)
+
+ logrus.Info(i18n.T("Checking for updates") + "...")
+
+ latest, found, err := selfupdate.DetectLatest("ArtalkJS/Artalk")
+ if err != nil {
+ logrus.Fatal("Error occurred while detecting version: ", err)
+ }
+
+ ignoreVersionCheck, _ := cmd.Flags().GetBool("force")
+ if !ignoreVersionCheck {
+ v := semver.MustParse(strings.TrimPrefix(config.Version, "v"))
+ if !found || latest.Version.LTE(v) {
+ logrus.Println(i18n.T("Current version is the latest") + " (v" + v.String() + ")")
+ return
+ }
+ }
+
+ logrus.Info(i18n.T("New version available") + ": v" + latest.Version.String())
+ logrus.Info(i18n.T("Downloading") + "...")
+
+ exe, err := os.Executable()
+ if err != nil {
+ logrus.Fatal("Could not locate executable path ", err)
+ }
+
+ if err := selfupdate.UpdateTo(latest.AssetURL, exe); err != nil {
+ logrus.Fatal(i18n.T("Update failed")+" ", err)
+ }
+
+ logrus.Println(i18n.T("Update complete"))
+ fmt.Println("\n-------------------------------\n v" +
+ latest.Version.String() +
+ " Release Note\n" +
+ "-------------------------------\n\n" +
+ latest.ReleaseNotes)
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(upgradeCmd)
+
+ flagPV(upgradeCmd, "force", "f", false, "Force upgrade ignore version comparison.")
+}
diff --git a/cmd/utils.go b/cmd/utils.go
new file mode 100644
index 000000000..9a84b663d
--- /dev/null
+++ b/cmd/utils.go
@@ -0,0 +1,39 @@
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+//// Shortcut Functions ////
+
+func flag(cmd *cobra.Command, name string, defaultVal interface{}, usage string) {
+ f := cmd.PersistentFlags()
+ switch y := defaultVal.(type) {
+ case bool:
+ f.Bool(name, y, usage)
+ case int:
+ f.Int(name, y, usage)
+ case string:
+ f.String(name, y, usage)
+ }
+}
+
+func flagP(cmd *cobra.Command, name, shorthand string, defaultVal interface{}, usage string) {
+ f := cmd.PersistentFlags()
+ switch y := defaultVal.(type) {
+ case bool:
+ f.BoolP(name, shorthand, y, usage)
+ case int:
+ f.IntP(name, shorthand, y, usage)
+ case string:
+ f.StringP(name, shorthand, y, usage)
+ }
+}
+
+func flagV(cmd *cobra.Command, name string, defaultVal interface{}, usage string) {
+ flag(cmd, name, defaultVal, usage)
+}
+
+func flagPV(cmd *cobra.Command, name, shorthand string, defaultVal interface{}, usage string) {
+ flagP(cmd, name, shorthand, defaultVal, usage)
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..afb946cd1
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,12 @@
+version: "3.5"
+services:
+ artalk:
+ container_name: artalk
+ image: artalk/artalk
+ build:
+ context: ./
+ dockerfile: Dockerfile
+ ports:
+ - 8080:23366
+ volumes:
+ - ./data:/data
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100755
index 000000000..b6c21441f
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+set -e
+
+if [ "$1" != 'gen' ] && ( [ ! -e /data/artalk.yml ] && [ ! -e /data/artalk-go.yml ] ); then
+ if [ -e /conf.yml ]; then
+ # Move original config to `/data/` for upgrade (<= v2.1.8)
+ cp /conf.yml /data/artalk.yml
+ upMsg=""
+ upMsg+=$'# [v2.1.9+ Updated]\n'
+ upMsg+=$'# The new version of the Artalk container recommends mounting\n'
+ upMsg+=$'# an entire folder instead of a single file to avoid some issues.\n'
+ upMsg+=$'#\n'
+ upMsg+=$'# The original config file has been moved to the "/data/" folder,\n'
+ upMsg+=$'# please unmount the config file volume from your container\n'
+ upMsg+=$'# and edit "/data/artalk.yml" for configuration.'
+ echo "$upMsg" > /conf.yml
+ echo "$(date) [info] Copy config file from '/conf.yml' to '/data/artalk.yml' for upgrade"
+ else
+ # Generate new config
+ artalk gen conf /data/artalk.yml
+ echo "$(date) [info] Generate new config file to '/data/artalk.yml'"
+ fi
+fi
+
+# Run Artalk
+/usr/bin/artalk "$@"
diff --git a/docs/.npmrc b/docs/.npmrc
new file mode 100644
index 000000000..bf2e7648b
--- /dev/null
+++ b/docs/.npmrc
@@ -0,0 +1 @@
+shamefully-hoist=true
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
new file mode 100644
index 000000000..ec698bc52
--- /dev/null
+++ b/docs/.vitepress/config.ts
@@ -0,0 +1,183 @@
+import { defineConfig } from 'vitepress'
+import iterator from 'markdown-it-for-inline'
+import * as ArtalkCDN from '../code/ArtalkCDN.json'
+import * as Versions from '../code/ArtalkVersion.json'
+
+export default defineConfig({
+ title: 'Artalk',
+ description: '一款简洁的自托管评论系统',
+ lang: 'zh-CN',
+
+ head: [
+ ['link', { rel: 'icon', href: '/favicon.png' }],
+ ['meta', { name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi' }],
+ // artalk
+ ['link', { href: ArtalkCDN.CSS, rel: 'stylesheet' }],
+ // ['script', { src: ArtalkCDN.JS }],
+ // light gallery
+ ['link', { href: 'https://npm.elemecdn.com/lightgallery@2.3.0/css/lightgallery.css', rel: 'stylesheet' }],
+ ['script', { src: 'https://npm.elemecdn.com/lightgallery@2.3.0/lightgallery.min.js' }],
+ // katex
+ // ['link', { href: "https://npm.elemecdn.com/katex@0.15.3/dist/katex.min.css", rel: 'stylesheet' }],
+ // ['script', { src: 'https://npm.elemecdn.com/katex@0.15.3/dist/katex.min.js' }],
+ // ['script', { src: 'https://npm.elemecdn.com/@artalkjs/plugin-katex/dist/artalk-plugin-katex.js' }],
+ ],
+
+ lastUpdated: true,
+
+ markdown: {
+ // @link https://github.com/shikijs/shiki
+ theme: {
+ light: 'github-light',
+ dark: 'github-dark',
+ },
+ config: (md) => {
+ md.use(iterator, 'artalk_version', 'text', function (tokens, idx) {
+ tokens[idx].content = tokens[idx].content.replace(/:ArtalkVersion:/g, Versions.Artalk.replace(/^v/, ''));
+ });
+ },
+ },
+
+ themeConfig: {
+ sidebar: {
+ "/guide/": [
+ {
+ text: "快速开始",
+ collapsible: true,
+ items: [
+ { text: '项目介绍', link: '/guide/intro.md' },
+ { text: '程序部署', link: '/guide/deploy.md' },
+ { text: '数据迁移', link: '/guide/transfer.md' },
+ ]
+ },
+ {
+ text: "前端",
+ collapsible: true,
+ items: [
+ { text: '前端配置', link: '/guide/frontend/config.md' },
+ { text: '侧边栏', link: '/guide/frontend/sidebar.md' },
+ { text: '表情包', link: '/guide/frontend/emoticons.md' },
+ { text: '浏览量统计', link: '/guide/frontend/pv.md' },
+ { text: 'Latex', link: '/guide/frontend/latex.md' },
+ { text: '图片灯箱', link: '/guide/frontend/lightbox.md' },
+ { text: '多语言 (i18n)', link: '/guide/frontend/i18n.md' },
+ { text: '置入博客', link: '/guide/frontend/import-blog.md' },
+ { text: '置入框架', link: '/guide/frontend/import-framework.md' },
+ { text: '精简版本', link: '/guide/frontend/artalk-lite.md' },
+ { text: '扩展插件', link: '/guide/frontend/plugs.md' },
+ ],
+ },
+ {
+ text: '后端',
+ collapsible: true,
+ items: [
+ { text: '后端配置', link: '/guide/backend/config.md' },
+ { text: 'Docker', link: '/guide/backend/docker.md' },
+ { text: '管理员 × 多站点', link: '/guide/backend/multi-site.md' },
+ { text: '邮件通知', link: '/guide/backend/email.md' },
+ { text: '多元推送', link: '/guide/backend/admin_notify.md' },
+ { text: '图片上传', link: '/guide/backend/img-upload.md' },
+ { text: '评论审核', link: '/guide/backend/moderator.md' },
+ { text: '验证码', link: '/guide/backend/captcha.md' },
+ { text: '在后端控制前端', link: '/guide/backend/fe-control.md' },
+ { text: '相对 / 绝对路径', link: '/guide/backend/relative-path.md' },
+ { text: '守护进程', link: '/guide/backend/daemon.md' },
+ { text: '反向代理', link: '/guide/backend/reverse-proxy.md' },
+ { text: '编译构建', link: '/guide/backend/build.md' },
+ { text: '程序升级', link: '/guide/backend/update.md' },
+ ]
+ },
+ {
+ text: '更多内容',
+ collapsible: true,
+ items: [
+ { text: '安全防范', link: '/guide/security.md' },
+ { text: '扩展阅读', link: '/guide/extras.md' },
+ { text: '案例展示', link: '/guide/cases.md' },
+ { text: '关于我们', link: '/guide/about.md' },
+ ]
+ }
+ ],
+ "/develop/": [
+ {
+ text: '开发文档',
+ items: [
+ { text: '开发说明', link: '/develop/index.md', },
+ { text: 'HTTP API', link: '/develop/api.md', },
+ { text: 'Frontend Event', link: '/develop/event.md', },
+ ]
+ }
+ ]
+ },
+
+ nav: [
+ // NavbarItem
+ {
+ text: '介绍',
+ link: '/guide/intro',
+ },
+ {
+ text: '部署',
+ link: '/guide/deploy',
+ },
+ {
+ text: '配置',
+ link: '/guide/frontend/config',
+ },
+ {
+ text: '迁移',
+ link: '/guide/transfer',
+ },
+ {
+ text: '案例',
+ link: '/guide/cases',
+ },
+ {
+ text: '开发',
+ link: '/develop/',
+ },
+ // NavbarGroup
+ {
+ text: '传送',
+ items: [
+ {
+ text: '前端仓库',
+ link: 'https://github.com/ArtalkJS/Artalk',
+ },
+ {
+ text: '后端仓库',
+ link: 'https://github.com/ArtalkJS/Artalk',
+ },
+ {
+ text: '文档仓库',
+ link: 'https://github.com/ArtalkJS/Docs',
+ },
+ {
+ text: '文档镜像 (国内)',
+ link: 'https://artalk-docs.qwqaq.com'
+ }
+ ],
+ },
+ ],
+
+ socialLinks: [
+ { icon: 'github', link: 'https://github.com/ArtalkJS/Artalk' }
+ ],
+
+ algolia: {
+ appId: 'BH4D9OD16A',
+ apiKey: '37ab96e3f5a774cbbf0571b035b42adb',
+ indexName: 'artalk-js',
+ searchParameters: {
+ facetFilters: ['lang:zh-CN']
+ }
+ },
+
+ editLink: {
+ repo: 'ArtalkJS/Docs',
+ branch: 'master',
+ dir: 'docs',
+ text: '完善文档'
+ },
+ }
+})
diff --git a/docs/.vitepress/theme/Artalk.vue b/docs/.vitepress/theme/Artalk.vue
new file mode 100644
index 000000000..101cf36a3
--- /dev/null
+++ b/docs/.vitepress/theme/Artalk.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/Artransfer.vue b/docs/.vitepress/theme/Artransfer.vue
new file mode 100644
index 000000000..613eca6b8
--- /dev/null
+++ b/docs/.vitepress/theme/Artransfer.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue
new file mode 100644
index 000000000..123450db4
--- /dev/null
+++ b/docs/.vitepress/theme/Layout.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
new file mode 100644
index 000000000..e9b495ff5
--- /dev/null
+++ b/docs/.vitepress/theme/index.ts
@@ -0,0 +1,20 @@
+import DefaultTheme from 'vitepress/theme'
+import Layout from './Layout.vue'
+import Artalk from './Artalk.vue'
+import Artransfer from './Artransfer.vue'
+import { Theme } from 'vitepress'
+
+export default {
+ ...DefaultTheme,
+
+ Layout,
+
+ enhanceApp({ app, router, siteData }) {
+ app.component('Artransfer', Artransfer)
+ app.component('Artalk', Artalk)
+
+ // app is the Vue 3 app instance from `createApp()`.
+ // router is VitePress' custom router. `siteData` is
+ // a `ref` of current site-level metadata.
+ }
+} as Theme
diff --git a/docs/.vitepress/theme/style.scss b/docs/.vitepress/theme/style.scss
new file mode 100644
index 000000000..b5c4e6ddc
--- /dev/null
+++ b/docs/.vitepress/theme/style.scss
@@ -0,0 +1,142 @@
+:root {
+ --vp-c-brand: #558fb5;
+ --vp-c-brand-light: #498cb8;
+ --vp-c-brand-lighter: #549ccc;
+ --vp-c-brand-dark: #366482;
+ --vp-c-brand-darker: #244f6b;
+ --vp-code-block-bg: #f6f8fa;
+ --vp-c-divider: #dfe2e5;
+
+ --vp-home-hero-name-color: transparent;
+ --vp-home-hero-name-background: linear-gradient(90deg,#0083ff, #37dfd9);
+}
+
+.dark {
+ --vp-c-bg: #22272e;
+ --vp-c-bg-alt: #22272e;
+ --vp-code-block-bg: #2b313a;
+ --vp-c-text-2: #8094a8;
+ --vp-button-alt-bg: #1e2224;
+ --vp-button-alt-border: #2d3235;
+ --vp-c-bg-soft: #1e2224;
+ --vp-c-divider-light: #2d3235;
+ --vp-c-divider: #34404c;
+ --vp-c-black: #22272e;
+ --vp-c-bg-soft: #2b313a;
+}
+
+.VPHome .clip {
+ font-weight: 500;
+ font-size: 1.5em;
+ margin-bottom: 0.5em;
+ display: inline-block;
+}
+
+.VPHome .VPButton.brand {
+ border-color: #0083ff;
+ color: #fff;
+ background-color: #0083ff;
+
+ &:hover {
+ border-color: #007CF0;
+ color: #fff;
+ background-color: #007CF0;
+ }
+}
+
+/** 一起摇摆 **/
+.wave {
+ display: inline-block;
+ transform-origin: center bottom;
+ animation:upAnimation 2.33s ease .8s 3 both;
+}
+@keyframes upAnimation {
+ 0% {
+ transform: rotate(0deg);
+ transition-timing-function:cubic-bezier(0.215, .61, .355, 1);
+ }
+ 10% {
+ transform: rotate(-12deg);
+ transition-timing-function:cubic-bezier(0.215, .61, .355, 1);
+ }
+ 20% {
+ transform: rotate(12deg);
+ transition-timing-function:cubic-bezier(0.215, .61, .355, 1);
+ }
+ 28% {
+ transform: rotate(-10deg);
+ transition-timing-function:cubic-bezier(0.215, .61, .355, 1);
+ }
+ 36% {
+ transform: rotate(10deg);
+ transition-timing-function:cubic-bezier(0.755, .5, .855, .06);
+ }
+ 42% {
+ transform: rotate(-8deg);
+ transition-timing-function:cubic-bezier(0.755, .5, .855, .06);
+ }
+ 48% {
+ transform: rotate(8deg);
+ transition-timing-function:cubic-bezier(0.755, .5, .855, .06);
+ }
+ 52% {
+ transform: rotate(-4deg);
+ transition-timing-function:cubic-bezier(0.755, .5, .855, .06);
+ }
+ 56% {
+ transform: rotate(4deg);
+ transition-timing-function:cubic-bezier(0.755, .5, .855, .06);
+ }
+ 60% {
+ transform: rotate(0deg);
+ transition-timing-function:cubic-bezier(0.755, .5, .855, .06);
+ }
+ 100% {
+ transform: rotate(0deg);
+ transition-timing-function:cubic-bezier(0.215, .61, .355, 1);
+ }
+}
+
+.vp-doc > div > img {
+ max-width: 100%;
+ margin: 1.5em auto;
+}
+
+img[atk-emoticon] {
+ display: initial;
+}
+
+/* 由于 body 未添加一致的 transition,导致其他地方暗黑模式切换加 transition 比较怪 */
+.vp-doc div[class*='language-'] { transition: none; }
+.vp-doc :not(pre) > code { transition: none; }
+
+.vp-doc {
+ table {
+ border-collapse: collapse;
+ margin: 1rem 0;
+ display: block;
+ overflow-x: auto
+ }
+
+ td,th {
+ padding: .6em 1em;
+ }
+}
+
+/* 侧边栏 */
+.VPSidebar::-webkit-scrollbar {
+ width: 7px
+}
+
+.VPSidebar::-webkit-scrollbar-track {
+ background-color: transparent
+}
+
+.VPSidebar::-webkit-scrollbar-thumb {
+ background-color: var(--vp-c-divider)
+}
+
+// details block patch
+.custom-block.details > summary {
+ cursor: pointer;
+}
diff --git a/docs/.vitepress/types/shims-vue.d.ts b/docs/.vitepress/types/shims-vue.d.ts
new file mode 100644
index 000000000..798e8fcfa
--- /dev/null
+++ b/docs/.vitepress/types/shims-vue.d.ts
@@ -0,0 +1,5 @@
+declare module '*.vue' {
+ import { defineComponent } from 'vue';
+ const component: ReturnType;
+ export default component;
+}
diff --git a/packages/artalk/CNAME b/docs/CNAME
similarity index 100%
rename from packages/artalk/CNAME
rename to docs/CNAME
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..d8befb42e
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,19 @@
+## Artalk Documentation
+
+> The Documentation of Artalk
+
+## Build
+
+```bash
+pnpm docs:dev
+pnpm docs:build
+```
+
+## Deploy
+
+[](https://app.netlify.com/sites/focused-kilby-a3ec8c/deploys)
+
+本站点通过 [Netlify](https://www.netlify.com/) 对 master 分支进行自动部署。
+
+## Licensed
+CC BY-NC-SA 4.0
diff --git a/docs/artalk-banner.png b/docs/artalk-banner.png
new file mode 100644
index 000000000..f9d0a0fbc
Binary files /dev/null and b/docs/artalk-banner.png differ
diff --git a/docs/artalk-go-banner.png b/docs/artalk-go-banner.png
new file mode 100644
index 000000000..895b00c44
Binary files /dev/null and b/docs/artalk-go-banner.png differ
diff --git a/docs/code/ArtalkCDN.json b/docs/code/ArtalkCDN.json
new file mode 100644
index 000000000..9a85b73ea
--- /dev/null
+++ b/docs/code/ArtalkCDN.json
@@ -0,0 +1,4 @@
+{
+ "JS": "https://npm.elemecdn.com/artalk@2.4.4/dist/Artalk.js",
+ "CSS": "https://npm.elemecdn.com/artalk@2.4.4/dist/Artalk.css"
+}
diff --git a/docs/code/ArtalkVersion.json b/docs/code/ArtalkVersion.json
new file mode 100644
index 000000000..bcf536697
--- /dev/null
+++ b/docs/code/ArtalkVersion.json
@@ -0,0 +1,3 @@
+{
+ "Artalk": "2.4.4"
+}
diff --git a/docs/code/quick-start/cdn.html b/docs/code/quick-start/cdn.html
new file mode 100644
index 000000000..f6618a0f4
--- /dev/null
+++ b/docs/code/quick-start/cdn.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/docs/code/quick-start/normal-install.sh b/docs/code/quick-start/normal-install.sh
new file mode 100644
index 000000000..f61777fc6
--- /dev/null
+++ b/docs/code/quick-start/normal-install.sh
@@ -0,0 +1,4 @@
+$ tar xvzf artalk.tar.gz
+$ cd artalk
+$ vim artalk.yml
+$ ./artalk server
diff --git a/docs/develop/api.md b/docs/develop/api.md
new file mode 100644
index 000000000..af399eafe
--- /dev/null
+++ b/docs/develop/api.md
@@ -0,0 +1,588 @@
+# HTTP API
+
+::: tip 无特殊说明时 API 调用响应格式
+
+| 字段名 | 类型 | 说明 |
+| ----- | ------- | --- |
+| `success` | Boolean | 接口调用结果 |
+| `msg` | String | 返回消息,出错时此字段包含错误消息 |
+| `data` | Any | 返回数据,可能为 Object / String / Array 类型 |
+
+:::
+
+## Normal API
+
+### 评论新增
+
+**POST** `/api/add`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `name` | String | 是 | 待新增评论者昵称 |
+| `email` | String | 是 | 待新增评论者邮箱 |
+| `link` | String | 否 | 待新增评论者链接 |
+| `content` | String | 是 | 待新增评论内容 |
+| `rid` | Number | 否 | 待新增评论为回复评论时传入父评论 `ID`,否则为 `0` |
+| `page_key` | String | 是 | 待新增评论目标页面唯一标识符 |
+| `page_title` | String | 否 | 待新增评论目标页面标题 |
+| `token` | String | 否 | 评论请求 Token |
+| `site_name` | String | 否 | 待新增评论目标站点名称 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 评论新增成功 |
+| `data.comment` | Object | - | 评论数据 |
+| `data.comment.id` | Number | - | 评论 ID |
+| `data.comment.content` | String | - | 评论正文 |
+| `data.comment.nick` | String | - | 评论者昵称 |
+| `data.comment.email_encrypted` | String | - | 评论者邮箱 MD5 加密值 |
+| `data.comment.link` | String | - | 评论者链接 |
+| `data.comment.ua` | String | - | 评论者 User-Agent |
+| `data.comment.date` | String | - | 评论时间,格式为 `1970-01-01 00:00:00` |
+| `data.comment.is_collapsed` | Boolean | - | 评论是否折叠 |
+| `data.comment.is_pending` | Boolean | - | 评论是否待审 |
+| `data.comment.is_allow_reply` | Boolean | - | 评论是否允许回复 |
+| `data.comment.rid` | Number | `0` | 待新增评论为回复评论时返回父评论 `ID` |
+| `data.comment.badge_name` | String | - | 评论者徽章文字 |
+| `data.comment.badge_color` | String | - | 评论者徽章颜色 |
+| `data.comment.visible` | Boolean | `true` | 评论是否可见 |
+| `data.comment.vote_up` | Number | `0` | 评论赞同数 |
+| `data.comment.vote_down` | Number | `0` | 评论反对数 |
+| `data.comment.page_key` | String | - | 评论所在页面唯一标识符 |
+| `data.comment.site_name` | String | - | 评论所在站点名称 |
+
+### 评论获取
+
+**POST** `/api/get`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `page_key` | String | 是 | 待获取评论页面的唯一标识符 |
+| `limit` | Number | 否 | 待获取评论的数量限制 |
+| `offset` | Number | 否 | 待获取评论的起始位置偏移 |
+| `type` | String | 否 | 获取指定类型的评论 |
+| `name` | String | 否 | 获取指定昵称的评论 |
+| `email` | String | 否 | 获取指定邮箱的评论 |
+| `site_name` | String | 否 | 获取指定站点名称的评论 |
+| `flat_mode` | Boolean | 否 | 待获取评论是否平铺模式 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 评论获取成功 |
+| `data.comments` | Array | - | 页面评论数据 |
+| `data.total` | Number | - | 页面评论总数(包括所有子评论) |
+| `data.total_parents` | Number | - | 页面评论中父级评论总数 |
+| `data.page` | Object | - | 页面数据 |
+| `data.page.id` | Number | - | 页面 ID |
+| `data.page.admin_only` | Boolean | - | 页面是否仅管理员可评论 |
+| `data.page.key` | String | - | 页面唯一标识符 |
+| `data.page.url` | String | - | 页面链接 |
+| `data.page.title` | String | - | 页面标题 |
+| `data.page.site_name` | String | - | 页面所在站点名称 |
+| `data.page.vote_up` | Number | - | 页面赞同数 |
+| `data.page.vote_down` | Number | - | 页面反对数 |
+| `data.unread` | Array | - | 未读的评论提醒数据 |
+| `data.unread_count` | Number | - | 未读的评论提醒数据总数 |
+| `data.api_version` | Object | - | 目标 Artalk 后端版本数据 |
+
+::: tip
+
+ - `data.comments` 数组中元素结构参考 [评论新增接口](#评论新增) `/api/add` 响应的 `data.comment`
+ - `data.unread` 数组中元素结构如下
+ | 字段名 | 数据类型 | 默认 | 说明 |
+ | ----- | ------- | :-: | --- |
+ | `id` | Number | - | 评论提醒 ID |
+ | `user_id` | Number | - | 评论提醒目标用户 ID |
+ | `comment_id` | Number | - | 评论提醒来源评论 ID |
+ | `is_read` | Boolean | - | 评论提醒是否已读 |
+ | `is_emailed` | Boolean | - | 评论提醒是否已发送邮件 |
+ | `read_link` | String | - | 评论提醒已读地址 |
+ - `data.api_version` 对象结构参考 [Artalk 版本接口](#artalk-版本) `/api/version` 响应
+
+:::
+
+### 用户获取
+
+**POST** `/api/user-get`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `name` | String | 否 | 待获取用户的昵称 |
+| `email` | String | 否 | 待获取用户的邮箱 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 用户获取成功 |
+| `data.user` | Object | `null` | 获取的用户数据,若用户不存在返回空 |
+| `data.user.id` | Number | - | 用户 ID |
+| `data.user.name` | String | - | 用户昵称 |
+| `data.user.email` | String | - | 用户邮箱 |
+| `data.user.link` | String | - | 用户链接 |
+| `data.user.badge_name` | String | - | 用户徽章文字 |
+| `data.user.badge_color` | String | - | 用户徽章颜色 |
+| `data.user.is_admin` | Boolean | - | 用户是否管理员 |
+| `data.is_login` | Boolean | - | 用户是否登录 |
+| `data.unread` | Array | `[]` | 用户未读的评论提醒数据 |
+| `data.unread_count` | Number | `0` | 用户未读的评论提醒数据总数 |
+
+::: tip
+
+ - `data.unread` 数组中元素结构参考 [评论获取接口](#评论获取) `/api/get` **TIP**
+
+:::
+
+### 用户登录
+
+**GET/POST** `/api/login`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `name` | String | 是 | 待登录用户的昵称 |
+| `email` | String | 是 | 待登录用户的邮箱 |
+| `password` | String | 是 | 待登录用户的密码(管理员身份验证) |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 用户登录成功 |
+| `data.token` | String | - | 登录的用户 Token |
+
+### 提醒已读
+
+**POST** `/api/mark-read`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `notify_key` | String | 否 | 评论提醒唯一标识符 |
+| `name` | String | 否 | 评论提醒用户的昵称 |
+| `email` | String | 否 | 评论提醒用户的邮箱 |
+| `all_read` | Boolean | 否 | 评论提醒是否已读 |
+| `site_name` | String | 否 | 评论提醒所在站点名称 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 提醒已读成功 |
+
+### 评论投票
+
+**POST** `/api/vote`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `target_id` | Number | 是 | 待投票评论 ID |
+| `type` | String | 否 | 待投票类型,前缀 `comment_`、`page_` 依次代表对评论投票、对页面投票,后缀 `_up`、`_down` 依次代表赞同、反对 |
+| `name` | String | 否 | 投票用户昵称 |
+| `email` | String | 否 | 投票用户邮箱 |
+| `site_name` | String | 否 | 投票目标评论或页面所在站点名称 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 评论投票成功 |
+| `data.vote_num` | Number | - | 目标评论或页面指定类型投票数据 |
+
+### Artalk 版本
+
+::: warning
+
+此接口较特殊,返回结果不包含 `success`、`msg`、`data` 字段,直接返回包含指定字段的 JSON。
+
+:::
+
+**GET/POST** `/api/version`
+
+**参数** 无
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `app` | String | `artalk` | Artalk 后端程序名 |
+| `version` | String | - | Artalk 后端程序版本号 |
+| `commit_hash` | String | - | Artalk 后端程序 Git 提交哈希值 |
+| `fe_min_version` | String | - | Artalk 后端程序所需前端最低版本 |
+
+## Captcha API
+
+### 验证码刷新
+
+**GET** `/api/captcha/refresh`
+
+**参数** 无
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 验证码刷新成功 |
+| `data.img_data` | String | - | 根据 IP 获取的验证码图片 Base64 编码 |
+
+### 验证码检验
+
+**GET** `/api/captcha/check`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `value` | String | 是 | 验证码 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 验证码检验成功 |
+| `data.img_data` | String | - | 新的验证码图片 Base64 编码,仅验证码检验出错时存在此字段 |
+
+## Admin API
+
+### 评论编辑
+
+**POST** `/api/admin/comment-edit`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 待编辑评论 ID,**不可修改** |
+| `site_name` | String | 否 | 待编辑评论所在站点名称,**不可修改** |
+| `content` | String | 否 | 待编辑评论正文 |
+| `page_key` | String | 否 | 待编辑评论所在页面唯一标识符 |
+| `nick` | String | 否 | 待编辑评论所属用户昵称 |
+| `email` | String | 否 | 待编辑评论所属用户邮箱 |
+| `link` | String | 否 | 待编辑评论所属用户链接 |
+| `rid` | String | 否 | 待编辑评论为回复评论时传入父评论 `ID`,否则为 `0` |
+| `ua` | String | 否 | 待编辑评论所属用户 User-Agent |
+| `ip` | String | 否 | 待编辑评论所属用户 IP |
+| `is_collapsed` | Boolean | 否 | 待编辑评论是否折叠 |
+| `is_pending` | Boolean | 否 | 待编辑评论是否待审 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 评论编辑成功 |
+| `data.comment` | Object | - | 编辑评论的新数据 |
+
+::: tip
+
+ - `data.comment` 对象结构参考 [评论新增接口](#评论新增) `/api/add` 响应的 `data.comment`
+
+:::
+
+### 评论删除
+
+::: danger
+
+此接口不推荐外部调用。
+
+调用此接口前请务必核实评论 ID,一旦调用成功将删除该评论下所有相关数据。
+
+:::
+
+**POST** `/api/admin/comment-del`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 待删除评论 ID |
+| `site_name` | String | 是 | 待删除评论所在站点名称 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 评论删除成功 |
+
+### 页面获取
+
+**POST** `/api/admin/page-get`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `site_name` | String | 是 | 待获取页面目标站点名称 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 页面获取成功 |
+| `data.pages` | Array | - | 目标站点的页面数据 |
+| `data.sites` | Array | - | 所有站点数据 |
+
+::: tip
+
+ - `data.pages` 数组中元素结构参考 [评论获取接口](#评论获取) `/api/get` 响应的 `data.page`
+ - `data.sites` 数组中元素结构参考 [站点编辑接口](#站点编辑) `/api/admin/site-edit` 响应的 `data.site`
+
+:::
+
+### 页面编辑
+
+**POST** `/api/admin/page-edit`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 待编辑页面 ID,**不可修改** |
+| `site_name` | String | 否 | 待编辑页面所在站点名称,**不可修改** |
+| `key` | String | 否 | 待编辑页面唯一标识符 |
+| `title` | String | 否 | 待编辑页面标题 |
+| `admin_only` | Boolean | 否 | 待编辑页面是否仅管理员可评论 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 页面编辑成功 |
+| `data.page` | Object | - | 编辑页面的新数据 |
+
+::: tip
+
+ - `data.page` 对象结构参考 [评论获取接口](#评论获取) `/api/get` 响应的 `data.page`
+
+:::
+
+### 页面删除
+
+::: danger
+
+此接口不推荐外部调用。
+
+调用此接口前请务必核实页面 ID,一旦调用成功将删除该页面下所有相关数据。
+
+:::
+
+**POST** `/api/admin/page-del`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `key` | String | 是 | 待删除页面唯一标识符 |
+| `site_name` | String | 是 | 待删除页面所在站点名称 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 页面删除成功 |
+
+### 页面更新
+
+**POST** `/api/admin/page-fetch`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 待更新页面 ID |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 页面更新成功 |
+| `data.page` | Object | - | 更新页面的新数据 |
+
+::: tip
+
+ - `data.page` 对象结构参考 [评论获取接口](#评论获取) `/api/get` 响应的 `data.page`
+
+:::
+
+### 站点获取
+
+**POST** `/api/admin/site-get`
+
+**参数** 无
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 站点获取成功 |
+| `data.sites` | Array | - | 所有站点数据 |
+
+::: tip
+
+ - `data.sites` 数组中元素结构参考 [站点编辑接口](#站点编辑) `/api/admin/site-edit` 响应的 `data.site`
+
+:::
+
+### 站点新增
+
+**POST** `/api/admin/site-add`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `name` | String | 是 | 待新增站点名称 |
+| `urls` | String | 是 | 待新增站点链接,如有多个需以英文半角符号 `,` 分割 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 站点新增成功 |
+| `data.site` | Object | - | 新增站点的数据 |
+| `data.site.id` | Number | - | 站点 ID |
+| `data.site.name` | String | - | 站点名称 |
+| `data.site.urls` | Array | - | 站点链接数组,元素为单个站点链接字符串 |
+| `data.site.urls_raw` | String | - | 站点链接数组合并字符串,以英文半角符号 `,` 分割 |
+| `data.site.first_url` | String | - | 站点第一链接 |
+
+### 站点编辑
+
+**POST** `/api/admin/site-edit`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 待编辑站点 ID |
+| `name` | String | 是 | 待编辑站点新名称 |
+| `urls` | String | 是 | 待编辑站点新链接 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 站点编辑成功 |
+| `data.site` | Object | - | 编辑站点的新数据 |
+| `data.site.id` | Number | - | 站点 ID |
+| `data.site.name` | String | - | 站点名称 |
+| `data.site.urls` | Array | - | 站点链接数组,元素为单个站点链接字符串 |
+| `data.site.urls_raw` | String | - | 站点链接数组合并字符串,以英文半角符号 `,` 分割 |
+| `data.site.first_url` | String | - | 站点第一链接 |
+
+### 站点删除
+
+::: danger
+
+此接口不推荐外部调用。
+
+调用此接口前请务必核实站点 ID,一旦调用成功将删除该站点下所有相关数据。
+
+:::
+
+**POST** `/api/admin/site-del`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 待删除站点 ID |
+| `del_content` | Boolean | 否 | 是否删除站点所有数据,为 `false` 时仅删除站点配置而保留站点评论数据 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 站点删除成功 |
+
+### 设置获取
+
+**POST** `/api/admin/setting-get`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 管理员用户 ID |
+| `name` | String | 否 | 管理员用户昵称 |
+| `url` | String | 否 | 管理员用户链接 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 设置获取成功 |
+
+### 设置保存
+
+**POST** `/api/admin/setting-save`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `id` | Number | 是 | 管理员用户 ID |
+| `name` | String | 否 | 管理员用户昵称 |
+| `url` | String | 否 | 管理员用户链接 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 设置保存成功 |
+
+### 邮件发送
+
+::: warning
+
+页面收到评论、评论收到回复等满足需要发送邮件提醒条件时,后端将自动向目标地址发送邮件提醒,不需手动处理邮件发送。请勿滥用此接口。
+
+:::
+
+**POST** `/api/admin/send-mail`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `subject` | String | 是 | 邮件主题 |
+| `body` | String | 是 | 邮件正文 |
+| `to_addr` | String | 是 | 邮件收件人 |
+
+**响应**
+
+| 字段名 | 数据类型 | 默认 | 说明 |
+| ----- | ------- | :-: | --- |
+| `success` | Boolean | `true` | 邮件发送成功 |
+
+### Artrans 接口
+
+::: warning
+
+此接口暂不稳定,请避免使用!
+
+:::
+
+**POST** `/api/admin/artransfer`
+
+**参数**
+
+| 字段名 | 数据类型 | 必需 | 说明 |
+| ----- | ------- | :---: | --- |
+| `type` | String | 是 | 导入数据类型 |
+| `payload` | String | 否 | 导入数据 |
+
+**响应** 无
diff --git a/docs/develop/event.md b/docs/develop/event.md
new file mode 100644
index 000000000..07f3ce587
--- /dev/null
+++ b/docs/develop/event.md
@@ -0,0 +1,34 @@
+# Event
+
+## 基本事件
+
+- `list-load` 评论加载事件
+- `list-loaded` 评论加载完成事件
+- `list-inserted` 评论插入事件
+- `editor-submit` 编辑器提交事件
+- `editor-submitted` 编辑器提交完成事件
+- `user-changed` 本地用户数据变更事件
+- `conf-updated` 配置变更事件
+- `sidebar-show` 侧边栏显示事件
+- `sidebar-hide` 侧边栏隐藏事件
+
+## 添加事件监听
+
+```js
+Artalk.use(ctx => {
+ ctx.on('list-loaded', () => {
+ alert('评论已加载完毕')
+ })
+})
+```
+
+## 解除事件监听
+
+```js
+let foo = function() { /* do something */ }
+
+Artalk.use(ctx => {
+ ctx.on('list-loaded', foo)
+ ctx.off('list-loaded', foo)
+})
+```
diff --git a/docs/develop/index.md b/docs/develop/index.md
new file mode 100644
index 000000000..219a874b8
--- /dev/null
+++ b/docs/develop/index.md
@@ -0,0 +1,18 @@
+# 开发
+
+::: warning
+
+目前文档仍在陆续完善中...
+
+:::
+
+由于 Artalk 正处于开发阶段,使用此文档中 `API`、`Event` 前请务必检查时效性。
+
+ - `API` 部分参考源码
+ * [@ArtalkJS/Artalk - src/api/index.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/src/api/index.ts)
+ * [@ArtalkJS/Artalk - http/a_router.go](https://github.com/ArtalkJS/Artalk/blob/master/http/a_router.go)
+ * [@ArtalkJS/Artalk - http/a_http.go](https://github.com/ArtalkJS/Artalk/blob/master/http/a_http.go)
+ - `Event` 部分参考源码
+ * [@ArtalkJS/Artalk - types/event.d.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/types/event.d.ts)
+
+通过 Artalk 提供的 `API` 和 `Event`,你可以实现很多高级功能,比如编写评论管理机器人、评论提醒推送插件等。Artalk 并不为此提供技术指导,但鼓励你参考此处的文档自行定制。
diff --git a/docs/guide/about.md b/docs/guide/about.md
new file mode 100644
index 000000000..165fe1e08
--- /dev/null
+++ b/docs/guide/about.md
@@ -0,0 +1,17 @@
+# 🥬 关于我们
+
+## ~~开发团队~~有人想自我介绍一下
+
+[@qwqcode](https://github.com/qwqcode):🥬 + 🦜
+
+P.S. 因为侧边栏太空了,也许。所以,不知道放点什么好。被迫写了个关于页面。
+
+## 团队成员
+
+ohhhhhhh.
+
+The Artalk. Made with ♥️.
+
+## 关于本站
+
+该站点通过 [Netlify](https://www.netlify.com/) 对 [master](https://github.com/ArtalkJS/Docs) 分支进行自动部署。
diff --git a/docs/guide/backend/admin_notify.md b/docs/guide/backend/admin_notify.md
new file mode 100644
index 000000000..72c1cbc4f
--- /dev/null
+++ b/docs/guide/backend/admin_notify.md
@@ -0,0 +1,384 @@
+# 多元推送
+
+你可以配置 `admin_notify`,让 Artalk 以多种方式通知管理员。
+
+支持 **Telegram**、**飞书**、**钉钉**、**Bark**、**Slack**、**LINE**,并且多种方式可以同时启用。
+
+完整的 `admin_notify` 配置如下:
+
+::: details 点击显示
+
+```yaml
+# 多元推送
+admin_notify:
+ # 通知模版
+ notify_tpl: "default"
+ noise_mode: false
+ # 邮件通知管理员
+ email:
+ enabled: true # 当使用其他推送方式时,可以关闭管理员邮件通知
+ mail_subject: "[{{site_name}}] 您的文章「{{page_title}}」有新回复"
+ # Telegram
+ telegram:
+ enabled: false
+ api_token: ""
+ receivers:
+ - 7777777
+ # 飞书
+ lark:
+ enabled: false
+ webhook_url: ""
+ # 钉钉
+ ding_talk:
+ enabled: false
+ token: ""
+ secret: ""
+ # Bark
+ bark:
+ enabled: false
+ server: "http://day.app/xxxxxxx/"
+ # Slack
+ slack:
+ enabled: false
+ oauth_token: ""
+ receivers:
+ - "CHANNEL_ID"
+ # LINE
+ line:
+ enabled: false
+ channel_secret: ""
+ channel_access_token: ""
+ receivers:
+ - "USER_ID_1"
+ - "GROUP_ID_1"
+ # WebHook
+ webhook:
+ enabled: false
+ url: ""
+```
+
+:::
+
+## 邮件通知
+
+```yaml
+admin_notify:
+ enabled: true # 当使用其他推送方式时,可以关闭管理员邮件通知
+ mail_subject: "[{{site_name}}] 您的文章「{{page_title}}」有新回复"
+```
+
+当使用其他推送方式时,可以关闭管理员邮件通知。
+
+配置项 `mail_subject` 为发送给管理员的邮件标题。
+
+在这之前,你需要配置全局邮件发送功能:[参考此处](./email.md)。
+
+## Telegram
+
+```yaml
+admin_notify:
+ # Telegram
+ telegram:
+ enabled: true
+ api_token: ""
+ receivers:
+ - 7777777
+```
+
+- `api_token`:TG Bot 的 API Token。
+- `receivers`:消息接受者的数字 ID,可设置多个。
+
+### 创建 TG Bot
+
+搜索 `@BotFather` 回复 `/newbot` 并按提示创建新的 TG 机器人。
+
+
+
+标红的文字就是你之后需要在 Artalk 配置中填入的 `api_token`。
+
+配置中的 `receivers` 填入需要接受消息的账号数字 ID,可以搜索机器人 `@RawDataBot` 获取如图:
+
+
+
+详情可参考:[“Bots: An introduction for developers - Telegram”](https://core.telegram.org/bots)
+
+::: tip
+
+鉴于复杂的网络环境,如需使用代理,请在 Artalk 启动之前配置环境变量,例如:
+
+```sh
+export https_proxy=http://127.0.0.1:7890
+```
+
+:::
+
+## 飞书
+
+```yaml
+admin_notify:
+ # 飞书
+ lark:
+ enabled: true
+ webhook_url: ""
+```
+
+- `webhook_url`:填入创建群组机器人时得到的 WebHook 地址。
+
+### 创建群组机器人
+
+点击顶部的加号,创建一个新的群组:
+
+
+
+找到右侧的「群设置」-「群机器人」- 点击「添加机器人」- 选择「自定义机器人」并按照提示创建。
+
+
+
+复制如上图的 WebHook 地址,并修改 Artalk 的 `webhook_url` 配置即可。
+
+
+
+可参考:[“飞书帮助中心文档”](https://www.feishu.cn/hc/zh-CN/articles/360024984973)
+
+## 钉钉
+
+```yaml
+admin_notify:
+ # 钉钉
+ ding_talk:
+ enabled: true
+ token: ""
+ secret: ""
+```
+
+可参考:[“钉钉开放文档”](https://open.dingtalk.com/document/robots/custom-robot-access)
+
+## Bark
+
+```yaml
+admin_notify:
+ # Bark
+ bark:
+ enabled: true
+ server: "http://day.app/xxxxxxx/"
+```
+
+[Bark](https://github.com/Finb/Bark) 是一款开源的 iOS App,并且[支持自托管](https://github.com/Finb/bark-server),你能使用 Bark 轻松地推送消息给你的 iOS 设备。
+
+你可以在 App Store 搜索下载,并获得需要填入 Artalk 的 `server` 配置项:
+
+
+
+
+
+## Slack
+
+```yaml
+admin_notify:
+ # Slack
+ slack:
+ enabled: true
+ oauth_token: ""
+ receivers:
+ - "CHANNEL_ID"
+```
+
+## LINE
+
+```yaml
+admin_notify:
+ # LINE
+ line:
+ enabled: true
+ channel_secret: ""
+ channel_access_token: ""
+ receivers:
+ - "USER_ID_1"
+ - "GROUP_ID_1"
+```
+
+## 通知模版
+
+```yaml
+admin_notify:
+ notify_tpl: "default"
+```
+
+配置项 `admin_notify.notify_tpl` 可设置为自定义通知模版「文件路径」,默认的通知模版为:
+
+```
+@{{reply_nick}}:
+
+{{reply_content}}
+
+{{link_to_reply}}
+```
+
+可用变量和邮件模板相同,可参考:[“邮件模版”](./email.md#邮件模板)
+
+## 嘈杂模式 `noise_mode`
+
+```yaml
+admin_notify:
+ noise_mode: false
+```
+
+noise_mode 默认为关闭状态,当该项设置为 `false` 时,站内仅向管理员回复的消息会发送通知,例如「普通用户 A」回复「普通用户 B」,这两个用户之间的通讯不会通知管理员。
+
+注:当 `moderator.pending_default` 为 `true` 时,noise_mode 为始终开启状态。
+
+## WebHook 回调
+
+开启 WebHook 后,创建新评论将以 **POST** 方式携带 `application/json` 类型的 Body 数据请求设定的 WebHook 地址。
+
+你可以编写自己的 Server 端代码,处理来自 Artalk 的请求。
+
+**Artalk 配置文件**
+
+```yaml
+admin_notify:
+ webhook:
+ enabled: true
+ url: "http://localhost:8080/"
+```
+
+**Body 数据内容**
+
+`application/json` 类型
+
+|Key|描述|类型|备注|
+| - | - | - | - |
+|`notify_subject`|通知标题 |String| 对应 admin_notify.notify_subject 配置项 |
+|`notify_body` |通知内容 |String| 根据 admin_notify.notify_tpl 模版渲染 |
+|`comment` |评论内容 |Object| 新创建的评论数据对象 |
+|`parent_comment`|评论回复的目标|Object| Root 根节点评论类型改项为 null |
+
+**Body 数据样本**
+
+```js
+{
+ "notify_subject": "",
+ "notify_body": "@测试用户:\n\n测试内容\n\nhttps://127.0.0.1/index.html?atk_comment=1057",
+ "comment": {
+ "id": 1057,
+ "content": "测试内容",
+ "user_id": 226,
+ "nick": "测试用户",
+ "email_encrypted": "654236c1e78i4c09a17c4869c9d43910",
+ "link": "https://qwqaq.com",
+ "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36",
+ "date": "2022-05-23 17:00:23",
+ "is_collapsed": false,
+ "is_pending": false,
+ "is_pinned": false,
+ "is_allow_reply": false,
+ "rid": 0,
+ "badge_name": "",
+ "badge_color": "",
+ "visible": true,
+ "vote_up": 0,
+ "vote_down": 0,
+ "page_key": "/index.html",
+ "page_url": "https://127.0.0.1/index.html",
+ "site_name": "ArtalkDocs"
+ },
+ "parent_comment": null
+}
+```
+
+**Node.js express 处理示例**
+
+```js
+const express = require('express');
+
+const app = express();
+
+app.use(express.json()); // Use JSON middleware
+
+app.post('/', function(request, response){
+ console.log(request.body);
+
+ const notifySubject = request.body.notify_subject
+ const notifyBody = request.body.notify_body
+ console.log(notifySubject, notifyBody);
+
+ response.send(request.body);
+});
+
+app.listen(8080);
+```
+
+**Node.js http 处理示例**
+
+```js
+const http = require("http");
+
+const requestListener = function (req, res) {
+ // receive json request
+ let body = "";
+ req.on("data", function (data) {
+ body += data;
+ });
+ req.on("end", function () {
+ let json = "";
+ try {
+ json = JSON.parse(body);
+ } catch {}
+
+ // do something with json
+ console.log(json);
+ res.end();
+ });
+
+ res.writeHead(200);
+ res.end("Hello, World!");
+};
+
+const server = http.createServer(requestListener);
+server.listen(8080);
+```
+
+**PHP Laravel 处理示例**
+
+```php
+Route::get('/', function (Request $request) {
+ $data = $request->json()->all();
+ $notify_subject = $data["notify_subject"];
+ $notify_body = $data["notify_body"];
+});
+```
+
+**Golang net/http 处理示例**
+
+```go
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+)
+
+type ArtalkNotify struct {
+ NotifySubject string `json:"notify_subject"`
+ NotifyBody string `json:"notify_body"`
+ Comment interface{} `json:"comment"`
+ ParentComment interface{} `json:"parent_comment"`
+}
+
+func webhookHandler(rw http.ResponseWriter, req *http.Request) {
+ decoder := json.NewDecoder(req.Body)
+ var notify ArtalkNotify
+ err := decoder.Decode(¬ify)
+ if err != nil {
+ panic(err)
+ }
+ log.Println(notify.NotifyBody)
+}
+
+func main() {
+ http.HandleFunc("/webhook", webhookHandler)
+ log.Fatal(http.ListenAndServe(":8080", nil))
+}
+```
+
diff --git a/docs/guide/backend/build.md b/docs/guide/backend/build.md
new file mode 100644
index 000000000..1876eee3c
--- /dev/null
+++ b/docs/guide/backend/build.md
@@ -0,0 +1,67 @@
+# 后端构建
+
+## 编译运行
+
+```bash
+# 拉取代码
+git clone https://github.com/ArtalkJS/Artalk.git Artalk
+
+# 编译程序
+cd Artalk && make all
+
+# 配置文件
+cp artalk.example.yml artalk.yml
+vim artalk.yml
+
+# 运行程序
+./bin/artalk help
+./bin/artalk -c artalk.yml server
+```
+
+## 构建二进制文件
+
+```bash
+# 拉取代码
+git clone https://github.com/ArtalkJS/Artalk.git
+
+# 执行编译
+make all
+```
+
+编译二进制文件将会输出到 `bin` 目录中
+
+## Docker Compose 编译运行
+
+```bash
+# 拉取代码
+git clone https://github.com/ArtalkJS/Artalk
+cd Artalk
+
+# 构建镜像
+docker-compose build
+
+# 运行
+docker-compose up -d
+```
+
+## Docker 镜像构建
+
+```bash
+# 拉取代码
+git clone https://github.com/ArtalkJS/Artalk
+cd Artalk
+
+# 构建镜像
+make docker-docker
+
+# 发布镜像
+make docker-push
+```
+
+## DevOps
+
+后端构建目前已交给 [GitHub Actions](https://github.com/ArtalkJS/Artalk/actions) 自动完成
+
+|Docker 镜像构建|Release 编译|
+|-|-|
+|[](https://github.com/ArtalkJS/Artalk/actions/workflows/dockerhub.yml)|[](https://github.com/ArtalkJS/Artalk/actions/workflows/release.yml)|
diff --git a/docs/guide/backend/captcha.md b/docs/guide/backend/captcha.md
new file mode 100644
index 000000000..a7b3fc313
--- /dev/null
+++ b/docs/guide/backend/captcha.md
@@ -0,0 +1,80 @@
+# 验证码
+
+Artalk 内置图片验证码功能,你可以配置操作频率限制,当超过限度时激活验证码。
+
+此外,你也可以接入[极验](https://www.geetest.com/),拥有一个滑动验证码。
+
+完整的 `captcha` 配置如下:
+
+```yaml
+# 验证码
+captcha:
+ enabled: true # 总开关
+ always: false # 总是需要验证码
+ action_limit: 3 # 激活验证码所需操作次数
+ action_reset: 60 # 重置操作计数器超时 (单位:s, 设为 -1 不重置)
+ # Geetest 极验
+ geetest: # https://www.geetest.com
+ enabled: false
+ captcha_id: ""
+ captcha_key: ""
+```
+
+- **always**:当该项为 `true` 时,总是需要输入验证码。
+- **action_limit**:激活评论所需的操作次数。
+- **action_reset**:当时间超过该值时会重置操作计数器,单位为秒,设为 `-1` 将永不重置。
+
+注:当 `always` 开启时,`action_limit` 和 `action_reset` 配置将失效。
+
+## 配置举例
+
+#### 例 1
+
+在 60s 时间范围内,当操作次数超过 3 次,将一直被要求输入验证码:
+
+```yaml
+captcha:
+ action_limit: 3
+ action_reset: 60
+```
+
+在 60s 后将自动重置计数器,即重新获得 3 次不用输入验证码的机会。
+
+#### 例 2
+
+无论多少时间范围内,这个 IP 地址操作次数只要超过 5 次时,将一直被要求输入验证码:
+
+```yaml
+captcha:
+ action_limit: 5
+ action_reset: -1
+```
+
+#### 例 3
+
+总是要求输入验证码,无论这个 IP 操作多少次:
+
+```yaml
+captcha:
+ always: true
+```
+
+## 操作的定义
+
+一个 IP 地址的一次「评论、投票、图片上传、密码验证」都算作一次「操作」。
+
+## Geetest 极验
+
+Artalk 支持接入 [Geetest 极验](https://www.geetest.com/adaptive-captcha) 第四代「行为验」,启用极验后,验证码将切换为滑动验证码。
+
+你需要在官网注册账号,并申请获得 `captcha_id` 和 `captcha_key`,并填入配置文件:
+
+```yaml
+captcha:
+ # 省略其他配置...
+ geetest:
+ enabled: true
+ captcha_id: ""
+ captcha_key: ""
+```
+
diff --git a/docs/guide/backend/config.md b/docs/guide/backend/config.md
new file mode 100644
index 000000000..a7a3cba00
--- /dev/null
+++ b/docs/guide/backend/config.md
@@ -0,0 +1,314 @@
+# 后端配置
+
+::: tip
+后端配置可以在侧边栏 “[控制中心](/guide/frontend/sidebar.md)” 直接修改,无需手动修改配置
+:::
+
+Artalk 默认以工作目录下的 `artalk.yml` 作为配置文件,可使用参数 `-c` 来指定具体文件:
+
+```bash
+artalk -c ./conf.yml
+```
+
+## 获取模版配置文件
+
+可参考一份「完整的配置文件」:[artalk.example.yml](https://github.com/ArtalkJS/Artalk/blob/master/artalk.example.yml)
+
+#### 使用 gen 命令生成配置文件
+
+Artalk 提供 `gen` 命令,你可以快速生成一份新的配置文件:
+
+```bash
+artalk gen conf ./artalk.yml
+```
+
+#### 命令行下载配置文件
+
+```bash
+curl -L https://raw.githubusercontent.com/ArtalkJS/Artalk/master/artalk.example.yml > artalk.yml
+```
+
+```bash
+wget -O artalk.yml https://raw.githubusercontent.com/ArtalkJS/Artalk/master/artalk.example.yml
+```
+
+## 加密密钥 `app_key`
+
+在 Artalk 启动之前,你需要配置一个 `app_key` 用于对网站内容进行安全加密:
+
+```yaml
+app_key: "<任意的字符>"
+```
+
+## 数据库 `db`
+
+Artalk 支持连接多种数据库,支持 SQLite、MySQL、PostgreSQL、SQL Server 配置如下:
+
+#### SQLite
+
+SQLite 是轻型数据库,使用单个文件存储数据,无需额外运行程序,尤其适合小型站点,例如个人博客。
+
+```yaml
+db:
+ type: "sqlite"
+ file: "./data/artalk.db"
+```
+
+#### MySQL / PostgreSQL / SQL Server
+
+修改 `type` 为你的数据库类型:
+
+```yaml
+db:
+ type: "mysql" # sqlite, mysql, pgsql, mssql
+ name: "artalk" # 数据库名
+ host: "localhost" # 地址
+ port: "3306" # 端口
+ user: "root" # 账号
+ password: "" # 密码
+ charset: "utf8mb4" # 编码格式
+ table_prefix: "" # 表前缀 (例如:"atk_")
+```
+
+数据表将在 Artalk 启动时自动完成创建,无需额外操作。
+
+## 管理员 `admin_users`
+
+你需要配置管理员账户,这样才能通过「[控制中心](../frontend/sidebar.md)」对站点内容进行管理。
+
+Artalk 支持多站点,你可以创建多个管理员账户,为其分配站点,让你的朋友们共用同一个后端程序。
+
+详情参考:[“管理员 × 多站点”](/guide/backend/multi-site.md)
+
+## 可信域名 `trusted_domains`
+
+```yaml
+trusted_domains:
+ - "https://前端使用域名A.com"
+ - "https://前端使用域名B.com"
+```
+
+配置该项能限制来自列表外的 Referer 和跨域请求。
+
+:::tip
+
+你需要将「使用该后端的前端」URL 地址加入可信域名列表中,
+
+若非默认 80/443 端口需额外附带端口号,例如:`https://example.com:8080`
+
+:::
+
+在侧边栏[控制中心](../frontend/sidebar.md#控制中心)「站点」选项卡 - 选择站点「修改 URL」,填入站点 URL 也具有相同的效果;添加多个 URL 可使用 `","` 英文逗号分隔,修改后请重启 Artalk。
+
+可以将其关闭:
+
+```yaml
+trusted_domains:
+ - "*"
+```
+
+::: danger
+
+但并不建议这样做,关闭后将存在潜在安全风险,例如可能遭受 CSRF 跨域攻击。
+
+:::
+
+细节:`trusted_domains` 配置项实际上是对响应标头:
+
+- `Access-Control-Allow-Origin` 的控制 (参考:[W3C Cross-Origin Resource Sharing](https://fetch.spec.whatwg.org/#http-cors-protocol))
+- `Referer` 的限制 (参考:[Referer - HTTP | MDN](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Referer))
+
+CSRF 跨域攻击防范措施参考:[OWASP 安全备忘单](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
+
+## 默认站点 `site_default`
+
+如果你觉得大概是不会用到 Artalk 的多站点功能,可以直接将该项配置为你的站点名,例如:
+
+```yaml
+site_default: "Artalk 官网"
+```
+
+然后在前端直接使用这个站点名:
+
+```js
+new Artalk({ site: "Artalk 官网" })
+```
+
+这样,你就无需在侧边栏的[控制中心](../frontend/sidebar.md#控制中心)手动创建站点。
+
+## 前端配置 `frontend`
+
+增加 `frontend` 字段内容可以在后端控制前端的配置,详情可参考:[“在后端控制前端”](/guide/backend/fe-control)。
+
+## 邮件通知 `email`
+
+配置邮件通知,让回复通过邮件的形式通知目标用户,你可以自定义邮件发送者名称、标题、模版等。
+
+详情参考:[“后端 · 邮件通知”](/guide/backend/email.md)
+
+## 多元推送 `admin_notify`
+
+你可以配置多种消息发送方式,例如飞书、Telegram 等,当收到新的评论时通知管理员。
+
+详情参考:[“后端 · 多元推送”](/guide/backend/admin_notify.md)
+
+## 评论审核 `moderator`
+
+配置评论审核来自动拦截垃圾评论。
+
+详情参考:[“后端 · 评论审核”](/guide/backend/moderator.md)
+
+## 验证码 `captcha`
+
+支持图片、滑动验证码,通过验证码对请求频率进行限制。
+
+详情参考:[“后端 · 验证码”](/guide/backend/captcha.md)
+
+## 缓存配置 `cache`
+
+为了提高评论系统的响应速度和性能,Artalk 内置一套缓存机制,并且默认开启,无需额外配置。但如果有需要,你也可以连接外部缓存服务器,支持 Redis 和 Memcache。
+
+```yaml
+cache:
+ type: "builtin" # 支持 redis, memcache, builtin (自带缓存)
+ expires: 30 # 缓存过期时间 (单位:分钟)
+ warm_up: false # 程序启动时预热缓存
+ server: "" # 连接缓存服务器 (例如:"localhost:6379")
+```
+
+- **warm_up**:缓存预热功能。设置为 `true`,在 Artalk 启动时会立刻对数据库内容进行全面缓存,如果你的评论数据较多,多达上万条,启动时间可能会延长。
+- **type**:缓存类型。可选:`redis`, `memcache`, `builtin`。
+
+type 默认为 `builtin`,如遇特殊情况可将缓存关闭,将其设置为 `disabled`。
+
+注:如果在 Artalk 程序外部修改数据库内容,需要刷新 Artalk 缓存才能更新。
+
+---
+
+Redis 身份认证、数据库配置:
+
+```yaml
+# 缓存
+cache:
+ # 省略其他配置项...
+ redis:
+ network: "tcp" # 连接方式 (tcp 或 unix)
+ username: "" # 用户名
+ password: "" # 密码
+ db: 0 # 使用零号数据库
+```
+
+技术细节:[Artalk 缓存机制 时序图.png](/images/artalk/artalk-cache.png)
+
+
+
+## 监听地址 `host`
+
+Artalk 的默认 HTTP 端口为 23366,你可以在配置文件中指定:
+
+```yaml
+host: "0.0.0.0"
+port: 23366
+```
+
+配置 `host` 监听地址为 `0.0.0.0` 将 Artalk 服务暴露到全网可访问范围,
+
+如果你只想让 Artalk 仅本地能够访问,可将 `host` 配置为 `127.0.0.1`。
+
+命令行下启动 Artalk 时,可以携带 `--host` 和 `--port` 参数分别对地址和端口进行指定,例如:
+
+```bash
+artalk server --host 127.0.0.1 --port 8080
+```
+
+## 加密传输 `ssl`
+
+```yaml
+ssl:
+ enabled: true
+ cert_path: ""
+ key_path: ""
+```
+
+你可以配置该项,让 HTTP 升级为 HTTPS,通过 SSL 协议加密传输数据。
+
+- `cert_path`:SSL 证书公钥文件路径。
+- `key_path`:SSL 证书私钥文件路径。
+
+你也可以直接反向代理 Artalk 本地服务器,然后在例如 Nginx 启用 HTTPS。
+
+## 时区配置 `timezone`
+
+```yaml
+timezone: "Asia/Shanghai"
+```
+
+该值填写你所在地时区,对应 IANA 数据库时区名,参考:[Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) / [RFC-6557](https://www.rfc-editor.org/rfc/rfc6557.html)。
+
+```
+UTC+08:00 Asia/Shanghai
+UTC+09:00 Asia/Tokyo
+UTC-07:00 America/Los_Angeles
+UTC-04:00 America/New_York
+```
+
+## 登录超时 `login_timeout`
+
+该值设定管理员账户登录 JWT 令牌的有效期,单位:秒。
+
+例如,3 天有效:
+
+```yaml
+login_timeout: 259200
+```
+
+## 日志配置 `log`
+
+打开日志后,系统错误等信息将被记录到设定的文件中。
+
+```yaml
+log:
+ enabled: true # 总开关
+ filename: "./data/artalk.log" # 日志文件路径
+```
+
+## 调试模式 `debug`
+
+将 `debug` 配置为 `true` 启用调试模式。
+
+```yaml
+debug: true
+```
+
+## 工作目录 `-w` 参数
+
+Artalk 在不指定工作目录的情况下,会使用「程序启动时的目录」作为工作目录。
+
+```bash
+pwd # 显示当前目录路径
+```
+
+使用参数 `-w` 来指定工作目录,它通常是一个「绝对路径」,例如:
+
+```bash
+artalk -w /root/artalk -c ./conf.yml
+```
+
+注:`-c` 的相对路径会基于 `-w` 的路径,Artalk 此时会读取 `/root/artalk/conf.yml` 作为配置文件。
+
+其次,在「配置文件中」使用的「相对路径」,也会基于「工作目录」。
+
+例如 `conf.yml` 中有这样的配置:
+
+```yaml
+test_file: "./data/artalk.log"
+```
+
+将读取 `/root/artalk/data/artalk.log`。
+
+::: tip
+
+配置文件相关代码:[/config/config.go](https://github.com/ArtalkJS/Artalk/blob/master/config/config.go)
+
+前往:[“前端配置”](/guide/frontend/config.md)
+:::
diff --git a/docs/guide/backend/daemon.md b/docs/guide/backend/daemon.md
new file mode 100644
index 000000000..b8ae92db7
--- /dev/null
+++ b/docs/guide/backend/daemon.md
@@ -0,0 +1,75 @@
+# 守护进程
+
+## Docker
+
+更新 Docker 容器的 [Restart 策略](https://docs.docker.com/config/containers/start-containers-automatically/#use-a-restart-policy) 以达到进程守护效果。
+
+```bash
+docker update --restart=unless-stopped artalk
+```
+
+## Docker Compose
+
+在 `docker-compose.yml` 文件给服务添加 `restart: unless-stopped` 策略:
+
+```diff
+version: '3'
+services:
+ artalk:
++ restart: unless-stopped
+```
+
+## tmux
+
+tmux 将创建一个持续的命令行会话,在 SSH 或 tty 断开后保持在后台。
+
+Note: 服务器关闭或重启后,tmux 会话将被清除,需要手动重新运行程序。
+
+1. 创建会话 `tmux new -s artalk`
+2. 运行程序 `./artalk server`
+
+恢复接入会话:`tmux attach -t artalk`
+
+查看所有会话:`tmux ls`
+
+## systemd
+
+`sudo vim /etc/systemd/system/artalk.service`
+
+```ini
+[Unit]
+Description=Artalk
+After=network.target remote-fs.target nss-lookup.target
+
+[Service]
+User=root
+ExecStart= server -w <工作目录绝对路径> -c <配置文件相对于工作目录路径>
+ExecReload=/bin/kill -s HUP $MAINPID
+ExecStop=/bin/kill -s QUIT $MAINPID
+Restart=on-abnormal
+RestartSec=5s
+
+[Install]
+WantedBy=multi-user.target
+```
+- 更新 systemd 配置:`systemctl daemon-reload`
+- 启动:`systemctl start artalk.service`
+- 停止:`systemctl stop artalk.service`
+- 状态:`systemctl status artalk.service`
+
+Tip: 设置 `alias` 简化命令输入;Artalk 参数 `-w` 用于指定工作目录,配置文件中的所有「相对路径」会基于该目录,例如 `./data/` 文件夹。
+
+## Supervisor
+
+以宝塔面板举例:打开「软件商店」,搜索并安装「Supervisor管理器」:
+
+
+
+安装后,打开插件,点击「添加守护程序」:
+
+
+
+> - 启动用户:`root` 或其他
+> - 运行目录:点击右侧图标,选择 Artalk 所在目录
+> - 启动命令:`./artalk server`
+
diff --git a/docs/guide/backend/data.md b/docs/guide/backend/data.md
new file mode 100644
index 000000000..e63d70da3
--- /dev/null
+++ b/docs/guide/backend/data.md
@@ -0,0 +1,5 @@
+# 数据
+
+## 备份
+
+前往:[“数据迁移 · 备份”](/guide/transfer.md)
diff --git a/docs/guide/backend/docker.md b/docs/guide/backend/docker.md
new file mode 100644
index 000000000..9c5d92db7
--- /dev/null
+++ b/docs/guide/backend/docker.md
@@ -0,0 +1,115 @@
+# Docker
+
+Artalk 提供后端程序的 Docker 镜像,以便加速部署流程,提供一个良好的部署体验。
+
+[Docker Hub](https://hub.docker.com/r/artalk/artalk) 镜像版本随代码仓库的 [Releases](https://github.com/ArtalkJS/Artalk/releases) 保持同步。
+
+## 镜像拉取
+
+`docker pull artalk/artalk`
+
+## 容器创建
+
+:::tip
+
+推荐使用 Docker Compose:[“后端部署”](/guide/backend/install) 页面已详细讲解。
+
+:::
+
+常规的 Docker 容器创建可参考:
+
+```bash
+# 为 Artalk 创建一个目录
+mkdir Artalk
+cd Artalk
+
+# 拉取 docker 镜像
+docker pull artalk/artalk
+
+# 生成配置文件
+docker run -it -v $(pwd)/data:/data --rm artalk/artalk gen config data/artalk.yml
+
+# 编辑配置文件
+vim data/artalk.yml
+
+# 运行 docker 容器
+docker run -d \
+ --name artalk \
+ -p 0.0.0.0:8080:23366 \
+ -v $(pwd)/data:/data \
+ artalk/artalk
+```
+
+然后,在前端配置填入后端地址:
+
+```js
+new Artalk({ server: "http://your_domain:8080" })
+```
+
+## 重启
+
+修改配置文件后,需要重启才能生效。
+
+```bash
+# Docker Compose
+docker-compose restart
+
+# Docker
+docker restart artalk
+```
+
+## 停止
+
+```bash
+# Docker Compose
+docker-compose stop
+
+# Docker
+docker stop artalk
+```
+
+## 升级
+
+删除现有容器,拉取最新镜像,然后重新创建容器即可。
+
+### Docker Compose
+
+```bash
+docker-compose down
+docker-compose pull
+docker-compose up -d
+```
+
+### Docker
+
+```bash
+docker stop artalk
+docker rm artalk
+docker pull artalk/artalk
+```
+
+::: tip
+升级可能会有配置文件等变动,请注意查看版本 Changelog,通常是在 [GitHub Release](https://github.com/ArtalkJS/Artalk/releases) 页面
+:::
+
+## 拉取历史镜像
+
+镜像会随代码仓库 tags 自动构建发布,您可拉取不同版本号的镜像。
+
+```bash
+docker pull artalk/artalk@版本号
+```
+
+## 进入容器
+
+```bash
+# Docker Compose
+docker-compose exec artalk bash
+
+# Docker
+docker exec -it artalk bash
+```
+
+## 多平台兼容性
+
+Docker 镜像暂仅提供 amd_64 构建,若需要在 ARM 架构运行,请下载 [二进制编译构建](/guide/backend/install.md#普通方式)
diff --git a/docs/guide/backend/email.md b/docs/guide/backend/email.md
new file mode 100644
index 000000000..7c4733546
--- /dev/null
+++ b/docs/guide/backend/email.md
@@ -0,0 +1,325 @@
+# 邮件通知
+
+启用后,当用户「新增评论」或「回复评论」时,Artalk 会向「普通用户」或「管理员」发送邮件。
+
+Artalk 支持 SMTP 协议、阿里云邮件推送、调用系统 sendmail 命令等。
+
+完整的 `email` 配置如下:
+
+```yaml
+# 邮件通知
+email:
+ enabled: false # 总开关
+ send_type: "smtp" # 发送方式 [smtp, ali_dm, sendmail]
+ send_name: '{{reply_nick}}' # 发信人昵称
+ send_addr: "example@qq.com" # 发信人地址
+ mail_subject: "[{{site_name}}] 您收到了来自 @{{reply_nick}} 的回复"
+ mail_tpl: "default" # 邮件模板文件
+ smtp:
+ host: "smtp.qq.com"
+ port: 587
+ username: "example@qq.com"
+ password: ""
+ ali_dm: # https://help.aliyun.com/document_detail/29444.html
+ access_key_id: "" # 阿里云颁发给用户的访问服务所用的密钥 ID
+ access_key_secret: "" # 用于加密的密钥
+ account_name: "example@example.com" # 管理控制台中配置的发信地址
+```
+
+### 选择发件方式
+
+配置项 `enabled` 启用邮件,`send_type` 用于选择发送方式,可选:`smtp`, `ali_dm`, `sendmail`。
+
+```yaml
+email:
+ enabled: true
+ send_type: "smtp" # 发送方式
+ # 省略其他配置...
+ smtp:
+ # SMTP 配置...
+ ali_dm:
+ # 阿里云推送配置...
+```
+
+### SMTP 配置
+
+```yaml
+# 邮件通知
+email:
+ enabled: true
+ send_type: "smtp" # 选择 smtp
+ smtp:
+ host: "smtp.qq.com"
+ port: 587
+ username: "example@qq.com"
+ password: ""
+```
+
+### 阿里云推送配置
+
+```yaml
+email:
+ enabled: true
+ send_type: "ali_dm" # 选择 ali_dm
+ ali_dm:
+ access_key_id: "" # 阿里云颁发给用户的访问服务所用的密钥 ID
+ access_key_secret: "" # 用于加密的密钥
+ account_name: "example@example.com" # 管理控制台中配置的发信地址
+```
+
+可参考:[阿里云官方文档](https://help.aliyun.com/document_detail/29444.html)
+
+## 评论回复
+
+邮件中会有一个评论回复按钮,该链接指向前端给定的页面 PageKey,若你提供的 `pageKey` 配置项为页面的「相对路径」,你需要在「[控制中心](../frontend/sidebar.md#控制中心)」-「站点」为你的站点设置一个 URL:
+
+
+
+## 邮件模板
+
+### 模板变量
+
+你可以在 `mail_subject` 和 `mail_subject_to_admin` 以及邮件模板文件中使用模板变量:
+
+```yaml
+email:
+ # 省略其他配置...
+ mail_subject: "[{{site_name}}] 您收到了来自 @{{reply_nick}} 的回复"
+ mail_subject_to_admin: '[{{site_name}}] 您的文章 "{{page_title}}" 有新回复'
+ mail_tpl: "default" # 邮件模板文件
+```
+
+变量是 “Mustache” 的语法,`双大括号` + `变量名` 的形式即可输出一个变量:
+
+**基本内容变量**
+
+```
+{{content}} # 评论内容
+{{link_to_reply}} # 回复链接
+{{nick}} # 评论者昵称
+{{page_title}} # 页面标题
+{{page_url}} # 页面 PageKey (URL)
+{{reply_content}} # 回复对象的内容
+{{reply_nick}} # 回复对象的昵称f
+{{site_name}} # 站点名
+{{site_url}} # 站点 URL
+```
+
+::: details 查看其他变量
+
+```
+# 评论创建者
+{{comment.badge_color}}
+{{comment.badge_name}}
+{{comment.content}}
+{{comment.content_raw}}
+{{comment.date}}
+{{comment.datetime}}
+{{comment.email}}
+{{comment.email_encrypted}}
+{{comment.id}}
+{{comment.is_allow_reply}}
+{{comment.is_collapsed}}
+{{comment.is_pending}}
+{{comment.link}}
+{{comment.nick}}
+{{comment.page.admin_only}}
+{{comment.page.id}}
+{{comment.page.key}}
+{{comment.page.site_name}}
+{{comment.page.title}}
+{{comment.page.url}}
+{{comment.page.vote_down}}
+{{comment.page.vote_up}}
+{{comment.page_key}}
+{{comment.page_title}}
+{{comment.rid}}
+{{comment.site.first_url}}
+{{comment.site.id}}
+{{comment.site.name}}
+{{comment.site.urls.0}}
+{{comment.site.urls_raw}}
+{{comment.site_name}}
+{{comment.time}}
+{{comment.ua}}
+{{comment.visible}}
+{{comment.vote_down}}
+{{comment.vote_up}}
+
+# 父评论(评论创建者回复的评论)
+{{parent_comment.badge_color}}
+{{parent_comment.badge_name}}
+{{parent_comment.content}}
+{{parent_comment.content_raw}}
+{{parent_comment.date}}
+{{parent_comment.datetime}}
+{{parent_comment.email}}
+{{parent_comment.email_encrypted}}
+{{parent_comment.id}}
+{{parent_comment.is_allow_reply}}
+{{parent_comment.is_collapsed}}
+{{parent_comment.is_pending}}
+{{parent_comment.link}}
+{{parent_comment.nick}}
+{{parent_comment.page.admin_only}}
+{{parent_comment.page.id}}
+{{parent_comment.page.key}}
+{{parent_comment.page.site_name}}
+{{parent_comment.page.title}}
+{{parent_comment.page.url}}
+{{parent_comment.page.vote_down}}
+{{parent_comment.page.vote_up}}
+{{parent_comment.page_key}}
+{{parent_comment.page_title}}
+{{parent_comment.rid}}
+{{parent_comment.site.first_url}}
+{{parent_comment.site.id}}
+{{parent_comment.site.name}}
+{{parent_comment.site.urls}}
+{{parent_comment.site.urls_raw}}
+{{parent_comment.site_name}}
+{{parent_comment.time}}
+{{parent_comment.ua}}
+{{parent_comment.visible}}
+{{parent_comment.vote_down}}
+{{parent_comment.vote_up}}
+```
+
+:::
+
+::: details 查看数据样例
+
+```json
+{
+ "content": "测试内容
\n",
+ "link_to_reply": "https://artalk.js.org/?atk_comment=8100&atk_notify_key=44a9b2f08312565fba47c716df9d177f",
+ "nick": "用户名",
+ "page_title": "Artalk",
+ "page_url": "https://artalk.js.org/",
+
+ "reply_content": "回复者内容
\n",
+ "reply_nick": "回复者",
+ "site_name": "ArtalkDemo",
+ "site_url": "http://localhost:3000/",
+
+ "comment.badge_color": "",
+ "comment.badge_name": "",
+ "comment.content": "回复者内容
\n",
+ "comment.content_raw": "回复者内容",
+ "comment.date": "2021-11-22",
+ "comment.datetime": "2021-11-22 22:22:42",
+ "comment.email": "replyer@example.com",
+ "comment.email_encrypted": "249898bd50e0febc5799485cf10b345a",
+ "comment.id": 8100,
+ "comment.is_allow_reply": true,
+ "comment.is_collapsed": false,
+ "comment.is_pending": false,
+ "comment.link": "",
+ "comment.nick": "回复者",
+ "comment.page.admin_only": false,
+ "comment.page.id": 75,
+ "comment.page.key": "https://artalk.js.org/",
+ "comment.page.site_name": "ArtalkDemo",
+ "comment.page.title": "Artalk",
+ "comment.page.url": "https://artalk.js.org/",
+ "comment.page.vote_down": 0,
+ "comment.page.vote_up": 0,
+ "comment.page_key": "https://artalk.js.org/",
+ "comment.page_title": "Artalk",
+ "comment.rid": 8099,
+ "comment.site.first_url": "http://localhost:3000/",
+ "comment.site.id": 2,
+ "comment.site.name": "ArtalkDemo",
+ "comment.site.urls.0": "http://localhost:3000/",
+ "comment.site.urls_raw": "http://localhost:3000/",
+ "comment.site_name": "ArtalkDemo",
+ "comment.time": "22:22:42",
+ "comment.ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
+ "comment.visible": false,
+ "comment.vote_down": 0,
+ "comment.vote_up": 0,
+
+ "parent_comment.badge_color": "",
+ "parent_comment.badge_name": "",
+ "parent_comment.content": "测试内容
\n",
+ "parent_comment.content_raw": "测试内容",
+ "parent_comment.date": "2021-11-22",
+ "parent_comment.datetime": "2021-11-22 22:21:17",
+ "parent_comment.email": "test@example.com",
+ "parent_comment.email_encrypted": "55502f40dc8b7c769880b10874abc9d0",
+ "parent_comment.id": 8099,
+ "parent_comment.is_allow_reply": true,
+ "parent_comment.is_collapsed": false,
+ "parent_comment.is_pending": false,
+ "parent_comment.link": "https://qwqaq.com",
+ "parent_comment.nick": "用户名",
+ "parent_comment.page.admin_only": false,
+ "parent_comment.page.id": 75,
+ "parent_comment.page.key": "https://artalk.js.org/",
+ "parent_comment.page.site_name": "ArtalkDemo",
+ "parent_comment.page.title": "Artalk",
+ "parent_comment.page.url": "https://artalk.js.org/",
+ "parent_comment.page.vote_down": 0,
+ "parent_comment.page.vote_up": 0,
+ "parent_comment.page_key": "https://artalk.js.org/",
+ "parent_comment.page_title": "Artalk",
+ "parent_comment.rid": 0,
+ "parent_comment.site.first_url": "http://localhost:3000/",
+ "parent_comment.site.id": 2,
+ "parent_comment.site.name": "ArtalkDemo",
+ "parent_comment.site.urls.0": "http://localhost:3000/",
+ "parent_comment.site.urls_raw": "http://localhost:3000/",
+ "parent_comment.site_name": "ArtalkDemo",
+ "parent_comment.time": "22:21:17",
+ "parent_comment.ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36",
+ "parent_comment.visible": false,
+ "parent_comment.vote_down": 0,
+ "parent_comment.vote_up": 0,
+}
+```
+
+:::
+
+### 自定义模板
+
+你可以将 `mail_tpl` 设置为一个「具体的文件路径」,使用外部的自定义邮件模板。
+
+例如,将 `mail_tpl` 配置为 `"/root/Artalk/data/mail_tpl/your_email_template.html"`
+
+```yaml
+email:
+ mail_tpl: "/root/Artalk/data/mail_tpl/your_email_template.html"
+ # 其他配置省略...
+```
+
+那么,在这个路径应该有一个文件:
+
+```html
+
+
Hi, {{nick}}:
+
+ 您在 “{{page_title}}” 收到了回复:
+
+
+
@{{reply_nick}}:
+
{{reply_content}}
+
+
回复消息 »
+
+```
+
+Artalk 内置许多预设的邮件模板,例如 `mail_tpl: "default"` 使用的就是:[/email-tpl/default.html](https://github.com/ArtalkJS/Artalk/blob/master/email-tpl/default.html)
+
+## 发向管理员的邮件
+
+邮件通知目标为管理员和普通用户,你可通过如下配置,为发向管理员的邮件设定不同的标题:
+
+```yaml
+admin_notify:
+ enabled: true
+ mail_subject: "[{{site_name}}] 您的文章「{{page_title}}」有新回复"
+```
+
+注:旧版 `email.mail_subject_to_admin` 配置项已弃用,请使用以上替代。
+
+不局限于邮件,Artalk 支持多种方式向管理员发送通知,参考:[“多元推送”](./admin_notify.md#邮件通知)。
diff --git a/docs/guide/backend/fe-control.md b/docs/guide/backend/fe-control.md
new file mode 100644
index 000000000..9ec720d27
--- /dev/null
+++ b/docs/guide/backend/fe-control.md
@@ -0,0 +1,60 @@
+# 在后端控制前端
+
+你可以在后端完全控制前端的行为,我们推荐使用这种方式部署 Artalk。
+
+## 在前端引入内置的资源
+
+后端 Artalk 服务器中内置了可供前端引入的 JS 和 CSS 资源文件:
+
+```html
+
+
+
+
+
+```
+
+> 提示:将 `` 替换为你的 Artalk 服务器地址。
+
+这样如果升级后端 Artalk 程序,前端无需更换新版 ArtalkJS 的引入地址,来使之与后端程序兼容。
+
+注:内置的前端 JS 和 CSS 始终与后端版本兼容,但不保证是最新的版本。
+
+## 在后端控制前端的配置
+
+你能够在后端控制 [前端的配置](/guide/frontend/config)。
+
+这个功能处于「默认关闭」状态,首先你需要在前端启用它:
+
+```diff
+new Artalk({
++ useBackendConf: true,
+})
+```
+
+然后,在后端 Artalk 的配置文件中添加 `frontend` 字段内容,例如 `artalk.yml` 添加:
+
+```yaml
+frontend:
+ placeholder: "键入内容..."
+ noComment: "「此时无声胜有声」"
+ sendBtn: "发送评论"
+ emoticons: "https://raw.githubusercontent.com/ArtalkJS/Emoticons/master/grps/default.json"
+ # ----- 此处省略 -------
+ # 与前端配置项名称保持一致
+```
+
+这样就无需在前端改动配置,前端的配置始终跟随后端。
+
+一份完整的后端 `frontend` 字段配置文件可供参考:[artalk.frontend.example.yml](https://github.com/ArtalkJS/Artalk/blob/master/artalk.frontend.example.yml)
+
+::: tip
+
+如果你的表情包配置项 [emoticons](/guide/frontend/emoticons) 需传递 Object 而非 URL,可以将其转为 JSON 字符串,例如:
+
+```yaml
+frontend:
+ emoticons: '{"表情": { "test": "tttt..." }}'
+```
+
+:::
diff --git a/docs/guide/backend/img-upload.md b/docs/guide/backend/img-upload.md
new file mode 100644
index 000000000..41f8f0aea
--- /dev/null
+++ b/docs/guide/backend/img-upload.md
@@ -0,0 +1,90 @@
+# 图片上传
+
+Artalk 提供图片上传功能,支持限制图片大小、上传频率等,你还能结合 upgit 将图片上传到图床。
+
+完整的 `img-upload` 配置如下:
+
+```yaml
+# 图片上传
+img_upload:
+ enabled: true # 总开关
+ path: "./data/artalk-img/" # 图片存放路径
+ max_size: 5 # 图片大小限制 (单位:MB)
+ public_path: null # 指定图片链接基础路径 (默认为 "/static/images/")
+ # 使用 upgit 将图片上传到 GitHub 或图床
+ upgit:
+ enabled: false # 启用 upgit
+ exec: "./upgit -c -t /artalk-img"
+ del_local: true # 上传后删除本地的图片
+```
+
+## 使用 Upgit 上传到图床
+
+[Upgit](https://github.com/pluveto/upgit) 支持将图片上传到 Github、Gitee、腾讯云 COS、七牛云、又拍云、SM.MS 等图床或代码仓库。
+
+首先,根据 [README.md](https://github.com/pluveto/upgit) 的说明,下载 Upgit 并完成你需要上传的目标图床的配置。
+
+然后在 Artalk 的 `img_upload.upgit` 字段填入 Upgit 启动参数 (建议使用程序绝对路径),例如:
+
+```yaml
+ upgit:
+ enabled: true # 启用 upgit
+ exec: "/root/upgit -c -t /artalk-img"
+ del_local: true # 上传后删除本地的图片
+```
+
+## 上传频率限制
+
+频率限制跟随 `captcha` 验证码配置,当超出限制将弹出验证码。
+
+可参考:[“后端 · 验证码”](/guide/backend/captcha.md)
+
+## path
+
+`img_upload.path` 为上传的图片文件「本地存放目录」路径,该目录会被 Artalk 映射到可访问的:
+
+```
+http://<后端地址>/static/images/
+```
+
+## public_path
+
+`img.public_path` 为空的默认值为:`/static/images/`
+
+当该项为「相对路径」时,例如:`/static/images/` 前端上传图片得到的 HTML 标签将为:
+
+```html
+
+```
+
+注:这里的 `<后端地址>` 是前端 `conf.server` 配置。
+
+当该项为「完整 URL 路径」时,例如:`https://cdn.github.com/img/` 时,图片标签将为:
+
+```html
+
+```
+
+提示:这个配置可以结合负载均衡等场景使用。
+
+## 在前端自定义上传 API
+
+前端提供了配置项 `imgUploader`,你可以自定义前端图片上传时请求的 API,例如:
+
+```js
+new Artalk({
+ imgUploader: async (file) => {
+ const form = new FormData()
+ form.set('file', file)
+
+ const imgUrl = await fetch("https://api.example.org/upload", {
+ method: 'POST',
+ body: form
+ })
+
+ return imgUrl
+ }
+})
+```
+
+参考:[前端配置文档](../frontend/config.md#imguploader)
diff --git a/docs/guide/backend/index.md b/docs/guide/backend/index.md
new file mode 100644
index 000000000..9ae482b04
--- /dev/null
+++ b/docs/guide/backend/index.md
@@ -0,0 +1,22 @@
+---
+prev: install.md
+next: config.md
+---
+
+# Artalk
+
+Artalk 拥有一个 Golang 语言编写的后端程序。
+
+[GitHub 代码仓库](https://github.com/ArtalkJS/Artalk)
+
+- 高效快速
+- 异步执行
+- 跨平台兼容
+- 轻量级部署
+
+## Supports
+
+- 运行环境:支持 Linux, Windows, Darwin (x64 + ARM)
+- 数据存储:支持 SQLite, MySQL, PostgreSQL, SQL Server
+- 邮件发送:支持 SMTP, 阿里云邮件, 调用 sendmail 发送邮件
+- 高效缓存:支持 Redis, Memcache, In-Memory (BigCache)
diff --git a/docs/guide/backend/install.md b/docs/guide/backend/install.md
new file mode 100644
index 000000000..c025ca421
--- /dev/null
+++ b/docs/guide/backend/install.md
@@ -0,0 +1,7 @@
+---
+next: '/guide/backend/config.md'
+---
+
+# 后端部署
+
+参考:[程序部署](../deploy.md)
diff --git a/docs/guide/backend/moderator.md b/docs/guide/backend/moderator.md
new file mode 100644
index 000000000..18ac6a9bf
--- /dev/null
+++ b/docs/guide/backend/moderator.md
@@ -0,0 +1,112 @@
+# 评论审核
+
+完整的 `moderator` 配置如下:
+
+```yaml
+# 评论审核
+moderator:
+ pending_default: false # 发表新评论默认为 “待审状态”
+ api_fail_block: false # 垃圾检测 API 请求错误仍然拦截
+ # akismet.com 反垃圾
+ akismet_key: ""
+ # 腾讯云文本内容安全 (tms)
+ tencent: # https://cloud.tencent.com/document/product/1124/64508
+ enabled: false
+ secret_id: ""
+ secret_key: ""
+ region: "ap-guangzhou"
+ # 阿里云内容安全
+ aliyun: # https://help.aliyun.com/document_detail/28417.html
+ enabled: false
+ access_key_id: ""
+ access_key_secret: ""
+ region: "cn-shanghai"
+ # 关键词词库过滤
+ keywords:
+ enabled: false
+ pending: false # 匹配成功设为待审状态
+ files: # 支持多个词库文件
+ - "./data/词库_1.txt"
+ file_sep: "\n" # 词库文件内容分割符
+ replac_to: "x" # 替换字符
+```
+
+## 默认待审模式
+
+开启发表新评论默认为 “待审状态”:
+
+```yaml
+moderator:
+ pending_default: true
+```
+
+## Akismet
+
+[Akismet](https://akismet.com/) 是 WordPress 提供的面向全球范围的老牌垃圾拦截 API,通常对一些英文的垃圾评论十分凑效。Akismet 提供了 Personal 免费版本,适用于个人博客站点。
+
+
+
+你能在 [Akismet 官网](https://akismet.com/) 轻松地申请 `akismet_key`,并填入配置文件中,即可启用 Akismet 垃圾拦截。
+
+
+
+```yaml
+moderator:
+ akismet_key: "your_key"
+```
+
+## 腾讯云文本内容安全
+
+可参考:[“腾讯云文档”](https://cloud.tencent.com/document/product/1124/64508)
+
+开通「文本内容安全」后,在「访问管理」-「API 密钥管理」新增具有权限的 Secret,然后填入配置:
+
+```yaml
+moderator:
+ tencent:
+ enabled: true
+ secret_id: ""
+ secret_key: ""
+ region: "ap-guangzhou"
+```
+
+## 阿里云内容安全
+
+可参考:[“阿里云文档”](https://help.aliyun.com/document_detail/28417.html)
+
+开通「阿里云内容安全」后,阿里云后台创建 Access Key 并填入配置:
+
+```yaml
+moderator:
+ aliyun:
+ enabled: true
+ access_key_id: ""
+ access_key_secret: ""
+ region: "cn-shanghai"
+```
+
+## 关键词词库过滤
+
+如果你不想依赖于远程 API,可以在本地配置导入词库文件,让 Artalk 根据词语来检测垃圾评论:
+
+```yaml
+moderator:
+ keywords:
+ enabled: true
+ pending: false # 匹配成功设为待审状态
+ files: # 支持多个词库文件
+ - "./data/词库_1.txt"
+ file_sep: "\n" # 词库文件内容分割符
+ replac_to: "x" # 替换字符
+```
+
+- **pending**:当成功匹配时,是否将评论设为待审核状态。
+- **files**:词库文件。允许多个文件,Artalk 启动时会合并词库。
+- **file_sep**:词库文件内容分割符。例如:文件中每行一个词语,该项配置 `\n`。
+- **replac_to**:替换字符。例如:该项设置为 `x`,你可以将 `pending` 设置为 `false`,评论自动过审,但匹配到的词语会被替换为 `x`,例如 `fxxk`、`xxxx`。
+
+注:`replac_to` 不建议使用 `*` 星号,应为它和 Markdown 的加粗语法冲突。
+
+## 使用验证码
+
+你可以开启 Artalk 的验证码功能,支持图片和滑动验证码,[参考此处](./captcha.md)。
diff --git a/docs/guide/backend/multi-site.md b/docs/guide/backend/multi-site.md
new file mode 100644
index 000000000..c85d208a3
--- /dev/null
+++ b/docs/guide/backend/multi-site.md
@@ -0,0 +1,144 @@
+# 管理员 × 多站点
+
+Artalk 支持站点隔离,以及为站点分配指定的管理员账户,
+
+所以,你可以实现两种模式:单人多站点、多人多站点。
+
+运用「单人多站点」模式,你能够实现多个站点共用同一个 Artalk 后端程序,集中化管理。
+
+运用「多人多站点」模式,你可以和你的朋友们共用同一个 Artalk 后端程序。😉
+
+## 管理员配置
+
+你可以设置多个管理员账户,当输入框输入匹配管理员用户名和邮箱时,将弹出密码验证提示框,
+
+并且只有管理员才能访问侧边栏「[控制中心](../frontend/sidebar.md#控制中心)」,在前端对评论内容进行管理操作。
+
+在配置文件中添加如下内容:
+
+```yaml
+admin_users:
+ - name: "admin"
+ email: "admin@example.com"
+ password: ""
+ badge_name: "管理员"
+ badge_color: "#FF6C00"
+ - name: "admin2"
+ email: "admin2@example.com"
+ password: ""
+ badge_name: "小管理员"
+ badge_color: "#FF6C00"
+```
+
+每一项配置的解释:
+
+- **name** & **email**:用户名和邮箱,**不区分大小写**。
+- **password**:用户密码。
+
+ 支持 bcrypt 和 md5 加密,例如填写:`"(md5)50c21190c6e4e5418c6a90d2b5031119"`。
+
+ **建议使用更安全的 bcrypt 加密算法**,在 Linux 环境下,你可以使用 [htpasswd 命令](https://httpd.apache.org/docs/2.4/programs/htpasswd.html) 来生成密文:
+
+ ```bash
+ unset HISTFILE # 临时禁用 history 防止密码在历史记录中出现
+ htpasswd -bnBC 10 "" "your_password" | tr -d ':'
+ ```
+
+ 然后配置填写:`"(bcrypt)$2y$10$ti4vZYIrxVN8rLcY..."`,以 `(bcrypt)` 开头。
+
+ 命令解释参考:[“Compute bcrypt hash from command line”](https://unix.stackexchange.com/questions/307994/compute-bcrypt-hash-from-command-line#answer-419855)
+
+- **badge_name**:用户显示的头衔徽标文字。
+- **badge_color**:用户显示的头衔徽标背景颜色。
+
+## 站点的创建和管理
+
+你可以在侧边栏「[控制中心](../frontend/sidebar.md#控制中心)」创建多个站点,管理站点和快速切换站点,详情可参考:[“侧边栏”](/guide/frontend/sidebar.html)。
+
+## 超级管理员、普通管理员
+
+管理员分为「超级管理员」和「普通管理员」,前者可以管理全部站点,后者只能管理部分站点。
+
+普通管理员将:
+
+ - 不会收到其它站点的评论邮件通知,邮件通知彼此隔离。
+ - 不能对设定范围外的站点进行 删除、修改、查询 等操作。
+ - 仅能在受限模式下使用 数据导入导出、站点管理 等功能。
+
+完整的 `admin_users` 配置如下:
+
+```yaml
+admin_users:
+ - name: "超级管理员"
+ email: "super_admin@example.com"
+ password: "(bcrypt)$2y$10$ti4vZYIrxVN8rLcYXVgXCO.GJND0dyI49r7IoF3xqIx8bBRmIBZRm"
+ badge_name: "超级管理员"
+ badge_color: "#FF6C00"
+ receive_email: false
+
+ - name: "普通管理员1"
+ email: "admin1@example.com"
+ password: "(bcrypt)$2y$10$ti4vZYIrxVN8rLcYXVgXCO.GJND0dyI49r7IoF3xqIx8bBRmIBZRm"
+ badge_name: "管理员"
+ badge_color: "#FF6C00"
+ sites:
+ - 站点名 A
+ - 站点名 B
+
+ - name: "普通管理员2"
+ email: "admin2@example.com"
+ password: "(bcrypt)$2y$10$ti4vZYIrxVN8rLcYXVgXCO.GJND0dyI49r7IoF3xqIx8bBRmIBZRm"
+ badge_name: "管理员"
+ badge_color: "#FF6C00"
+ sites:
+ - 站点名 C
+ - 站点名 D
+```
+
+### 为管理员分配站点
+
+- **sites**:向管理员分配站点,即控制管理员对于站点「[控制中心](../frontend/sidebar.md#控制中心)」的可访问权限。
+
+ 当未分配站点时,即 sites 未配置时,该账户为「**超级管理员**」,可以管理全部站点:
+
+ ```yaml
+ admin_users:
+ - name: "super_admin"
+ # 未分配 sites,为超级管理员
+ ```
+
+ 当分配站点后,即 sites 已配置时,该管理员成为「**普通管理员**」:
+
+ ```yaml
+ admin_users:
+ - name: "a_admin_user"
+ # ↓ 为账户分配 sites
+ sites:
+ - 站点名 A
+ - 站点名 B
+ ```
+
+ 注:你至少需要一个超级管理员,这样才能创建站点。
+
+### 控制管理员接收邮件通知
+
+当页面有新的评论时,邮件会发送给**该站点**的全部管理员,但你可以配置 `receive_email` 强制禁用它。
+
+这对于设定了多个邮箱,但又不希望某些邮箱收到评论邮件的情况很有帮助。
+
+- **receive_email**:设置为 `false` 系统将不会发送邮件通知给该用户。
+
+ 注:禁止后该管理员用户仍可以收到来自他人的 @AT 回复,只是当用户对页面进行评论时 (创建根评论时) 不发送邮件通知。
+
+```yaml
+admin_users:
+ - name: "super_admin"
+ # 未分配 sites,即为超级管理员
+ receive_email: false # ← 超级管理员不接收邮件
+
+ - name: "a_admin_user"
+ # 分配 sites,即为普通管理员
+ sites:
+ - 站点名 A
+ - 站点名 B
+```
diff --git a/docs/guide/backend/notify.md b/docs/guide/backend/notify.md
new file mode 100644
index 000000000..4113b27cc
--- /dev/null
+++ b/docs/guide/backend/notify.md
@@ -0,0 +1,21 @@
+# 多元通知
+
+Artalk v2.1.8+ 配置项:
+
+- `notify` 弃用并变更为 `admin_notify`
+- `email.mail_subject_to_admin` 弃用并变更为 `admin_notify.email.mail_subject`
+
+```yaml
+# 管理员多元推送
+admin_notify:
+ # 通知模版
+ notify_tpl: "default"
+ noise_mode: false
+ # 邮件通知管理员
+ email:
+ enabled: true # 当使用其他推送方式时,可以关闭管理员邮件通知
+ mail_subject: "[{{site_name}}] 您的文章「{{page_title}}」有新回复"
+```
+
+
+请参考新文档:[多元推送](./admin_notify.md)
diff --git a/docs/guide/backend/relative-path.md b/docs/guide/backend/relative-path.md
new file mode 100644
index 000000000..0853ea0bf
--- /dev/null
+++ b/docs/guide/backend/relative-path.md
@@ -0,0 +1,53 @@
+# 相对 / 绝对路径
+
+Artalk 支持解析相对路径,因此你可以在前端页面进行如下配置:
+
+```js
+new Artalk({
+ site: "举个栗子站点", // 你的站点名
+ pageKey: "/relative-path/xx.html", // 使用相对路径
+})
+```
+
+建议页面使用相对路径,因为这为日后的「站点迁移」需求创建条件。
+
+然后,你需要在侧边栏「[控制中心](../frontend/sidebar.md#控制中心)」-「站点」找到 “举个栗子站点”,修改站点 URL。
+
+
+
+之后,所有相对路径都会「基于这个站点 URL」,例如:
+
+```bash
+"/relative-path/xx.html"
+ ↓ 解析为
+"https://设定的举个栗子站点URL.xxx/relative-path/xx.html"
+```
+
+### 解析后的 URL 用途
+
+站点 URL + 页面 相对路径 将用于:
+
+- **邮件通知**中的回复评论链接
+- **侧边栏**快速跳转到某条评论
+- **控制中心**页面管理打开页面
+- **获取页面标题**等信息时使用
+
+### 配置多个站点 URL 的情况
+
+你可能需要配置站点的多个 URL 来允许 Referer 和跨域。
+
+「[控制中心](../frontend/sidebar.md#控制中心)」-「站点」修改站点 URL 支持为站点添加多个 URL,用英文逗号 `,` 分隔开每个 URL 即可。
+
+**当站点存在多个 URL 时**,「相对路径」会基于多个 URL 中的「**第一个**」URL。
+
+### 使用绝对路径的情况
+
+区别于使用相对路径,你可以使用绝对路径,例如前端这样配置:
+
+```js
+new Artalk({
+ pageKey: "https://your_domain.com/relative-path/xx.html", // 使用绝对路径
+})
+```
+
+这时后端不会去解析该地址,邮件、侧边栏等地方都是直接使用 `pageKey` 这个绝对路径来定位页面。
diff --git a/docs/guide/backend/reverse-proxy.md b/docs/guide/backend/reverse-proxy.md
new file mode 100644
index 000000000..726876f8a
--- /dev/null
+++ b/docs/guide/backend/reverse-proxy.md
@@ -0,0 +1,111 @@
+# 反向代理
+
+## Nginx
+
+假定:
+
+- 你想绑定的域名是:`artalk.your_domain.com`
+- Artalk 本地地址:`http://localhost:23366`
+
+以 Ubuntu 20.04 为例:
+
+创建站点配置文件:
+
+```bash
+sudo vim /etc/nginx/sites-available/artalk.your_domain.com
+```
+
+编辑反向代理配置文件:
+
+```nginx
+server
+{
+ listen 80;
+ listen [::]:80;
+
+ server_name artalk.your_domain.com;
+
+ location / {
+ proxy_redirect off;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_pass http://localhost:23366/;
+ }
+}
+```
+
+创建软链接启用站点:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/artalk.your_domain.com /etc/nginx/sites-enabled/
+```
+
+验证配置文件是否有效:
+
+```bash
+sudo nginx -t
+```
+
+配置没有问题,重启 Nginx:
+
+```bash
+sudo systemctl restart nginx
+```
+
+配置前端:
+
+```js
+new Artalk({ server: "http://artalk.your_domain.com" })
+```
+
+::: tip
+你还可以再套一层 CDN,然后加上 SSL
+
+注意配置文件权限,以及反代目标 URL 可访问性
+
+尤其是运行在 Docker 容器内的 artalk,注意检查 IP 和端口是否能够被 Nginx 正常访问
+:::
+
+## Caddy
+
+创建 Caddyfile:
+
+```
+artalk.your_domain.com {
+ reverse_proxy / http://localhost:23366
+}
+```
+
+## Apache
+
+需要启用反代模块 `mod_proxy.c`
+
+```apache
+
+ ServerName your_domain.xxx
+ ServerAlias
+
+ RewriteEngine On
+ RewriteCond %{QUERY_STRING} transport=polling [NC]
+ RewriteRule /(.*) http://localhost:23366/$1 [P]
+
+
+ ProxyRequests Off
+ SSLProxyEngine on
+ ProxyPass / http://localhost:23366/
+ ProxyPassReverse / http://localhost:23366/
+
+
+```
+
+## 宝塔面板
+
+首先创建一个站点 (例如 `artalk.your_domain.com`),然后点击站点的「设置」:
+
+
+
+打开「反向代理」选项卡,点击「添加反向代理」,「目标 URL」填写 `http://localhost:端口号`(端口号与 Artalk 端口对应),「发送域名」填写 `$host`,如图:
+
+
+
diff --git a/docs/guide/backend/update.md b/docs/guide/backend/update.md
new file mode 100644
index 000000000..28e80d4be
--- /dev/null
+++ b/docs/guide/backend/update.md
@@ -0,0 +1,27 @@
+# 后端升级
+
+## 命令行一键升级
+
+执行 `./artalk upgrade`
+
+此操作会从自动从 GitHub Release 下载并升级程序,执行前需关闭 Artalk。
+
+:::tip
+
+执行 `./artalk upgrade -f` 携带参数 `-f` 来进行同版本号的补充更新。
+
+:::
+
+## Docker 升级
+
+可参考:[“Docker · 升级”](./docker.md#升级)
+
+## 普通方式
+
+前往 [GitHub Release](https://github.com/ArtalkJS/Artalk/releases) 手动下载最新构建
+
+替换掉旧版本文件即可。
+
+::: tip
+升级可能会有配置文件等变动,请注意查看版本 Changelog,通常是在 [GitHub Release](https://github.com/ArtalkJS/Artalk/releases) 页面
+:::
diff --git a/docs/guide/cases.md b/docs/guide/cases.md
new file mode 100644
index 000000000..f531e670a
--- /dev/null
+++ b/docs/guide/cases.md
@@ -0,0 +1,37 @@
+# 🚀 案例展示
+
+### 博客
+
+- [一蓑烟雨](https://easyf12.top/)
+- [二丫讲梵](https://wiki.eryajf.net)
+- [Alliot’s blog](https://www.iots.vip/)
+- [小康博客](https://www.antmoe.com/)
+- [程序员 Life](https://xuqilong.top)
+- [远方的灯塔](https://terwergreen.com)
+- [Bowenの破站](https://bowenyoung.cn/)
+- [杜老师说](https://dusays.com/)
+- [频率](https://pinlyu.com/)
+- [子舒](https://zburu.com/)
+- [叶开](https://xn--qpru0x.cn/)
+- [Thun888](https://blog.thun888.xyz/)
+- [青空之蓝](https://blog.ixk.me/)
+- [Monstx](https://blog.monsterx.cn/)
+- [QWQAQ](https://qwqaq.com/)
+
+### 主题
+
+- [hexo-theme-stellar](https://github.com/xaoxuu/hexo-theme-stellar): Elegant and powerful theme for Hexo.
+- [FixIt Theme | Hugo](https://github.com/Lruihao/FixIt): A clean, elegant but advanced blog theme for Hugo
+- [hexo-theme-volantis](https://github.com/volantis-x/hexo-theme-volantis): A Wonderful Theme for Hexo
+- [hexo-theme-cards](https://github.com/ChrAlpha/hexo-theme-cards): Another Simple & Swift theme for Hexo
+- [hexo-theme-butterfly](https://github.com/jerryc127/hexo-theme-butterfly): A Hexo Theme: Butterfly
+
+::: tip
+
+此页面案例由团队成员整理...
+
+如果希望 / 不希望你使用到 Artalk 的项目在此展示,欢迎在下方留言!
+
+非常感谢你为 Artalk 社区做出的贡献 😘
+
+:::
diff --git a/docs/guide/deploy.md b/docs/guide/deploy.md
new file mode 100644
index 000000000..2b64ab7c5
--- /dev/null
+++ b/docs/guide/deploy.md
@@ -0,0 +1,196 @@
+# 📦 程序部署
+
+## Docker 部署
+
+推荐使用 Docker 部署,需预先安装 [Docker 引擎](https://docs.docker.com/engine/install/),服务器执行命令创建容器:
+
+```bash
+docker run -d \
+ --name artalk \
+ -p 8080:23366 \
+ -v $(pwd)/data:/data \
+ artalk/artalk
+```
+
+> 假设域名 `http://your_domain` 已正确添加 DNS 记录并指向你的服务器 IP
+
+浏览器打开 `http://your_domain:8080` 将出现 Artalk 后台登陆界面。
+
+执行命令创建管理员账户:
+
+```bash
+docker exec -it artalk artalk admin
+```
+
+在你的网站引入 Artalk 程序内嵌的的前端 CSS、JS 资源并初始化:
+
+> 注:将 `http://your_domain:8080` 改为你的服务器域名,或使用 [公共 CDN 资源](#cdn-资源)。
+
+```html
+
+
+
+
+
+
+
+
+
+```
+
+在评论框输入管理员的用户名和邮箱,控制台入口按钮将出现在评论框右下角位置。
+
+在控制台,你可以根据喜好配置评论系统、[将评论迁移到 Artalk](./transfer.md)。
+
+祝贺!你已成功完成 Artalk 部署 🥳
+
+## 普通方式部署
+
+1. 前往 [GitHub Release](https://github.com/ArtalkJS/Artalk/releases) 下载程序压缩包
+2. 提取压缩包:`tar -zxvf artalk_版本号_系统_架构.tar.gz`
+3. 运行程序 `./artalk server`
+4. 前端配置
+
+ ```js
+ new Artalk({ server: "http://your_domain:23366" })
+ ```
+
+**其它可选操作:**
+
+- [“反向代理端口到 80 / 443 (Nginx, Apache)”](/guide/backend/reverse-proxy.md)
+- ["持久化运作 (tmux, systemd, supervisor)"](/guide/backend/daemon.md)
+
+**附表:文件名释义表**
+
+|文件名|操作系统|CPU 架构|
+|:-|:-:|:-:|
+|artalk_linux_amd64.tar.gz|Linux|x86_64|
+|artalk_linux_arm64.tar.gz|Linux|ARM64|
+|artalk_linux_arm7.tar.gz|Linux|ARMv7|
+|artalk_windows_amd64.zip|Windows|x86_64|
+|artalk_darwin_arm64.tar.gz|macOS|Apple Silicon|
+|artalk_darwin_amd64.tar.gz|macOS|Intel Chip ~~(什么狗屎)~~|
+
+## Docker Compose 部署
+
+提供 docker-compose.yaml 文件可供参考:
+
+```yaml
+version: "3.5"
+services:
+ artalk:
+ container_name: artalk
+ image: artalk/artalk
+ ports:
+ - 8080:23366
+ volumes:
+ - ./data:/data
+```
+
+在与配置文件相同的目录执行命令创建容器:
+
+```bash
+docker-compose up -d
+```
+
+::: details 一些 Docker Compose 常用命令
+
+```bash
+docker-compose restart # 重启容器
+docker-compose stop # 暂停容器
+docker-compose down # 删除容器
+docker-compose pull # 更新镜像
+docker-compose exec artalk bash # 进入容器
+```
+
+:::
+
+> 详细可见:[“后端 · Docker”](/guide/backend/docker.md)
+
+## 自行编译并运行
+
+可参考:[“后端构建”](./backend/build.md)
+
+## CDN 资源
+
+::: tip Artalk 最新版本
+
+当前 Artalk 前端最新版本号为: :ArtalkVersion:
+
+若需升级前端,请将 URL 中的版本号数字部分替换即可。
+:::
+
+Artalk 后端程序内嵌了前端 JS、CSS 文件,使用公共 CDN 资源请注意前后端版本的兼容性。
+
+Artalk 静态资源通过上游 [CDNJS](https://cdnjs.com/) 分发,国内有许多镜像可供选择:
+
+**BootCDN (国内)**
+
+> https://cdn.bootcdn.net/ajax/libs/artalk/2.4.4/Artalk.js
+>
+> https://cdn.bootcdn.net/ajax/libs/artalk/2.4.4/Artalk.css
+
+
+**ElemeCDN (国内)**
+
+> https://npm.elemecdn.com/artalk@2.4.4/dist/Artalk.js
+>
+> https://npm.elemecdn.com/artalk@2.4.4/dist/Artalk.css
+
+**CDNJS**
+
+> https://cdnjs.cloudflare.com/ajax/libs/artalk/2.4.4/Artalk.js
+>
+> https://cdnjs.cloudflare.com/ajax/libs/artalk/2.4.4/Artalk.css
+
+**UNPKG**
+
+> https://unpkg.com/artalk@2.4.4/dist/Artalk.js
+>
+> https://unpkg.com/artalk@2.4.4/dist/Artalk.css
+
+**JS DELIVR**
+
+> https://cdn.jsdelivr.net/npm/artalk@2.4.4/dist/Artalk.js
+>
+> https://cdn.jsdelivr.net/npm/artalk@2.4.4/dist/Artalk.css
+
+## ArtalkLite
+
+可选择精简版 [ArtalkLite](./frontend/artalk-lite.md):体积更小、更简约。
+
+## Node 环境
+
+```bash
+pnpm add artalk
+```
+
+引入到你的项目:
+
+```js
+import 'artalk/dist/Artalk.css'
+import Artalk from 'artalk'
+
+new Artalk({
+ // ...
+})
+```
+
+## 何时引入、何时 new?
+
+- 可以在任意位置引入 JS 和 CSS 资源,但需确保 JS 引入在执行 `new Artalk({})` 前。
+- 执行 `new Artalk({ el: '#x' })` 时,需要确保 `` 存在于页面当中。
+
+可参考:[“前端框架引入”](./frontend/import-framework.md) / [“博客引入”](./frontend/import-blog.md)
+
+## 数据导入
+
+从其他评论系统导入数据:[“数据迁移”](./transfer.md)
diff --git a/docs/guide/describe.md b/docs/guide/describe.md
new file mode 100644
index 000000000..08e257320
--- /dev/null
+++ b/docs/guide/describe.md
@@ -0,0 +1,42 @@
+# 😴 深入了解
+
+::: warning
+目前文档仍在陆续完善中
+:::
+
+
+## 控制台(管理后台)
+
+面向管理员用户,我们提供了 “控制台” 功能,通过控制台,你能对数据内容进行统一管理。
+
+### 如何呼出控制台
+
+控制台被集成到评论页中,您仅需:
+1. 在评论框输入管理员的名字与邮箱
+2. 密码验证框自动弹出,输入正确密码
+3. 控制台按钮将会在评论框右下角显示
+
+【图示】
+
+## 通知中心
+
+通知中心入口按钮将会在用户填入名字和邮箱后显示,它在评论框的右下角,与管理员控制台按钮位置相同。
+
+通知中心的评论分为 提及、我的、全部、待审 四类。
+
+- 提及:被人 AT 或对你的回复
+- 我的:我发布的评论
+- 全部:我的所有评论及相关评论
+- 待审:我的待审核评论
+
+## 邮件发送
+
+Artalk 支持以多种方式发送邮件,
+
+## 审核制度
+
+你能够通过设置,开启 评论默认待审,当用户提交评论后,需要管理员审核后才能显示,在此期间仅评论者自己可见。
+
+## 反垃圾
+
+Artalk 提供多种反垃圾的方法,其具体实现我们称之为 垃圾评论检查器,一旦检测到垃圾评论,检查器会将它标记为 “待审核” 状态。
diff --git a/docs/guide/extras.md b/docs/guide/extras.md
new file mode 100644
index 000000000..381fc1fba
--- /dev/null
+++ b/docs/guide/extras.md
@@ -0,0 +1,44 @@
+# 📚 扩展阅读
+
+“我们仰望同一片天空,却看着不同的地方。”
+
+Artalk 离不开社区的支持与帮助,**是你们让 Artalk 走得更远!**
+
+非常感谢对 Artalk 提供支持与帮助的朋友们!😘
+
+## Artalk v2
+
+- [Artalk 评论系统推荐 (By @inkss)](https://inkss.cn/blog/8f37d8c3/)
+- [Nuxt.js如何部署Artalk和遇到的问题 (By @子舒)](https://imhan.cn/posts/20220218/)
+- [宝塔安装过程大概记录 (By @xiamuguizhi)](https://github.com/ArtalkJS/Artalk/discussions/46)
+
+更多新版读物正在撰写当中(
+
+## Artalk v1
+
+::: warning
+
+Artalk v1 版本基于 [PHP 后端](https://github.com/ArtalkJS/ArtalkPHP),部分内容可能不适用于 Go 后端,但仍具有参考价值。
+
+:::
+
+- [[!!!] 一个 Artalk 的非官方文档 (By @thun888)](https://blog.thun888.xyz/wiki/Artalk/)
+- [为 Gridsome 添加 Artalk 自托管评论系统 - Monstx's Blog](https://blog.monsterx.cn/code/use-self-hosted-comment-system-in-gridsome/)
+- [Artalk 自托管评论系统的后端部署 - Jalen's Blog](https://blog.jalenchuh.cn/posts/artalk-api-php/)
+- [无服务器搭建 Artalk 评论系统后端 - 陈YFの博客](https://blog.cyfan.top/p/480ab6ed.html)
+- [哔哩哔哩 [站内首发] Artalk 评论系统搭建教程 - @thun888](https://www.bilibili.com/s/video/BV1954y1E7uP)
+- [Hexo Next 主题添加 Artalk 评论系统 - 心底的河流](https://lhy.life/20201126-artalk-next/)
+- [Hexo 添加 Artalk 评论教程 - 我相信我可以](https://butterfly.imlete.cn/article/Hexo-Artalk.html)
+- [基于 Butterfly 主题添加 Artalk 评论系统 - 卓越科技的 Blog](https://blog.imzykj.cn/posts/93afb348/)
+
+## 衍生品
+
+- [Artalk:一款 ~~简洁~~ 有趣的自托管评论系统 (By monsterxcn)](https://github.com/monsterxcn/Artalk)
+
+## 终于更新了!
+
+在 Artalk v1 向 v2 的跨越期间,我经历了高考,这真是一段超级漫长的时间呢。
+
+高考后,时间变得相对充裕,Artalk 后端采用 Golang 完成了重置,同时 Artalk 的功能也在不断完善当中。 🥳
+
+@qwqcode
diff --git a/docs/guide/frontend/artalk-lite.md b/docs/guide/frontend/artalk-lite.md
new file mode 100644
index 000000000..468603e73
--- /dev/null
+++ b/docs/guide/frontend/artalk-lite.md
@@ -0,0 +1,13 @@
+# 精简版本
+
+ArtalkLite 是 Artalk 的精简版本,相较于普通版,有以下区别:
+
+- 体积更小,更为简约
+- 默认关闭表情包、投票、UA 徽标
+- 去除 marked 依赖,但支持一些基本的 Markdown 语法
+
+ArtalkLite 随普通版一起发布,你可以将 CDN 地址文件名修改为 ArtalkLite 获取:
+
+> https://unpkg.com/artalk@2/dist/ArtalkLite.js
+>
+> https://unpkg.com/artalk@2/dist/ArtalkLite.css
diff --git a/docs/guide/frontend/config.md b/docs/guide/frontend/config.md
new file mode 100644
index 000000000..3df459e85
--- /dev/null
+++ b/docs/guide/frontend/config.md
@@ -0,0 +1,451 @@
+# 前端配置
+
+::: tip
+前端配置可以在侧边栏 “[控制中心](/guide/frontend/sidebar.md)” 直接修改,无需手动修改配置
+:::
+
+```js
+new Artalk({ 你的配置... })
+```
+
+- 默认配置:[defaults.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/src/defaults.ts)
+- 声明文件:[artalk-config.d.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/types/artalk-config.d.ts)
+
+## 基本配置(必填项)
+
+### el
+
+**装载元素**(填入需要绑定的元素 Selector)
+
+- 类型:`String|HTMLElement`
+- 默认值:`undefined`
+
+> 例如:`#Comments` 对应元素 ``
+
+### pageKey
+
+**页面地址**(相对路径 / 完整 URL)
+
+- 类型:`String`
+- 默认值:`location.pathname`
+
+可留空自动获取页面的相对路径。
+
+可以填写由博客系统生成的 `固定链接`,但建议使用相对路径以便日后切换域名。
+
+参考:[“关于使用相对 / 绝对路径”](/guide/backend/relative-path.md)
+
+### pageTitle
+
+**页面标题**(用于管理列表显示,邮件通知等)
+
+- 类型:`String`
+- 默认值:`document.title`
+
+可留空自动获取页面的 `` 标签值。
+
+### server
+
+**后端程序 API 地址**
+
+- 类型:`String`
+- 默认值:`undefined`
+
+部署后端,确保后端地址前端可访问
+
+> 例如:http://yourdomain.xxx
+
+::: warning 更新注意
+
+v2.2.6+ 的后续版本,请填入不带 `/api/` 路径的后端 URL。
+
+:::
+
+### site
+
+**站点名称**
+
+- 类型:`String`
+- 默认值:`undefined`
+
+可留空使用后端配置的 “默认站点”,
+
+Artalk 支持多站点统一管理,此项用于站点隔离。
+
+### useBackendConf
+
+**跟随后端的配置**
+
+- 类型:`Boolean`
+- 默认值:`true`(默认启用)
+
+可以在后端的配置文件中定义前端的配置,让前端配置始终跟随后端。
+
+详情可参考:[“在后端控制前端”](/guide/backend/fe-control)
+
+## 国际化 (i18n)
+
+### locale
+
+**语言**
+
+- 类型:`String|Object|"auto"`
+- 默认值:`"zh-CN"`
+
+遵循 Unicode BCP 47 规范,该项默认为 "zh-CN" (简体中文)。
+
+目前 Artalk 内置 "zh-CN" (简体中文) 和 "en-US" (English)。
+
+你可以贡献其他语言,欢迎提交 PR:[@artalk/src/i18n/index.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/src/i18n/index.ts)
+
+可传入 Object 类型,按照 [@artalk/src/i18n/zh-CN.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/src/i18n/zh-CN.ts) 文件中对象的 Keys 编写自定义 locale 内容。
+
+详情参考:[多语言](./i18n.html)
+
+## 请求
+
+### reqTimeout
+
+**请求超时**
+
+- 类型:`Number`
+- 默认值:`15000`
+
+当请求时间大于该值,自动断开请求并报错。(单位:毫秒)
+
+## 表情包
+
+### emoticons
+
+**表情包**
+
+- 类型:`Object|Array|String|Boolean`
+- 默认值:"[https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json](https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json)"
+
+详细内容:[“前端 · 表情包”](/guide/frontend/emoticons.md)
+
+更新兼容 [OwO 格式](https://github.com/DIYgod/OwO),支持 URL 动态加载。
+
+设置为 `false` 关闭表情包功能。
+
+:::warning 请替换 CDN 资源
+很遗憾,JS DELIVR [在中国大陆的 ICP 牌照被吊销](https://github.com/jsdelivr/jsdelivr/issues/18348#issuecomment-997777996),感谢 JS DELIVR 对社区的贡献 :heart:。
+:::
+
+## 界面
+
+### placeholder
+
+**评论框占位字符**
+
+- 类型:`String`
+- 默认值:`"键入内容..."`
+
+### noComment
+
+**评论为空时显示字符**
+
+- 类型:`String`
+- 默认值:`"「此时无声胜有声」"`
+
+### sendBtn
+
+**发送按钮文字**
+
+- 类型:`String`
+- 默认值:`"发送评论"`
+
+### editorTravel
+
+**评论框旅行**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+设置为 `true` 当回复评论时,评论框移动到待回复评论位置之后,而不是固定不动。
+
+### darkMode
+
+**暗黑模式**
+
+- 类型:`Boolean|"auto"`
+- 默认值:`false`
+
+当 Artalk 被 new 时会读取该值,并根据该值选择是否开启暗黑模式(可与博客主题配合使用)。
+
+代码动态修改 darkMode:
+
+```js
+artalkInstance.setDarkMode(true)
+```
+
+> 参考代码:“[index.html](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/index.html#L97-L150)”
+
+可设置为 `"auto"`,Artalk 将监听 `(prefers-color-scheme: dark)` 根据用户操作系统判断自动切换暗黑模式。
+
+### flatMode
+
+**平铺模式**
+
+- 类型:`Boolean|"auto"`
+- 默认值:`"auto"`
+
+默认 `"auto"` 仅小尺寸屏幕设备自动开启「平铺」模式 (屏幕宽度 < 768px 时)
+
+设置 `true` 评论以「平铺模式」形式显示
+
+设置 `false` 评论以「层级嵌套」形式显示
+
+### nestMax
+
+**最大嵌套层数**
+
+- 类型:`Number`
+- 默认值:`2`
+
+评论「层级嵌套」模式的最大嵌套层数。
+
+### nestSort
+
+**嵌套评论的排序规则**
+
+- 类型:`"DATE_ASC"|"DATE_DESC"|"VOTE_UP_DESC"`
+- 默认值:`"DATE_ASC"`
+
+嵌套评论的子评论默认以「日期升序 (新评的论在末尾)」排列。
+
+## 功能
+
+### pvEl
+
+**页面浏览量 (PV) 绑定元素**
+
+- 类型:`String`
+- 默认值:`"#ArtalkPV"`
+
+你可以在页面任意位置,放置 HTML 标签:``
+
+当 Artalk 完成加载时展示页面的浏览量。
+
+该项填入绑定元素的 Selector,默认为 `#ArtalkPV`。
+
+### countEl
+
+**评论数绑定元素**
+
+- 类型:`String`
+- 默认值:`"#ArtalkCount"`
+
+你可以在页面任意位置,放置 HTML 标签:`` 显示当前页面的评论数。
+
+::: tip
+
+pvEl 和 countEl 元素标签都可以设置 `data-page-key` 属性值,来指定显示某个页面的统计数目,例如:``
+
+详情参考:[浏览量统计](./pv.md#显示多个页面的浏览量)
+
+:::
+
+### vote
+
+**投票按钮**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+启用评论投票功能 (赞同 / 反对)。
+
+### voteDown
+
+**反对按钮**
+
+- 类型:`Boolean`
+- 默认值:`false`
+
+反对的投票按钮(默认隐藏)。
+
+### uaBadge
+
+**显示用户的 UserAgent 信息徽标**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+### listSort
+
+**评论排序功能**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+鼠标移到评论列表左上角「n 条评论」的位置,显示悬浮下拉层,可以让评论列表按照「最新 / 最热 / 最早 / 作者」等规则排序筛选显示。
+
+### imgUpload
+
+**图片上传功能**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+该配置项自动跟随后端,当后端图片上传功能关闭时,仅管理员会显示图片上传按钮。
+
+### imgUploader
+
+**图片上传器**
+
+- 类型:`(file: File) => Promise`
+- 默认值:`undefined`
+
+自定义图片上传器,例如:
+
+```js
+new Artalk({
+ imgUploader: async (file) => {
+ const form = new FormData()
+ form.set('file', file)
+
+ const imgUrl = await fetch("https://api.example.org/upload", {
+ method: 'POST',
+ body: form
+ })
+
+ return imgUrl
+ }
+})
+```
+
+### preview
+
+**编辑器实时预览功能**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+显示编辑器的「预览」按钮。
+
+## 头像
+
+```js
+gravatar: {
+ mirror: '',
+ default: 'mp',
+}
+```
+
+### gravatar.mirror
+
+**Gravatar 镜像地址**
+
+- 类型:`String`
+- 默认值:`"https://sdn.geekzu.org/avatar/"`
+
+如果你觉得 Gravatar 头像加载速度不理想,可以尝试替换。
+
+例如:
+
+> Cravatar:https://cravatar.cn/avatar/
+>
+> V2EX:https://cdn.v2ex.com/gravatar/
+>
+> 极客族:https://sdn.geekzu.org/avatar/
+>
+> loli:https://gravatar.loli.net/avatar/
+
+### gravatar.default
+
+**默认头像**(URL or [Gravatar Type](http://cn.gravatar.org/site/implement/images/#default-image))
+
+- 类型:`String`
+- 默认值:`"mp"`
+
+### avatarURLBuilder
+
+**头像链接生成器**
+
+- 类型:`(comment: CommentData) => string`
+- 默认值:`undefined`
+
+自定义用户头像图片链接生成,例如:
+
+```js
+new Artalk({
+ avatarURLBuilder: (comment) => {
+ return `/api/avatar?email=${comment.email_encrypted}`
+ }
+})
+```
+
+## 评论分页
+
+```js
+pagination: {
+ pageSize: 20, // 每页评论数
+ readMore: true, // 加载更多 or 分页条
+ autoLoad: true, // 自动加载 (加载更多)
+}
+```
+
+### pagination.readMore
+
+**加载更多模式**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+设置 `true` 为 “**加载更多**” 模式
+
+设置 `false` 为 “**分页条**” 模式
+
+### pagination.autoLoad
+
+**滚动到底部自动加载**
+
+- 类型:`Boolean`
+- 默认值:`true`
+
+(需同时开启“加载更多”模式,将 `readMore` 设置为 `true`)
+
+### pagination.pageSize
+
+**每次请求获取数量**
+
+- 类型:`Number`
+- 默认值:`20`
+
+## 内容限高
+
+超过设定高度的内容将被隐藏,并显示“阅读更多”按钮。
+
+```js
+heightLimit: {
+ content: 300, // 评论内容限高
+ children: 400, // 子评论区域限高
+}
+```
+
+### heightLimit.content
+
+**评论内容限高**
+
+- 类型:`Number`
+- 默认值:`300`
+
+> 当值为 0 时,关闭限高
+
+### heightLimit.children
+
+**子评论区域限高**
+
+- 类型:`Number`
+- 默认值:`400`
+
+## 版本检测
+
+### versionCheck
+
+**版本检测**
+
+- 类型:`Boolean`
+- 默认:`true`
+
+当前端和后端版本不兼容时,显示警告提示框。
diff --git a/docs/guide/frontend/emoticons.md b/docs/guide/frontend/emoticons.md
new file mode 100644
index 000000000..d89754958
--- /dev/null
+++ b/docs/guide/frontend/emoticons.md
@@ -0,0 +1,155 @@
+# 表情包
+
+你可以在前端配置表情包列表,例如:
+
+```js
+new Artalk({
+ // 默认表情包列表,动态引入 ↓↓
+ emoticons: "https://raw.githubusercontent.com/ArtalkJS/Emoticons/master/grps/default.json",
+})
+```
+
+## 表情包预设
+
+Artalk 社区提供许多表情包预设,你能够挑选几套喜欢的表情包,仅需简单的配置,轻松添加到你的评论系统中,前往仓库:[“@ArtalkJS/Emoticons”](https://github.com/ArtalkJS/Emoticons)。
+
+## 格式支持
+
+### Artalk 标准格式
+
+```js
+[{
+ "name": "颜表情",
+ "type": "emoticon", // 字符类型
+ "items": [
+ { "key": "Hi", "val": "|´・ω・)ノ" },
+ { "key": "开心", "val": "ヾ(≧∇≦*)ゝ" },
+ //...
+ ]
+}, {
+ "name": "滑稽",
+ "type": "image", // 图片类型
+ "items": [
+ {
+ "key": "原味稽",
+ "val": "<图片 URL>"
+ },
+ //...
+ ]
+}]
+```
+
+
+### OwO 格式
+
+[OwO](https://github.com/DIYgod/OwO) 是作者 [@DIYgod](https://github.com/DIYgod) 开发的 JS 插件,能让输入框快速拥有插入表情符号的功能。
+
+Artalk 的表情包功能灵感也源于此,并且 Artalk 适配和兼容 OwO 格式的表情包数据文件,示例如下:
+
+```js
+new Artalk({
+ emoticons: "https://raw.githubusercontent.com/DIYgod/OwO/master/demo/OwO.json",
+ // 直接食用 OwO 格式的表情包 ↑↑
+})
+```
+
+社区也有许多可以直接食用的 OwO 格式表情包资源,例如:[@2X-ercha/Twikoo-Magic](https://github.com/2X-ercha/Twikoo-Magic)。
+
+## 装载方式
+
+### 动态引入
+
+将 `emoticons` 属性设置为表情包数据文件的 URL,当打开表情包列表时,Artalk 会动态引入。
+
+```js
+new Artalk({
+ emoticons: "<表情包数据文件 URL>",
+})
+```
+
+远程的表情包文件支持 Artalk、OwO 格式,且支持嵌套、混合装载。
+
+### 静态装载
+
+相较于动态引入,可以将表情包列表对象,作为 Artalk 配置,静态保存在页面的 JS 代码中,避免动态加载:
+
+```js
+new Artalk({
+ emoticons: [{
+ "name": "颜表情",
+ "type": "emoticon", // 字符类型
+ "items": [
+ { "key": "Hi", "val": "|´・ω・)ノ" },
+ { "key": "开心", "val": "ヾ(≧∇≦*)ゝ" },
+ //...
+ ]
+ }, {
+ "name": "滑稽",
+ "type": "image", // 图片类型
+ "items": [
+ {
+ "key": "原味稽",
+ "val": "<图片 URL>"
+ },
+ //...
+ ]
+ }],
+})
+```
+
+### 混合装载
+
+Artalk 支持 **动态**、**静态** 混合装载,例如:
+
+```js
+new Artalk({
+ emoticons: [
+ // 动态装载
+ "https://raw.githubusercontent.com/DIYgod/OwO/master/demo/OwO.json", // OwO 格式表情包
+ "https://raw.githubusercontent.com/qwqcode/huaji/master/huaji.json",
+ // 静态装载
+ {
+ "name": "表情包名字",
+ "type": "emoticon", // 字符类型
+ "items": [
+ { "key": "去吧大师球", "val": "(╯°A°)╯︵○○○" },
+ //...
+ ]
+ }
+ ]
+})
+```
+
+### 嵌套装载
+
+Artalk 支持远程表情包资源中**嵌套引入**另外的表情包资源,例如:
+
+```js
+new Artalk({
+ emoticons: [
+ "https://example.org/表情包.json"
+ ]
+})
+```
+
+文件 `表情包.json` 中的数据:
+
+```json
+[
+ "https://example.org/其他表情包.json",
+ //...
+ { // Artalk 格式、OwO 格式
+ //...
+ }
+]
+```
+
+### 关闭表情包功能
+
+你可以将 `emoticons` 设置为 `false` 来禁用表情包功能:
+
+```js
+new Artalk({
+ emoticons: false
+})
+```
diff --git a/docs/guide/frontend/i18n.md b/docs/guide/frontend/i18n.md
new file mode 100644
index 000000000..ddbfd5c27
--- /dev/null
+++ b/docs/guide/frontend/i18n.md
@@ -0,0 +1,38 @@
+# 多语言
+
+你可以配置 `locale` 修改 Artalk 的语言:
+
+```js
+new Artalk({
+ locale: 'en-US'
+})
+```
+
+locale 配置项格式遵循 Unicode BCP 47 规范,默认为 "zh-CN" (简体中文)。
+
+目前 Artalk 内置 "zh-CN" (简体中文) 和 "en-US" (English)。
+
+你可以贡献其他语言,欢迎提交 PR:[@artalk/src/i18n/index.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/src/i18n/index.ts)
+
+### 自动切换
+
+可将 locale 配置为 `"auto"`,根据用户浏览器自动切换语言,当语言不存在时,将被设置为 "en-US"。
+
+```js
+new Artalk({
+ locale: "auto"
+})
+```
+
+### 自定义 locale 内容
+
+可传入 Object 类型,按照 [@artalk/src/i18n/zh-CN.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/src/i18n/zh-CN.ts) 文件中对象的 Keys 编写自定义 locale 内容。
+
+```js
+new Artalk({
+ locale: {
+ placeholder: 'こんにちは',
+ //...
+ }
+})
+```
diff --git a/docs/guide/frontend/import-blog.md b/docs/guide/frontend/import-blog.md
new file mode 100644
index 000000000..124336889
--- /dev/null
+++ b/docs/guide/frontend/import-blog.md
@@ -0,0 +1,200 @@
+# 置入博客
+
+## 通用方法
+
+<<< @/code/quick-start/cdn.html
+
+## Hugo
+
+创建模板文件 `/主题目录/layouts/partials/comment/artalk.html`:
+
+```html
+
+
+
+
+
+
+
+```
+
+文章页模板 `/主题目录/layouts/_default/single.html` 合适的位置添加:
+
+```html
+
+ {{- partial "comment/artalk" . -}}
+
+```
+
+配置 `/config.yaml` 文件:
+
+```yaml
+params:
+ artalk:
+ server: 'https://artalk.example.org'
+ site: '你的站点名'
+```
+
+## Hexo
+
+创建 `/主题目录/layout/comment/artalk.ejs`:
+
+```html
+
+
+
+
+
+
+```
+
+修改文章模板文件,例如 `/主题目录/layout/post.ejs`:
+
+```html
+
+ <%- partial('comment/artalk') %>
+
+```
+
+编辑主题配置 `/主题目录/_config.example.yml`:
+
+```yaml
+comment:
+ artalk:
+ site: '你的站点名'
+ server: 'https://artalk.example.org'
+```
+
+::: tip
+
+NexT 主题可以安装 [Hexo NexT 主题的 Artalk 插件](https://github.com/leirock/hexo-next-artalk)
+
+:::
+
+
+## VuePress
+
+以 [VuePress v2](https://github.com/vuepress/vuepress-next) 为例,继承默认主题 `@vuepress/theme-default`
+
+可参考:[“/.vuepress/theme/Artalk.vue”](https://github.com/ArtalkJS/Docs/blob/eef37bca8cc0c9973bf121fdef4014dcd940f104/docs/.vuepress/theme/Artalk.vue) 创建 Artalk 评论组件。
+
+在 `/.vuepress/theme/clientAppEnhance.ts` 文件中全局注册组件:
+
+```ts
+import { defineClientAppEnhance } from '@vuepress/client'
+
+import Artalk from './Artalk.vue'
+
+export default defineClientAppEnhance(({ app, router, siteData }) => {
+ app.component('Artalk', Artalk)
+})
+```
+
+主题布局 `/.vuepress/theme/Layout.vue`:
+
+```vue
+
+
+
+
+
+
+
+
+
+```
+
+主题配置文件 `/.vuepress/theme/index.ts`:
+
+```ts
+import { path } from '@vuepress/utils'
+
+export default ({
+ name: 'vuepress-theme-local',
+ extends: '@vuepress/theme-default',
+ layouts: {
+ Layout: path.resolve(__dirname, 'Layout.vue'),
+ },
+ clientAppEnhanceFiles: path.resolve(__dirname, 'clientAppEnhance.ts'),
+})
+```
+
+## Typecho
+
+修改主题文章模板文件,例如 `post.php`:
+
+```php
+
+
+
+
+
+
+
+
+
+```
+
+## WordPress
+
+修改主题文章模板文件,例如 `single.php`:
+
+```php
+
+
+
+
+
+
+
+
+
+```
diff --git a/docs/guide/frontend/import-framework.md b/docs/guide/frontend/import-framework.md
new file mode 100644
index 000000000..a0e06a22c
--- /dev/null
+++ b/docs/guide/frontend/import-framework.md
@@ -0,0 +1,99 @@
+# 置入框架
+
+## 引入 Artalk
+
+通过包管理工具引入 Artalk,推荐使用 [pnpm](https://pnpm.io/zh/)
+
+```bash
+pnpm add artalk
+```
+
+## Vue
+
+Vue 3 + TypeScript 例:
+
+```vue
+
+
+
+
+
+```
+
+::: tip 提示
+
+VuePress 可参考:[“VuePress 引入”](./import-blog.md#vuepress)
+
+:::
+
+## React
+
+```jsx
+import 'artalk/dist/Artalk.css'
+
+import React, { createRef } from 'react'
+import Artalk from 'artalk'
+
+export default class Artalk extends React.Component {
+ el = createRef()
+
+ componentDidMount () {
+ const artalk = new Artalk({
+ el: this.el.current,
+ pageKey: `${location.pathname}`,
+ pageTitle: `${document.title}`,
+ server: 'http://localhost:8080',
+ site: 'Artalk 的博客',
+ // ...
+ })
+ }
+
+ render () {
+ return (
+
+ )
+ }
+}
+```
+
+## Svelte
+
+```html
+
+
+
+```
diff --git a/docs/guide/frontend/index.md b/docs/guide/frontend/index.md
new file mode 100644
index 000000000..7806962de
--- /dev/null
+++ b/docs/guide/frontend/index.md
@@ -0,0 +1,34 @@
+---
+prev: install.md
+next: config.md
+---
+
+# ArtalkJS
+
+Artalk 拥有一个前端,[GitHub 代码仓库](https://github.com/ArtalkJS/Artalk)。
+
+## 特性
+
+可见:[“README.md · 特性”](https://github.com/ArtalkJS/Artalk#%E7%89%B9%E6%80%A7)
+
+## 句话概括
+
+- 轻量级 (~30kB)
+- 易上手 (防秃顶)
+- TypeScript & Vanilla (纯天然无添加 / 无依赖)
+
+
diff --git a/docs/guide/frontend/install.md b/docs/guide/frontend/install.md
new file mode 100644
index 000000000..3fbb81e90
--- /dev/null
+++ b/docs/guide/frontend/install.md
@@ -0,0 +1,8 @@
+---
+prev: '../backend/install.md'
+next: 'config.md'
+---
+
+# 前端部署
+
+参考:[程序部署](../deploy.md)
diff --git a/docs/guide/frontend/latex.md b/docs/guide/frontend/latex.md
new file mode 100644
index 000000000..fff84dc42
--- /dev/null
+++ b/docs/guide/frontend/latex.md
@@ -0,0 +1,27 @@
+# Latex
+
+对于学术性站点,支持 Latex 是一个刚需,可以使用 [Katex](https://katex.org/) 这款被前端广泛使用的 Latex 语法解析器。Artalk 为保证简洁性,并未内置 Latex 语法解析功能,但你可以在页面中引入 Latex 和 Artalk Katex 插件,让评论系统获得 Latex 语法支持。
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+之后,你可以回复:
+
+```
+$$ P(A) = \sum P(\{ (e_1,...,e_N) \}) = {{N}\choose{k}} \cdot p^kq^{N-k} $$
+```
+
+查看效果:
+
+
+
diff --git a/docs/guide/frontend/lightbox.md b/docs/guide/frontend/lightbox.md
new file mode 100644
index 000000000..ac2b4246e
--- /dev/null
+++ b/docs/guide/frontend/lightbox.md
@@ -0,0 +1,55 @@
+# 图片灯箱
+
+Artalk LightBox 插件能帮助你将网站**现有的图片灯箱**功能自动集成到 Artalk 中。
+
+```html
+
+
+
+
+
+
+
+
+
+```
+
+如上所示,额外引入一个 `artalk-plugin-lightbox.js` 文件即可。
+
+目前自动集成支持:[LightGallery](https://github.com/sachinchoolur/lightGallery) [v2.5.0] / [FancyBox](https://github.com/fancyapps/fancybox) [v4.0.27] / [lightbox2](https://github.com/lokesh/lightbox2) [v2.11.3]
+
+对于还未适配的图片灯箱,欢迎提交 PR -> [查看代码](https://github.com/ArtalkJS/Artalk/blob/master/packages/plugin-lightbox/main.ts)
+
+::: details 附:图片灯箱依赖 CDN 资源
+
+注:通常一个博客主题本来就是有图片灯箱插件的,所以无需重复引入。
+
+#### LightGallery
+
+ ```html
+
+
+ ```
+
+#### FancyBox
+
+ ```html
+
+
+ ```
+
+:::
+
+### 配置灯箱
+
+在引入 `artalk-plugin-lightbox.js` 之前对全局变量 `ATK_LIGHTBOX_CONF` 进行设置,如下:
+
+```html
+
+
+```
diff --git a/docs/guide/frontend/plugs.md b/docs/guide/frontend/plugs.md
new file mode 100644
index 000000000..5167a0016
--- /dev/null
+++ b/docs/guide/frontend/plugs.md
@@ -0,0 +1,17 @@
+# 插件
+
+::: warning
+目前文档仍在陆续完善中...
+:::
+
+你可以通过 `Artalk.use` 来装载 Artalk 插件。
+
+```js
+Artalk.use((ctx) => {
+ ctx.editor.setContent("Hello World")
+})
+```
+
+## Context
+
+参考:[@artalk/types/context.d.ts](https://github.com/ArtalkJS/Artalk/blob/master/packages/artalk/types/context.d.ts)
diff --git a/docs/guide/frontend/pv.md b/docs/guide/frontend/pv.md
new file mode 100644
index 000000000..49d9e5b95
--- /dev/null
+++ b/docs/guide/frontend/pv.md
@@ -0,0 +1,48 @@
+# 浏览量统计
+
+Artalk 内置页面浏览量统计功能,你可以在你的页面任意位置,放置 HTML 标签:
+
+```html
+
+```
+
+当 Artalk 加载完毕时,该标签内容将修改为页面的浏览量计数。
+
+Artalk 加载需要时间,所以你可以给它一个占位字符:
+
+```html
+加载中...
+```
+
+你也可以绑定使用其他的元素,修改 Artalk 配置项,例如:
+
+```js
+new Artalk({
+ pvEl: '.your_element',
+})
+```
+
+### 显示多个页面的浏览量
+
+你能在除评论页面之外的任何页面,例如「文章列表」页,显示页面浏览量或评论数:
+
+```js
+Artalk.LoadCountWidget({
+ server: '服务器地址',
+ site: '站点名',
+ pvEl: '#ArtalkPV',
+ countEl: '#ArtalkCount',
+});
+```
+
+在非评论页时,就无需再 new Artalk 实例了 (否则页面浏览量 PV 数会无故增加),仅调用 `LoadCountWidget` 静态方法即可。
+
+然后你可以放置多个 `#ArtalkPV` 元素,通过属性 `data-page-key` 来指定需要查询的页面:
+
+```html
+
+
+
+
+
+```
diff --git a/docs/guide/frontend/sidebar.md b/docs/guide/frontend/sidebar.md
new file mode 100644
index 000000000..08c172155
--- /dev/null
+++ b/docs/guide/frontend/sidebar.md
@@ -0,0 +1,39 @@
+# 侧边栏
+
+侧边栏同时充当 **通知中心** + **控制中心** 的功能,当用户**填写评论框的“昵称”和“邮箱”后**,侧边栏入口按钮能在**评论框右下角**找到。
+
+
+
+## 通知中心
+
+普通用户打开侧边栏能查看与自己相关的评论,评论被分类为:提及、全部、我的、待审。
+
+你能快速地找到评论,跳转到评论对应的页面。
+
+
+
+当用户收到消息时,会显示红点标记,站内消息功能属于是。
+
+即便是不打开邮件通知,也能收到来自对方的回复。
+
+
+
+同时,你能在侧边栏快速回复对方的消息,无需前往评论页面。
+
+
+
+## 控制中心
+
+管理员账户侧边栏将从「通知中心」切换为「控制中心」。
+
+当输入管理员“昵称”和“邮件”后,会自动弹出密码验证提示框:
+
+
+
+进入侧边栏,你可以对「评论、页面、站点」等进行管理。
+
+
+
+点击左上角的站点图标,你能在多个站点之间快速切换。
+
+
diff --git a/docs/guide/intro.md b/docs/guide/intro.md
new file mode 100644
index 000000000..50e740905
--- /dev/null
+++ b/docs/guide/intro.md
@@ -0,0 +1,99 @@
+---
+next: frontend/install.md
+---
+
+# 👋 Hello Friend
+
+**Artalk** 是一款简洁的**自托管**评论系统,你可以在服务器上**轻松部署**并置入前端页面中。
+
+来到你的博客,或是任意位置,放置 Artalk 评论框,让页面具备丰富的**社会化**功能。
+
+
+
+## 功能亮点
+
+ - **轻量设计**
+
+ 前端采用 TypeScript (Vanilla JS),轻量级,无冗余依赖,仅 ~30KB (gzipped)。
+
+ 后端采用 Golang 重制 (Artalk v2),跨平台,体积小巧,五脏俱全,快速部署。
+
+ - **“麻雀虽小,五脏俱全”**
+
+ - Markdown 语法 + 代码高亮
+ - [通知中心](./frontend/sidebar.md) - 站内:侧边栏 + 红点标记
+ - [多形式推送](./backend/admin_notify.md) - 站外:邮件、TG、钉钉、飞书 + 异步执行
+ - [评论审核](./backend/moderator.md):折叠 / 反垃圾 / 频率限制 / 滑动验证
+ - [多站点](./backend/multi-site.md):共用同一个后端程序,多站点集中化管理
+ - [表情包](./frontend/emoticons.md):支持 OwO 格式 + 动态加载
+ - [Artrans](./transfer.md):评论数据快速迁移 (导入 / 导出) 工具
+ - 评论投票 / 身份徽章 / 密码验证 / 说说模式
+ - 评论盖楼 / 评论分页 / 滚动加载 / 实时预览
+ - 评论排序 / 评论置顶 / 评论防丢 / 自动填充
+ - 图片上传 / 页面管理 / 站点隔离 / 暗黑模式
+
+ 穷举不是我们的特长,更多有趣的功能期待你来探索!
+
+- **“Unlimited Blade Works”**
+
+ Artalk 正在持续成长,创意由你发挥,价值由你赋予!
+
+ 不论是 Vue、React、Svelte 的前端项目,还是 WordPress、Typecho、Hexo 等博客系统,都可以快速引入 Artalk,结合诸位的聪明才智,我们相信 Artalk 能够自如应对各种业务场景。
+
+更多支持 / 计划的功能,详见:[“README.md”](https://github.com/ArtalkJS/Artalk#todos)。
+
+## 用户体验
+
+我们相信优雅的设计能带来良好的用户体验,良好的用户体验能帮助项目走得更远。
+
+「平凡而不平庸的设计」倍受专业 UI 设计师青睐的设计工具 Figma 这次在 Artalk 的重新设计中也帮了大忙。我们预先使用 Figma 设计人性化、现代化的界面,再编写前端样式使其自然融合至现代化的网站中,简约清新的界面由此诞生。此外,我们还设计了用户身份认证徽章、评论平铺 / 无限嵌套模式、评论分页等样式,同时兼顾不同尺寸的设备,在有限的空间体验无限的内容。
+
+「崩溃就在一瞬间」对于不加优化的评论系统,用户每次评论可能需要反复输入个人信息,发生意外状况时辛苦键入的见解还可能完全丢失。要知道,成年人的崩溃只在一瞬间。为解决这些痛点,Artalk 借助浏览器缓存自动填充用户信息、自动保存评论数据,让用户以最少的成本发表见解。
+
+「丰富站点表情,重燃评论热情」千篇一律的表情包可能容易使访客丧失评论的热情,于是 Artalk 自带一套精心挑选的滑稽表情包。除此之外,Artalk 也支持自定义图片表情。
+
+「你所热爱的,就是你的生活?」用户体验不仅仅就访客而言,对于站点管理者,Artalk 也不乏人性化的设计。通过侧边栏集成管理[控制中心](./frontend/sidebar.md#控制中心),管理员用户可以方便快捷地管理名下多个站点,所有数据通过规范化 API 交流并且异步处理,减少数据处理阻塞,降低服务资源占用。针对可能出现的垃圾评论,Artalk 支持自动拦截,降低管理者工作强度,也还站点以清净。
+
+---
+
+我们希望 Artalk 不仅能实现评论系统应有的基础功能,更能成为搭建 **知识传播者和知识学习者交流思想** 桥梁的媒介,让知识不再局限于文本,帮助知识传播者创造其应有的价值。
+
+## 浏览器兼容性
+
+| 
Chrome | 
Firefox | 
Safari | 
Opera |
+| --------- | --------- | --------- | --------- |
+| 51+ | 52+ | 10+ | 38+ |
+
+理论上兼容所有支持 ES6 (ES2015) 标准的现代浏览器,参考:[“ECMAScript 6 compatibility table”](https://kangax.github.io/compat-table/es6/)
+
+## 社区理念
+
+“**化繁为简,简而不凡**”
+
+Artalk 的目标是在尽量 **简洁** 的前提下,实现 **丰富** 的功能。
+
+2018 年 10 月 2 日,Artalk 的 [第一行代码](https://github.com/ArtalkJS/Artalk/commit/66128e2c8d9a8ac00a8d1498ff0ec035a7727daf) 被推送至 GitHub,直至 2021 年 10 月 20 日,才发布了 v2 版本。由于团队成员较少且开发者时间并不充裕,项目整体发展较慢。我们非常需要社区的力量,无论是为项目反馈 Bug,还是提供新功能的创意,我们都十分期待。
+
+Artalk 社区是包容开放的社区,我们欢迎不同水平的人员帮助 / 参与项目开发。如果你是入门新手,除了积极学习项目相关知识外,你也可以尝试体验已有 Artalk 部署,在使用中寻找、确认 Artalk 的不足之处,复现、总结后在相关项目的 [Issues](https://github.com/ArtalkJS/Artalk/issues)、[Discussions](https://github.com/ArtalkJS/Artalk/discussions) 中发表相关讨论,帮助开发者更好地定位问题、更快地做出优化。如果你是颇有技术的开发人员,你可以在 [@ArtalkJS](https://github.com/ArtalkJS) 找到项目的所有源码,结合此文档,我们相信这也许不难理解。无论是优化前后端结构、实现全新功能还是编写社区项目,我们都期待 Artalk 汇入新鲜血液。
+
+“More action, less talk”,Artalk 社区不欢迎无意义的争论,我们希望社区成员和谐共处、为社区发展出谋划策。在提出问题前,你应当读过《[提问的智慧](https://lug.ustc.edu.cn/wiki/doc/smart-questions/)》,这可能决定了你最终是否能得到有用的回答。在表达观点前,你应当具备基本的礼仪,比如保持平和的态度、使用得体的语言,切忌恶语相向、冷嘲热讽、不尊重他人信仰和立场等。
+
+我们作为开源精神的推崇者以及实践者,希望我们所创造的自由软件,都应该被自由的使用,自由的研究,自由的更改和自由的分享。本项目前后端代码使用第 3 版 GNU 宽通用公共许可证([GNU Lesser General Public License V3](https://www.gnu.org/licenses/lgpl-3.0.html))开源,文档请遵循知识共享许可协议 ([Creative Commons License](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh))。
+::: tip 立即为社区贡献力量?
+
+ - 维护 Artalk 前端(仓库地址 [@ArtalkJS/Artalk](https://github.com/ArtalkJS/Artalk))
+ - 维护 Artalk 后端(仓库地址 [@ArtalkJS/Artalk](https://github.com/ArtalkJS/Artalk))
+ - 完善 Artalk 文档(仓库地址 [@ArtalkJS/Docs](https://github.com/ArtalkJS/Docs))
+ - 改进数据迁移工具(仓库地址 [@ArtalkJS/Artransfer](https://github.com/ArtalkJS/Artransfer))
+ - 分享你的想法创意(下方留言 / [Discussions](https://github.com/ArtalkJS/Artalk/discussions))
+ - 编写相关社区项目(扩展插件 / 部署教程等)
+
+:::
+
+## 写在结尾
+
+至此,相信你已经了解 Artalk 的基本情况。无论你是否选择 Artalk,我们都十分感谢你对 Artalk 的关注。如果 Artalk 尚未满足你的需求,希望你能提出一针见血的建议帮助 Artalk 成长。
+
+欢迎使用 Artalk,
+
+起飞!🛫️
diff --git a/docs/guide/security.md b/docs/guide/security.md
new file mode 100644
index 000000000..afd917f5a
--- /dev/null
+++ b/docs/guide/security.md
@@ -0,0 +1,39 @@
+# 安全防范
+
+## 用户身份凭证存储
+
+RESTful API 具有无状态 (Stateless) 这一特性,尽管 Artalk 的 API 并不属于 RESTful,但保证无状态这一特性能让 API 更灵活易用,相反使用 Session 会话技术在处理多站点、跨域等操作的时候尤为繁琐,对于普通用户来说,服务器配置也成为麻烦,不当的配置会让用户的安全受到威胁。
+
+在各种权衡考虑之下,Artalk 目前还是依然使用 localStorage 保存用户凭证数据,因为使用 localStorage 能保证 API 的无状态性,也无需考虑因使用 Cookie 而带来的各种麻烦,而且使用 Cookie 并不能换来多大的安全性提升。无论是 Cookie 还是 localStorage 都存在有一些缺陷。
+
+Cookie 有 CSRF 安全隐患,而无状态 API 不容易有。如果使用 Cookie,就需要在任何有身份验证的地方,额外考虑对 CSRF 安全问题的防范,虽然可以使用全局 CSRF_TOKEN,但这无疑增加了 API 和客户端的复杂程度,开发也很难保证永远不会遗漏一些细节。所以,我认为使用 Cookie 既引入了一些额外的安全隐患,并且程序的开发难度也变大了,为什么要这样折磨自己呢?
+
+还有一点是 Cookie 这种被广泛用作判断用户身份状态的技术特别容易被跟踪滥用,尽管可信的现代浏览器有很多内部的安全策略,但如果用户操作系统安装了一些恶意流氓软件,拥有较高权限,你无法保证某些软件窃取 Cookie 不是轻而易举的事 (特指某些大厂)。
+
+那,Cookie 有啥优点?使用 Cookie 的主要优点在于能防止客户端 (浏览器) 通过 JS 对用户凭证数据的「直接」访问,阻止恶意 XSS 攻击脚本「直接」窃取数据的可能性。注意,这里用了「直接」这一个修饰词。虽然使用 Cookie (httpOnly) 不能通过 JS 读取凭证数据,但 XSS 攻击脚本依然可以在客户端执行 fetch 代码向 API 发起请求,这些请求依然是携带了 Cookie 的,是具有用户身份的 API 访问。也就是说,使用 Cookie (httpOnly) 并不能从根源上解决安全问题,不管你是 Cookie 还是 localStorage,只要网站存在 XSS 漏洞,攻击者成功注入了 XSS 脚本,都会对用户的安全造成威胁。所以使用 Cookie (httpOnly) 防 XSS 我认为是个「伪命题」。
+
+XSS 是属于非常严重的问题了。所以无论使用何种方式存放敏感数据,XSS 防范才是重中之重。
+
+对于我们用户来说,不应随意引入不明的 JS 脚本;不应在不了解代码意图的情况下到浏览器控制台执行 JS 代码;不应随意点击来历不明的链接;尽量使用新开的隐身模式窗口打开不明链接。
+
+Artalk 在努力保护用户的安全。参考 [OWASP 安全备忘录](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html) 中提到的内容,Artalk 的开发对于来自用户输入的任何数据始终持以怀疑的态度,我们严格遵循以下准则防范 XSS 攻击:
+
+- 不直接或间接地调用 `innerHTML` 输出未经处理的用户输入数据,而是使用 `innerText`。
+- 需要额外注意将用户输入数据带入程序上下文时的处理,防止非法数据在页面被执行。例如不要在 `setAttribute` 时直接引用来自用户的输入值。
+- 注意类型合法性判断、做好类型的转换。例如 URL 以 `javascript:` 前缀开头应被视为非法。
+- 不要去调用一些高风险的内置函数,因为会带来潜在的安全隐患。例如:`eval()`。
+- 注意来自内置 API 的数据,应任被视为不可信的。例如:`location.hash.split("#")[1]`。
+
+## 跨域请求安全性
+
+参考:[Cross-Origin Resource Sharing (CORS) | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
+
+【待补充】
+
+## API 设计安全性
+
+【待补充】
+
+## 写在结尾
+
+Artalk 的功能在不断完善,同时也在不断向提高安全性的目标努力,但在努力的同时还是难免发生一些疏忽和遗漏,若遇问题或有好的建议,欢迎反馈与指正。
diff --git a/docs/guide/transfer.md b/docs/guide/transfer.md
new file mode 100644
index 000000000..71aaffe56
--- /dev/null
+++ b/docs/guide/transfer.md
@@ -0,0 +1,216 @@
+# 🛬 数据迁移
+
+## 数据行囊
+
+数据行囊(Artrans)是 Artalk 持久化数据保存规范格式。
+
+Artran = Art + Ran (艺术 + 奔跑)
+
+即 “奔跑的艺术”,又称~~艺术之润~~ Ran 为 Run 的过去式,代表已经润了(
+
+::: details Artran 格式定义
+
+我们这样定义:每一条评论数据 (Object) 称为 Artran,多条评数据论组成一个 Artran**s** (Array 类型)
+
+```json
+{
+ "id": "123",
+ "rid": "233",
+ "content": "Hello Artalk",
+ "ua": "Artalk/6.6",
+ "ip": "233.233.233.233",
+ "created_at": "2021-10-28 20:50:15 +0800 +0800",
+ "updated_at": "2021-10-28 20:50:15 +0800 +0800",
+ "is_collapsed": "false",
+ "is_pending": "false",
+ "vote_up": "666",
+ "vote_down": "0",
+ "nick": "qwqcode",
+ "email": "qwqcode@github.com",
+ "link": "https://qwqaq.com",
+ "password": "",
+ "badge_name": "管理员",
+ "badge_color": "#FF716D",
+ "page_key": "https://artalk.js.org/guide/transfer.html",
+ "page_title": "数据迁移",
+ "page_admin_only": "false",
+ "site_name": "Artalk",
+ "site_urls": "http://localhost:3000/demo/,https://artalk.js.org"
+}
+```
+
+我们称:一个 JSON 数组为 Artran **s**,
+
+数组里的每一个 Object 项目为 Artran (没有 s)
+
+:::
+
+## 转换工具
+
+使用以下工具,将其他格式的评论数据转换为 Artrans,然后导入 Artalk。[在新窗口中打开](https://artransfer.netlify.app)
+
+
+
+::: tip 提示
+
+下文有各种获取源数据的方法可供参考;若遇问题,请提交 [issue](https://github.com/ArtalkJS/Artransfer/issues) 反馈。
+
+:::
+
+## 数据导入
+
+转换为 `.artrans` 格式的数据文件可以导入 Artalk:
+
+- **前端导入**:你可在「[控制中心](./frontend/sidebar.md#控制中心)」找到「迁移」选项卡,并根据提示导入 Artrans。
+- **命令行导入**:执行 `artalk import -h` 查阅帮助文档。
+
+## 获取源数据
+
+### Typecho
+
+**安装插件获取 Artrans**
+
+提供 Artrans 导出插件:
+
+1. 点击「[这里](https://github.com/ArtalkJS/Artrans-Typecho/releases/download/v1.0.0/ArtransExporter.zip)」下载插件并「解压」到 Typecho 目录 `/usr/plugins/`。
+2. 前往 Typecho 后台「控制台 - 插件」启用插件「ArtransExporter」。
+3. 前往「控制台 - 导出评论 (Artrans)」即可导出 Typecho 所有评论为 Artrans 格式。
+
+**直连数据库获取 Artrans**
+
+如果你的博客已闭站,但数据库还存在,可以使用我们提供的支持直连 Typecho 数据库的命令行工具。
+
+[下载 Artransfer-CLI](https://github.com/ArtalkJS/Artransfer-CLI/releases) 压缩包解压后,执行:
+
+```sh
+./artransfer typecho \
+ --db="mysql" \
+ --host="localhost" \
+ --port="3306" \
+ --user="root" \
+ --password="123456" \
+ --name="typecho_数据库名"
+```
+
+执行后你将得到一份 Artrans 格式的文件:
+
+```sh
+> ls
+typecho-20220424-202246.artrans
+```
+
+注:支持连接多种数据库,详情参考[此处](https://github.com/ArtalkJS/Artransfer-CLI)。
+
+### WordPress
+
+前往 WordPress 后台「工具 - 导出」勾选「所有内容」,导出文件即可使用[转换工具](#转换工具)进行转换。
+
+
+
+### Valine
+
+前往 [LeanCloud 后台](https://console.leancloud.cn/) 导出 JSON 格式的评论数据文件,然后使用[转换工具](#转换工具)进行转换。
+
+
+
+### Waline
+
+使用 LeanCloud 数据库的 Waline 可参考上面 Valine 的方法,它们格式相通,方法类似。
+
+独立部署的 Waline 可下载 [Artransfer-CLI](https://github.com/ArtalkJS/Artransfer-CLI/releases) 连接本地数据库导出,命令行执行:
+
+```bash
+./artransfer waline \
+ --db="mysql" \
+ --host="localhost" \
+ --port="3306" \
+ --user="root" \
+ --password="123456" \
+ --name="waline_数据库名" \
+ --table-prefix="wl_"
+```
+
+你将得到一份 Artrans 格式的数据文件,然后[导入 Artalk](#如何导入-artrans)。
+
+注:支持连接多种数据库,详情参考[此处](https://github.com/ArtalkJS/Artransfer-CLI)。
+
+### Disqus
+
+前往 [Disqus 后台](https://disqus.com/admin),找到「Moderation - Export」点击导出,Disqus 会将 `.gz` 格式的压缩包发送至你的邮箱,解压之后可以得到 `.xml` 格式的数据文件,然后使用[转换工具](#转换工具)转为 Artrans。
+
+
+
+### Commento
+
+你可在 Commento 后台导出 JSON 格式的数据文件,然后使用[转换工具](#转换工具)进行转换。
+
+【图示,待补充...】
+
+### Twikoo
+
+[Twikoo](https://twikoo.js.org/) 是一款基于腾讯云开发的评论系统,可前往 [腾讯云后台](https://console.cloud.tencent.com/tcb) 导出 JSON 格式的评论数据,然后使用[转换工具](#转换工具)进行转换。
+
+
+
+### Artalk v1 (PHP 旧版后端)
+
+[Artalk v1](https://github.com/ArtalkJS/ArtalkPHP) 是 Artalk 的旧版后端,它使用 PHP 编写。新版后端我们全面转向 Golang,并重新设计了数据表结构,升级新版需要通过[转换工具](#转换工具)进行转换。
+
+旧版数据路径:`/data/comments.data.json`
+
+## 命令行导入
+
+执行 `artalk import -h` 查看帮助文档。
+
+```bash
+./artalk import 数据类型 [参数...]
+```
+
+参数格式遵循 `:`,例如:
+
+```bash
+./artalk import 类型 t_name:"Site" t_url:"https://xx.com" json_file:"文件路径"
+```
+
+前端的导入同样可以手动输入启动参数,例如:
+
+```json
+{
+ "t_name": "Site",
+ "t_url": "https://xx.com",
+ "json_file": "服务器上的文件路径"
+}
+```
+
+Artalk 导入功能的通用启动参数:
+
+| 参数 | 类型 | 说明 |
+| :------: | ------ | ------------ |
+| `t_name` | String | 导入站点名称 |
+| `t_url` | String | 导入站点 URL |
+| `json_file` | String | JSON 数据文件路径 |
+| `json_data` | String | JSON 数据字符串内容 |
+
+## 数据备份
+
+你可在前端界面的「[控制中心](./frontend/sidebar.md#控制中心)」找到「迁移」选项卡,然后导出 Artrans 格式的评论数据。
+
+### 命令行备份
+
+导出:`artalk export ./artrans`
+
+导入:`artalk import ./artrans`
+
+### 高级玩法
+
+执行 `artalk export` 可直接 “标准输出”,并进行 “管道” 或 “输出重定向” 等操作,例如:
+
+```bash
+artalk export | gzip -9 | ssh username@remote_ip "cat > ~/backup/artrans.gz"
+```
+
+## 写在结尾
+
+目前已支持将 Typecho、WordPress、Valine、Waline、Disqus、Commento、Twikoo 等类型的数据转为 Artrans,但鉴于评论系统的多样性,虽然我们已经对上述类型数据做了适配,但仍然还有许多并未兼容。如果你恰巧正在使用未被适配的评论系统,你除了等待 Artalk 官方支持之外,还可以尝试了解 Artrans 数据格式后自主编写评论数据导入导出工具。如果你觉得自己的工具写得不错,我们十分乐意将其收录在内,让我们共同创造一个能够在不同评论系统之间自由切换的工具。
+
+前往:[“Artransfer 迁移工具代码仓库”](https://github.com/ArtalkJS/Artransfer)
diff --git a/docs/images/akismet/1.png b/docs/images/akismet/1.png
new file mode 100644
index 000000000..74fcc8f0b
Binary files /dev/null and b/docs/images/akismet/1.png differ
diff --git a/docs/images/akismet/2.png b/docs/images/akismet/2.png
new file mode 100644
index 000000000..83f312059
Binary files /dev/null and b/docs/images/akismet/2.png differ
diff --git a/docs/images/artalk-go/artalk-go-cache.png b/docs/images/artalk-go/artalk-go-cache.png
new file mode 100644
index 000000000..5f222bfd4
Binary files /dev/null and b/docs/images/artalk-go/artalk-go-cache.png differ
diff --git a/docs/images/artalk-logo.png b/docs/images/artalk-logo.png
new file mode 100644
index 000000000..919c6813f
Binary files /dev/null and b/docs/images/artalk-logo.png differ
diff --git a/docs/images/baota-proxy/1.png b/docs/images/baota-proxy/1.png
new file mode 100644
index 000000000..51adcad4d
Binary files /dev/null and b/docs/images/baota-proxy/1.png differ
diff --git a/docs/images/baota-proxy/2.png b/docs/images/baota-proxy/2.png
new file mode 100644
index 000000000..eb92d91e0
Binary files /dev/null and b/docs/images/baota-proxy/2.png differ
diff --git a/docs/images/baota-proxy/3.png b/docs/images/baota-proxy/3.png
new file mode 100644
index 000000000..7ac2600e6
Binary files /dev/null and b/docs/images/baota-proxy/3.png differ
diff --git a/docs/images/baota-supervisor/0.png b/docs/images/baota-supervisor/0.png
new file mode 100644
index 000000000..8ec96f7d1
Binary files /dev/null and b/docs/images/baota-supervisor/0.png differ
diff --git a/docs/images/baota-supervisor/1.png b/docs/images/baota-supervisor/1.png
new file mode 100644
index 000000000..1b1be8f7c
Binary files /dev/null and b/docs/images/baota-supervisor/1.png differ
diff --git a/docs/images/latex-support/1.png b/docs/images/latex-support/1.png
new file mode 100644
index 000000000..4cac99386
Binary files /dev/null and b/docs/images/latex-support/1.png differ
diff --git a/docs/images/notify/bark.png b/docs/images/notify/bark.png
new file mode 100644
index 000000000..0f7c324b2
Binary files /dev/null and b/docs/images/notify/bark.png differ
diff --git a/docs/images/notify/lark-1.png b/docs/images/notify/lark-1.png
new file mode 100644
index 000000000..526e349cb
Binary files /dev/null and b/docs/images/notify/lark-1.png differ
diff --git a/docs/images/notify/lark-2.png b/docs/images/notify/lark-2.png
new file mode 100644
index 000000000..9cc21e096
Binary files /dev/null and b/docs/images/notify/lark-2.png differ
diff --git a/docs/images/notify/lark-3.png b/docs/images/notify/lark-3.png
new file mode 100644
index 000000000..9c5aab849
Binary files /dev/null and b/docs/images/notify/lark-3.png differ
diff --git a/docs/images/notify/tg-1.png b/docs/images/notify/tg-1.png
new file mode 100644
index 000000000..f5aa5e712
Binary files /dev/null and b/docs/images/notify/tg-1.png differ
diff --git a/docs/images/notify/tg-2.png b/docs/images/notify/tg-2.png
new file mode 100644
index 000000000..8dc64ee90
Binary files /dev/null and b/docs/images/notify/tg-2.png differ
diff --git a/docs/images/relative-path/1.png b/docs/images/relative-path/1.png
new file mode 100644
index 000000000..3f0ba3ba6
Binary files /dev/null and b/docs/images/relative-path/1.png differ
diff --git a/docs/images/sidebar/1.png b/docs/images/sidebar/1.png
new file mode 100644
index 000000000..88b8a280c
Binary files /dev/null and b/docs/images/sidebar/1.png differ
diff --git a/docs/images/sidebar/2.png b/docs/images/sidebar/2.png
new file mode 100644
index 000000000..d29e16734
Binary files /dev/null and b/docs/images/sidebar/2.png differ
diff --git a/docs/images/sidebar/3.png b/docs/images/sidebar/3.png
new file mode 100644
index 000000000..e35709fcf
Binary files /dev/null and b/docs/images/sidebar/3.png differ
diff --git a/docs/images/sidebar/4.png b/docs/images/sidebar/4.png
new file mode 100644
index 000000000..1dd46ce61
Binary files /dev/null and b/docs/images/sidebar/4.png differ
diff --git a/docs/images/sidebar/5.png b/docs/images/sidebar/5.png
new file mode 100644
index 000000000..a2785ea0c
Binary files /dev/null and b/docs/images/sidebar/5.png differ
diff --git a/docs/images/sidebar/6.png b/docs/images/sidebar/6.png
new file mode 100644
index 000000000..67a9e6f55
Binary files /dev/null and b/docs/images/sidebar/6.png differ
diff --git a/docs/images/sidebar/7.png b/docs/images/sidebar/7.png
new file mode 100644
index 000000000..849b37cff
Binary files /dev/null and b/docs/images/sidebar/7.png differ
diff --git a/docs/images/sidebar/site_url.png b/docs/images/sidebar/site_url.png
new file mode 100644
index 000000000..1e9494c80
Binary files /dev/null and b/docs/images/sidebar/site_url.png differ
diff --git a/docs/images/transfer/disqus.png b/docs/images/transfer/disqus.png
new file mode 100755
index 000000000..6ef859688
Binary files /dev/null and b/docs/images/transfer/disqus.png differ
diff --git a/docs/images/transfer/leancloud.png b/docs/images/transfer/leancloud.png
new file mode 100644
index 000000000..8a676daec
Binary files /dev/null and b/docs/images/transfer/leancloud.png differ
diff --git a/docs/images/transfer/tencent-tcb.png b/docs/images/transfer/tencent-tcb.png
new file mode 100755
index 000000000..f906f4d3a
Binary files /dev/null and b/docs/images/transfer/tencent-tcb.png differ
diff --git a/docs/images/transfer/wordpress.png b/docs/images/transfer/wordpress.png
new file mode 100644
index 000000000..fc982699b
Binary files /dev/null and b/docs/images/transfer/wordpress.png differ
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 000000000..9b15a470a
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,32 @@
+---
+layout: home
+
+title: Artalk
+titleTemplate: 自托管评论系统
+
+hero:
+ name: Artalk
+ text: The Selfhosted Comment System
+ tagline: 轻量、安全、易上手的自托管评论系统
+ actions:
+ - theme: brand
+ text: 快速开始
+ link: /guide/intro
+ - theme: alt
+ text: View on GitHub
+ link: https://github.com/ArtalkJS/Artalk
+
+features:
+ - icon: 🍃
+ title: "轻量"
+ details: 小巧的文件体积,迅速响应每一次交互
+ - icon: 🔒
+ title: 安全
+ details: 评论系统自托管,一切尽在掌握之中
+ - icon: 🐳
+ title: 快捷
+ details: 简便的安装步骤,带来简洁的评论界面
+ - icon: 🍱
+ title: 全面
+ details: Golang 后端,支持多种平台和系统环境
+---
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 000000000..a3b5a2a32
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "docs",
+ "version": "2.0.0",
+ "description": "Artalk documentation",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ArtalkJS/Artalk",
+ "directory": "docs"
+ },
+ "private": true,
+ "scripts": {
+ "docs:dev": "vitepress dev",
+ "docs:build": "vitepress build",
+ "docs:serve": "vitepress serve"
+ },
+ "devDependencies": {
+ "markdown-it-for-inline": "^0.1.1",
+ "sass": "^1.52.3",
+ "vitepress": "1.0.0-alpha.1"
+ }
+}
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
new file mode 100644
index 000000000..7262acd2a
--- /dev/null
+++ b/docs/pnpm-lock.yaml
@@ -0,0 +1,837 @@
+lockfileVersion: 5.4
+
+specifiers:
+ markdown-it-for-inline: ^0.1.1
+ sass: ^1.52.3
+ vitepress: 1.0.0-alpha.1
+
+devDependencies:
+ markdown-it-for-inline: 0.1.1
+ sass: 1.52.3
+ vitepress: 1.0.0-alpha.1_sass@1.52.3
+
+packages:
+
+ /@algolia/autocomplete-core/1.6.3:
+ resolution: {integrity: sha512-dqQqRt01fX3YuVFrkceHsoCnzX0bLhrrg8itJI1NM68KjrPYQPYsE+kY8EZTCM4y8VDnhqJErR73xe/ZsV+qAA==}
+ dependencies:
+ '@algolia/autocomplete-shared': 1.6.3
+ dev: true
+
+ /@algolia/autocomplete-shared/1.6.3:
+ resolution: {integrity: sha512-UV46bnkTztyADFaETfzFC5ryIdGVb2zpAoYgu0tfcuYWjhg1KbLXveFffZIrGVoboqmAk1b+jMrl6iCja1i3lg==}
+ dev: true
+
+ /@algolia/cache-browser-local-storage/4.13.1:
+ resolution: {integrity: sha512-UAUVG2PEfwd/FfudsZtYnidJ9eSCpS+LW9cQiesePQLz41NAcddKxBak6eP2GErqyFagSlnVXe/w2E9h2m2ttg==}
+ dependencies:
+ '@algolia/cache-common': 4.13.1
+ dev: true
+
+ /@algolia/cache-common/4.13.1:
+ resolution: {integrity: sha512-7Vaf6IM4L0Jkl3sYXbwK+2beQOgVJ0mKFbz/4qSxKd1iy2Sp77uTAazcX+Dlexekg1fqGUOSO7HS4Sx47ZJmjA==}
+ dev: true
+
+ /@algolia/cache-in-memory/4.13.1:
+ resolution: {integrity: sha512-pZzybCDGApfA/nutsFK1P0Sbsq6fYJU3DwIvyKg4pURerlJM4qZbB9bfLRef0FkzfQu7W11E4cVLCIOWmyZeuQ==}
+ dependencies:
+ '@algolia/cache-common': 4.13.1
+ dev: true
+
+ /@algolia/client-account/4.13.1:
+ resolution: {integrity: sha512-TFLiZ1KqMiir3FNHU+h3b0MArmyaHG+eT8Iojio6TdpeFcAQ1Aiy+2gb3SZk3+pgRJa/BxGmDkRUwE5E/lv3QQ==}
+ dependencies:
+ '@algolia/client-common': 4.13.1
+ '@algolia/client-search': 4.13.1
+ '@algolia/transporter': 4.13.1
+ dev: true
+
+ /@algolia/client-analytics/4.13.1:
+ resolution: {integrity: sha512-iOS1JBqh7xaL5x00M5zyluZ9+9Uy9GqtYHv/2SMuzNW1qP7/0doz1lbcsP3S7KBbZANJTFHUOfuqyRLPk91iFA==}
+ dependencies:
+ '@algolia/client-common': 4.13.1
+ '@algolia/client-search': 4.13.1
+ '@algolia/requester-common': 4.13.1
+ '@algolia/transporter': 4.13.1
+ dev: true
+
+ /@algolia/client-common/4.13.1:
+ resolution: {integrity: sha512-LcDoUE0Zz3YwfXJL6lJ2OMY2soClbjrrAKB6auYVMNJcoKZZ2cbhQoFR24AYoxnGUYBER/8B+9sTBj5bj/Gqbg==}
+ dependencies:
+ '@algolia/requester-common': 4.13.1
+ '@algolia/transporter': 4.13.1
+ dev: true
+
+ /@algolia/client-personalization/4.13.1:
+ resolution: {integrity: sha512-1CqrOW1ypVrB4Lssh02hP//YxluoIYXAQCpg03L+/RiXJlCs+uIqlzC0ctpQPmxSlTK6h07kr50JQoYH/TIM9w==}
+ dependencies:
+ '@algolia/client-common': 4.13.1
+ '@algolia/requester-common': 4.13.1
+ '@algolia/transporter': 4.13.1
+ dev: true
+
+ /@algolia/client-search/4.13.1:
+ resolution: {integrity: sha512-YQKYA83MNRz3FgTNM+4eRYbSmHi0WWpo019s5SeYcL3HUan/i5R09VO9dk3evELDFJYciiydSjbsmhBzbpPP2A==}
+ dependencies:
+ '@algolia/client-common': 4.13.1
+ '@algolia/requester-common': 4.13.1
+ '@algolia/transporter': 4.13.1
+ dev: true
+
+ /@algolia/logger-common/4.13.1:
+ resolution: {integrity: sha512-L6slbL/OyZaAXNtS/1A8SAbOJeEXD5JcZeDCPYDqSTYScfHu+2ePRTDMgUTY4gQ7HsYZ39N1LujOd8WBTmM2Aw==}
+ dev: true
+
+ /@algolia/logger-console/4.13.1:
+ resolution: {integrity: sha512-7jQOTftfeeLlnb3YqF8bNgA2GZht7rdKkJ31OCeSH2/61haO0tWPoNRjZq9XLlgMQZH276pPo0NdiArcYPHjCA==}
+ dependencies:
+ '@algolia/logger-common': 4.13.1
+ dev: true
+
+ /@algolia/requester-browser-xhr/4.13.1:
+ resolution: {integrity: sha512-oa0CKr1iH6Nc7CmU6RE7TnXMjHnlyp7S80pP/LvZVABeJHX3p/BcSCKovNYWWltgTxUg0U1o+2uuy8BpMKljwA==}
+ dependencies:
+ '@algolia/requester-common': 4.13.1
+ dev: true
+
+ /@algolia/requester-common/4.13.1:
+ resolution: {integrity: sha512-eGVf0ID84apfFEuXsaoSgIxbU3oFsIbz4XiotU3VS8qGCJAaLVUC5BUJEkiFENZIhon7hIB4d0RI13HY4RSA+w==}
+ dev: true
+
+ /@algolia/requester-node-http/4.13.1:
+ resolution: {integrity: sha512-7C0skwtLdCz5heKTVe/vjvrqgL/eJxmiEjHqXdtypcE5GCQCYI15cb+wC4ytYioZDMiuDGeVYmCYImPoEgUGPw==}
+ dependencies:
+ '@algolia/requester-common': 4.13.1
+ dev: true
+
+ /@algolia/transporter/4.13.1:
+ resolution: {integrity: sha512-pICnNQN7TtrcYJqqPEXByV8rJ8ZRU2hCiIKLTLRyNpghtQG3VAFk6fVtdzlNfdUGZcehSKGarPIZEHlQXnKjgw==}
+ dependencies:
+ '@algolia/cache-common': 4.13.1
+ '@algolia/logger-common': 4.13.1
+ '@algolia/requester-common': 4.13.1
+ dev: true
+
+ /@babel/helper-validator-identifier/7.16.7:
+ resolution: {integrity: sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /@babel/parser/7.18.5:
+ resolution: {integrity: sha512-YZWVaglMiplo7v8f1oMQ5ZPQr0vn7HPeZXxXWsxXJRjGVrzUFn9OxFQl1sb5wzfootjA/yChhW84BV+383FSOw==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+ dependencies:
+ '@babel/types': 7.18.4
+ dev: true
+
+ /@babel/types/7.18.4:
+ resolution: {integrity: sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/helper-validator-identifier': 7.16.7
+ to-fast-properties: 2.0.0
+ dev: true
+
+ /@docsearch/css/3.1.0:
+ resolution: {integrity: sha512-bh5IskwkkodbvC0FzSg1AxMykfDl95hebEKwxNoq4e5QaGzOXSBgW8+jnMFZ7JU4sTBiB04vZWoUSzNrPboLZA==}
+ dev: true
+
+ /@docsearch/js/3.1.0:
+ resolution: {integrity: sha512-5XSK+xbP0hcTIp54MECqxkWLs6kf7Ug4nWdxWNtx8cUpLiFNFnKXDxCb35wnyNpjukmrx7Q9DkO5tFFsmNVxng==}
+ dependencies:
+ '@docsearch/react': 3.1.0
+ preact: 10.7.3
+ transitivePeerDependencies:
+ - '@types/react'
+ - react
+ - react-dom
+ dev: true
+
+ /@docsearch/react/3.1.0:
+ resolution: {integrity: sha512-bjB6ExnZzf++5B7Tfoi6UXgNwoUnNOfZ1NyvnvPhWgCMy5V/biAtLL4o7owmZSYdAKeFSvZ5Lxm0is4su/dBWg==}
+ peerDependencies:
+ '@types/react': '>= 16.8.0 < 19.0.0'
+ react: '>= 16.8.0 < 19.0.0'
+ react-dom: '>= 16.8.0 < 19.0.0'
+ dependencies:
+ '@algolia/autocomplete-core': 1.6.3
+ '@docsearch/css': 3.1.0
+ algoliasearch: 4.13.1
+ dev: true
+
+ /@vitejs/plugin-vue/2.3.3_vite@2.9.12+vue@3.2.37:
+ resolution: {integrity: sha512-SmQLDyhz+6lGJhPELsBdzXGc+AcaT8stgkbiTFGpXPe8Tl1tJaBw1A6pxDqDuRsVkD8uscrkx3hA7QDOoKYtyw==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ vite: ^2.5.10
+ vue: ^3.2.25
+ dependencies:
+ vite: 2.9.12_sass@1.52.3
+ vue: 3.2.37
+ dev: true
+
+ /@vue/compiler-core/3.2.37:
+ resolution: {integrity: sha512-81KhEjo7YAOh0vQJoSmAD68wLfYqJvoiD4ulyedzF+OEk/bk6/hx3fTNVfuzugIIaTrOx4PGx6pAiBRe5e9Zmg==}
+ dependencies:
+ '@babel/parser': 7.18.5
+ '@vue/shared': 3.2.37
+ estree-walker: 2.0.2
+ source-map: 0.6.1
+ dev: true
+
+ /@vue/compiler-dom/3.2.37:
+ resolution: {integrity: sha512-yxJLH167fucHKxaqXpYk7x8z7mMEnXOw3G2q62FTkmsvNxu4FQSu5+3UMb+L7fjKa26DEzhrmCxAgFLLIzVfqQ==}
+ dependencies:
+ '@vue/compiler-core': 3.2.37
+ '@vue/shared': 3.2.37
+ dev: true
+
+ /@vue/compiler-sfc/3.2.37:
+ resolution: {integrity: sha512-+7i/2+9LYlpqDv+KTtWhOZH+pa8/HnX/905MdVmAcI/mPQOBwkHHIzrsEsucyOIZQYMkXUiTkmZq5am/NyXKkg==}
+ dependencies:
+ '@babel/parser': 7.18.5
+ '@vue/compiler-core': 3.2.37
+ '@vue/compiler-dom': 3.2.37
+ '@vue/compiler-ssr': 3.2.37
+ '@vue/reactivity-transform': 3.2.37
+ '@vue/shared': 3.2.37
+ estree-walker: 2.0.2
+ magic-string: 0.25.9
+ postcss: 8.4.14
+ source-map: 0.6.1
+ dev: true
+
+ /@vue/compiler-ssr/3.2.37:
+ resolution: {integrity: sha512-7mQJD7HdXxQjktmsWp/J67lThEIcxLemz1Vb5I6rYJHR5vI+lON3nPGOH3ubmbvYGt8xEUaAr1j7/tIFWiEOqw==}
+ dependencies:
+ '@vue/compiler-dom': 3.2.37
+ '@vue/shared': 3.2.37
+ dev: true
+
+ /@vue/reactivity-transform/3.2.37:
+ resolution: {integrity: sha512-IWopkKEb+8qpu/1eMKVeXrK0NLw9HicGviJzhJDEyfxTR9e1WtpnnbYkJWurX6WwoFP0sz10xQg8yL8lgskAZg==}
+ dependencies:
+ '@babel/parser': 7.18.5
+ '@vue/compiler-core': 3.2.37
+ '@vue/shared': 3.2.37
+ estree-walker: 2.0.2
+ magic-string: 0.25.9
+ dev: true
+
+ /@vue/reactivity/3.2.37:
+ resolution: {integrity: sha512-/7WRafBOshOc6m3F7plwzPeCu/RCVv9uMpOwa/5PiY1Zz+WLVRWiy0MYKwmg19KBdGtFWsmZ4cD+LOdVPcs52A==}
+ dependencies:
+ '@vue/shared': 3.2.37
+ dev: true
+
+ /@vue/runtime-core/3.2.37:
+ resolution: {integrity: sha512-JPcd9kFyEdXLl/i0ClS7lwgcs0QpUAWj+SKX2ZC3ANKi1U4DOtiEr6cRqFXsPwY5u1L9fAjkinIdB8Rz3FoYNQ==}
+ dependencies:
+ '@vue/reactivity': 3.2.37
+ '@vue/shared': 3.2.37
+ dev: true
+
+ /@vue/runtime-dom/3.2.37:
+ resolution: {integrity: sha512-HimKdh9BepShW6YozwRKAYjYQWg9mQn63RGEiSswMbW+ssIht1MILYlVGkAGGQbkhSh31PCdoUcfiu4apXJoPw==}
+ dependencies:
+ '@vue/runtime-core': 3.2.37
+ '@vue/shared': 3.2.37
+ csstype: 2.6.20
+ dev: true
+
+ /@vue/server-renderer/3.2.37_vue@3.2.37:
+ resolution: {integrity: sha512-kLITEJvaYgZQ2h47hIzPh2K3jG8c1zCVbp/o/bzQOyvzaKiCquKS7AaioPI28GNxIsE/zSx+EwWYsNxDCX95MA==}
+ peerDependencies:
+ vue: 3.2.37
+ dependencies:
+ '@vue/compiler-ssr': 3.2.37
+ '@vue/shared': 3.2.37
+ vue: 3.2.37
+ dev: true
+
+ /@vue/shared/3.2.37:
+ resolution: {integrity: sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw==}
+ dev: true
+
+ /@vueuse/core/8.6.0_vue@3.2.37:
+ resolution: {integrity: sha512-VirzExCm/N+QdrEWT7J4uSrvJ5hquKIAU9alQ37kUvIJk9XxCLxmfRnmekYc1kz2+6BnoyuKYXVmrMV351CB4w==}
+ peerDependencies:
+ '@vue/composition-api': ^1.1.0
+ vue: ^2.6.0 || ^3.2.0
+ peerDependenciesMeta:
+ '@vue/composition-api':
+ optional: true
+ vue:
+ optional: true
+ dependencies:
+ '@vueuse/metadata': 8.6.0
+ '@vueuse/shared': 8.6.0_vue@3.2.37
+ vue: 3.2.37
+ vue-demi: 0.13.1_vue@3.2.37
+ dev: true
+
+ /@vueuse/metadata/8.6.0:
+ resolution: {integrity: sha512-F+CKPvaExsm7QgRr8y+ZNJFwXasn89rs5wth/HeX9lJ1q8XEt+HJ16Q5Sxh4rfG5YSKXrStveVge8TKvPjMjFA==}
+ dev: true
+
+ /@vueuse/shared/8.6.0_vue@3.2.37:
+ resolution: {integrity: sha512-Y/IVywZo7IfEoSSEtCYpkVEmPV7pU35mEIxV7PbD/D3ly18B3mEsBaPbtDkNM/QP3zAZ5mn4nEkOfddX4uwuIA==}
+ peerDependencies:
+ '@vue/composition-api': ^1.1.0
+ vue: ^2.6.0 || ^3.2.0
+ peerDependenciesMeta:
+ '@vue/composition-api':
+ optional: true
+ vue:
+ optional: true
+ dependencies:
+ vue: 3.2.37
+ vue-demi: 0.13.1_vue@3.2.37
+ dev: true
+
+ /algoliasearch/4.13.1:
+ resolution: {integrity: sha512-dtHUSE0caWTCE7liE1xaL+19AFf6kWEcyn76uhcitWpntqvicFHXKFoZe5JJcv9whQOTRM6+B8qJz6sFj+rDJA==}
+ dependencies:
+ '@algolia/cache-browser-local-storage': 4.13.1
+ '@algolia/cache-common': 4.13.1
+ '@algolia/cache-in-memory': 4.13.1
+ '@algolia/client-account': 4.13.1
+ '@algolia/client-analytics': 4.13.1
+ '@algolia/client-common': 4.13.1
+ '@algolia/client-personalization': 4.13.1
+ '@algolia/client-search': 4.13.1
+ '@algolia/logger-common': 4.13.1
+ '@algolia/logger-console': 4.13.1
+ '@algolia/requester-browser-xhr': 4.13.1
+ '@algolia/requester-common': 4.13.1
+ '@algolia/requester-node-http': 4.13.1
+ '@algolia/transporter': 4.13.1
+ dev: true
+
+ /anymatch/3.1.2:
+ resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+ dev: true
+
+ /binary-extensions/2.2.0:
+ resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /body-scroll-lock/4.0.0-beta.0:
+ resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==}
+ dev: true
+
+ /braces/3.0.2:
+ resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
+ engines: {node: '>=8'}
+ dependencies:
+ fill-range: 7.0.1
+ dev: true
+
+ /chokidar/3.5.3:
+ resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
+ engines: {node: '>= 8.10.0'}
+ dependencies:
+ anymatch: 3.1.2
+ braces: 3.0.2
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /csstype/2.6.20:
+ resolution: {integrity: sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==}
+ dev: true
+
+ /esbuild-android-64/0.14.43:
+ resolution: {integrity: sha512-kqFXAS72K6cNrB6RiM7YJ5lNvmWRDSlpi7ZuRZ1hu1S3w0zlwcoCxWAyM23LQUyZSs1PbjHgdbbfYAN8IGh6xg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-android-arm64/0.14.43:
+ resolution: {integrity: sha512-bKS2BBFh+7XZY9rpjiHGRNA7LvWYbZWP87pLehggTG7tTaCDvj8qQGOU/OZSjCSKDYbgY7Q+oDw8RlYQ2Jt2BA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-64/0.14.43:
+ resolution: {integrity: sha512-/3PSilx011ttoieRGkSZ0XV8zjBf2C9enV4ScMMbCT4dpx0mFhMOpFnCHkOK0pWGB8LklykFyHrWk2z6DENVUg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-arm64/0.14.43:
+ resolution: {integrity: sha512-1HyFUKs8DMCBOvw1Qxpr5Vv/ThNcVIFb5xgXWK3pyT40WPvgYIiRTwJCvNs4l8i5qWF8/CK5bQxJVDjQvtv0Yw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-64/0.14.43:
+ resolution: {integrity: sha512-FNWc05TPHYgaXjbPZO5/rJKSBslfG6BeMSs8GhwnqAKP56eEhvmzwnIz1QcC9cRVyO+IKqWNfmHFkCa1WJTULA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-arm64/0.14.43:
+ resolution: {integrity: sha512-amrYopclz3VohqisOPR6hA3GOWA3LZC1WDLnp21RhNmoERmJ/vLnOpnrG2P/Zao+/erKTCUqmrCIPVtj58DRoA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-32/0.14.43:
+ resolution: {integrity: sha512-KoxoEra+9O3AKVvgDFvDkiuddCds6q71owSQEYwjtqRV7RwbPzKxJa6+uyzUulHcyGVq0g15K0oKG5CFBcvYDw==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-64/0.14.43:
+ resolution: {integrity: sha512-EwINwGMyiJMgBby5/SbMqKcUhS5AYAZ2CpEBzSowsJPNBJEdhkCTtEjk757TN/wxgbu3QklqDM6KghY660QCUw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm/0.14.43:
+ resolution: {integrity: sha512-e6YzQUoDxxtyamuF12eVzzRC7bbEFSZohJ6igQB9tBqnNmIQY3fI6Cns3z2wxtbZ3f2o6idkD2fQnlvs2902Dg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm64/0.14.43:
+ resolution: {integrity: sha512-UlSpjMWllAc70zYbHxWuDS3FJytyuR/gHJYBr8BICcTNb/TSOYVBg6U7b3jZ3mILTrgzwJUHwhEwK18FZDouUQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-mips64le/0.14.43:
+ resolution: {integrity: sha512-f+v8cInPEL1/SDP//CfSYzcDNgE4CY3xgDV81DWm3KAPWzhvxARrKxB1Pstf5mB56yAslJDxu7ryBUPX207EZA==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-ppc64le/0.14.43:
+ resolution: {integrity: sha512-5wZYMDGAL/K2pqkdIsW+I4IR41kyfHr/QshJcNpUfK3RjB3VQcPWOaZmc+74rm4ZjVirYrtz+jWw0SgxtxRanA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-riscv64/0.14.43:
+ resolution: {integrity: sha512-lYcAOUxp85hC7lSjycJUVSmj4/9oEfSyXjb/ua9bNl8afonaduuqtw7hvKMoKuYnVwOCDw4RSfKpcnIRDWq+Bw==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-s390x/0.14.43:
+ resolution: {integrity: sha512-27e43ZhHvhFE4nM7HqtUbMRu37I/4eNSUbb8FGZWszV+uLzMIsHDwLoBiJmw7G9N+hrehNPeQ4F5Ujad0DrUKQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-netbsd-64/0.14.43:
+ resolution: {integrity: sha512-2mH4QF6hHBn5zzAfxEI/2eBC0mspVsZ6UVo821LpAJKMvLJPBk3XJO5xwg7paDqSqpl7p6IRrAenW999AEfJhQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-openbsd-64/0.14.43:
+ resolution: {integrity: sha512-ZhQpiZjvqCqO8jKdGp9+8k9E/EHSA+zIWOg+grwZasI9RoblqJ1QiZqqi7jfd6ZrrG1UFBNGe4m0NFxCFbMVbg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-sunos-64/0.14.43:
+ resolution: {integrity: sha512-DgxSi9DaHReL9gYuul2rrQCAapgnCJkh3LSHPKsY26zytYppG0HgkgVF80zjIlvEsUbGBP/GHQzBtrezj/Zq1Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-32/0.14.43:
+ resolution: {integrity: sha512-Ih3+2O5oExiqm0mY6YYE5dR0o8+AspccQ3vIAtRodwFvhuyGLjb0Hbmzun/F3Lw19nuhPMu3sW2fqIJ5xBxByw==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-64/0.14.43:
+ resolution: {integrity: sha512-8NsuNfI8xwFuJbrCuI+aBqNTYkrWErejFO5aYM+yHqyHuL8mmepLS9EPzAzk8rvfaJrhN0+RvKWAcymViHOKEw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-arm64/0.14.43:
+ resolution: {integrity: sha512-7ZlD7bo++kVRblJEoG+cepljkfP8bfuTPz5fIXzptwnPaFwGS6ahvfoYzY7WCf5v/1nX2X02HDraVItTgbHnKw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild/0.14.43:
+ resolution: {integrity: sha512-Uf94+kQmy/5jsFwKWiQB4hfo/RkM9Dh7b79p8yqd1tshULdr25G2szLz631NoH3s2ujnKEKVD16RmOxvCNKRFA==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ esbuild-android-64: 0.14.43
+ esbuild-android-arm64: 0.14.43
+ esbuild-darwin-64: 0.14.43
+ esbuild-darwin-arm64: 0.14.43
+ esbuild-freebsd-64: 0.14.43
+ esbuild-freebsd-arm64: 0.14.43
+ esbuild-linux-32: 0.14.43
+ esbuild-linux-64: 0.14.43
+ esbuild-linux-arm: 0.14.43
+ esbuild-linux-arm64: 0.14.43
+ esbuild-linux-mips64le: 0.14.43
+ esbuild-linux-ppc64le: 0.14.43
+ esbuild-linux-riscv64: 0.14.43
+ esbuild-linux-s390x: 0.14.43
+ esbuild-netbsd-64: 0.14.43
+ esbuild-openbsd-64: 0.14.43
+ esbuild-sunos-64: 0.14.43
+ esbuild-windows-32: 0.14.43
+ esbuild-windows-64: 0.14.43
+ esbuild-windows-arm64: 0.14.43
+ dev: true
+
+ /estree-walker/2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ dev: true
+
+ /fill-range/7.0.1:
+ resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ to-regex-range: 5.0.1
+ dev: true
+
+ /fsevents/2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /function-bind/1.1.1:
+ resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
+ dev: true
+
+ /glob-parent/5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+ dependencies:
+ is-glob: 4.0.3
+ dev: true
+
+ /has/1.0.3:
+ resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
+ engines: {node: '>= 0.4.0'}
+ dependencies:
+ function-bind: 1.1.1
+ dev: true
+
+ /immutable/4.1.0:
+ resolution: {integrity: sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==}
+ dev: true
+
+ /is-binary-path/2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+ dependencies:
+ binary-extensions: 2.2.0
+ dev: true
+
+ /is-core-module/2.9.0:
+ resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==}
+ dependencies:
+ has: 1.0.3
+ dev: true
+
+ /is-extglob/2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /is-glob/4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ is-extglob: 2.1.1
+ dev: true
+
+ /is-number/7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+ dev: true
+
+ /jsonc-parser/3.0.0:
+ resolution: {integrity: sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==}
+ dev: true
+
+ /magic-string/0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /markdown-it-for-inline/0.1.1:
+ resolution: {integrity: sha512-lLQuczOg90a9q9anIUbmq+M+FFrIYNN5TfpccLDRchQic8nj/uTqaJKoYr73FF2tR4O8mFfh2ZzCDAAB2MZJgA==}
+ dev: true
+
+ /nanoid/3.3.4:
+ resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /normalize-path/3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /path-parse/1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ dev: true
+
+ /picocolors/1.0.0:
+ resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
+ dev: true
+
+ /picomatch/2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+ dev: true
+
+ /postcss/8.4.14:
+ resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.4
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /preact/10.7.3:
+ resolution: {integrity: sha512-giqJXP8VbtA1tyGa3f1n9wiN7PrHtONrDyE3T+ifjr/tTkg+2N4d/6sjC9WyJKv8wM7rOYDveqy5ZoFmYlwo4w==}
+ dev: true
+
+ /readdirp/3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+ dependencies:
+ picomatch: 2.3.1
+ dev: true
+
+ /resolve/1.22.0:
+ resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==}
+ hasBin: true
+ dependencies:
+ is-core-module: 2.9.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+ dev: true
+
+ /rollup/2.75.6:
+ resolution: {integrity: sha512-OEf0TgpC9vU6WGROJIk1JA3LR5vk/yvqlzxqdrE2CzzXnqKXNzbAwlWUXis8RS3ZPe7LAq+YUxsRa0l3r27MLA==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /sass/1.52.3:
+ resolution: {integrity: sha512-LNNPJ9lafx+j1ArtA7GyEJm9eawXN8KlA1+5dF6IZyoONg1Tyo/g+muOsENWJH/2Q1FHbbV4UwliU0cXMa/VIA==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+ dependencies:
+ chokidar: 3.5.3
+ immutable: 4.1.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /shiki/0.10.1:
+ resolution: {integrity: sha512-VsY7QJVzU51j5o1+DguUd+6vmCmZ5v/6gYu4vyYAhzjuNQU6P/vmSy4uQaOhvje031qQMiW0d2BwgMH52vqMng==}
+ dependencies:
+ jsonc-parser: 3.0.0
+ vscode-oniguruma: 1.6.2
+ vscode-textmate: 5.2.0
+ dev: true
+
+ /source-map-js/1.0.2:
+ resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /source-map/0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /sourcemap-codec/1.4.8:
+ resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+ dev: true
+
+ /supports-preserve-symlinks-flag/1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /to-fast-properties/2.0.0:
+ resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /to-regex-range/5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ is-number: 7.0.0
+ dev: true
+
+ /vite/2.9.12_sass@1.52.3:
+ resolution: {integrity: sha512-suxC36dQo9Rq1qMB2qiRorNJtJAdxguu5TMvBHOc/F370KvqAe9t48vYp+/TbPKRNrMh/J55tOUmkuIqstZaew==}
+ engines: {node: '>=12.2.0'}
+ hasBin: true
+ peerDependencies:
+ less: '*'
+ sass: '*'
+ stylus: '*'
+ peerDependenciesMeta:
+ less:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ dependencies:
+ esbuild: 0.14.43
+ postcss: 8.4.14
+ resolve: 1.22.0
+ rollup: 2.75.6
+ sass: 1.52.3
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /vitepress/1.0.0-alpha.1_sass@1.52.3:
+ resolution: {integrity: sha512-yA0QIl+mB3fQ2j+keQVa0DTT0waP2AeWM/p9VYfUAT9vOkQEGass4/oYmMGPCQrBwCaO3cpOxJL3ZFVooyvybQ==}
+ engines: {node: '>=14.6.0'}
+ hasBin: true
+ dependencies:
+ '@docsearch/css': 3.1.0
+ '@docsearch/js': 3.1.0
+ '@vitejs/plugin-vue': 2.3.3_vite@2.9.12+vue@3.2.37
+ '@vueuse/core': 8.6.0_vue@3.2.37
+ body-scroll-lock: 4.0.0-beta.0
+ shiki: 0.10.1
+ vite: 2.9.12_sass@1.52.3
+ vue: 3.2.37
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@vue/composition-api'
+ - less
+ - react
+ - react-dom
+ - sass
+ - stylus
+ dev: true
+
+ /vscode-oniguruma/1.6.2:
+ resolution: {integrity: sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA==}
+ dev: true
+
+ /vscode-textmate/5.2.0:
+ resolution: {integrity: sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ==}
+ dev: true
+
+ /vue-demi/0.13.1_vue@3.2.37:
+ resolution: {integrity: sha512-xmkJ56koG3ptpLnpgmIzk9/4nFf4CqduSJbUM0OdPoU87NwRuZ6x49OLhjSa/fC15fV+5CbEnrxU4oyE022svg==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ peerDependencies:
+ '@vue/composition-api': ^1.0.0-rc.1
+ vue: ^3.0.0-0 || ^2.6.0
+ peerDependenciesMeta:
+ '@vue/composition-api':
+ optional: true
+ dependencies:
+ vue: 3.2.37
+ dev: true
+
+ /vue/3.2.37:
+ resolution: {integrity: sha512-bOKEZxrm8Eh+fveCqS1/NkG/n6aMidsI6hahas7pa0w/l7jkbssJVsRhVDs07IdDq7h9KHswZOgItnwJAgtVtQ==}
+ dependencies:
+ '@vue/compiler-dom': 3.2.37
+ '@vue/compiler-sfc': 3.2.37
+ '@vue/runtime-dom': 3.2.37
+ '@vue/server-renderer': 3.2.37_vue@3.2.37
+ '@vue/shared': 3.2.37
+ dev: true
diff --git a/docs/public/assets/emoticons/default.json b/docs/public/assets/emoticons/default.json
new file mode 100644
index 000000000..2823167fb
--- /dev/null
+++ b/docs/public/assets/emoticons/default.json
@@ -0,0 +1,306 @@
+[
+ {
+ "name": "颜表情",
+ "type": "emoticon",
+ "items": [
+ { "key": "Hi", "val": "|´・ω・)ノ" },
+ { "key": "开心", "val": "ヾ(≧∇≦*)ゝ" },
+ { "key": "星星眼", "val": "(☆ω☆)" },
+ { "key": "掀桌", "val": "(╯‵□′)╯︵┴─┴" },
+ { "key": "流口水", "val": " ̄﹃ ̄" },
+ { "key": "捂脸", "val": "(/ω\)" },
+ { "key": "给跪", "val": "∠( ᐛ 」∠)_" },
+ { "key": "哈?", "val": "(๑•̀ㅁ•́ฅ)" },
+ { "key": "斜眼", "val": "→_→" },
+ { "key": "加油", "val": "୧(๑•̀⌄•́๑)૭" },
+ { "key": "有木有WiFi", "val": "٩(ˊᗜˋ*)و" },
+ { "key": "前方高能预警", "val": "(ノ°ο°)ノ" },
+ { "key": "纳尼", "val": "(´இ皿இ`)" },
+ { "key": "吓死惹", "val": "⌇●﹏●⌇" },
+ { "key": "已阅留爪", "val": "(ฅ´ω`ฅ)" },
+ { "key": "去吧大师球", "val": "(╯°A°)╯︵○○○" },
+ { "key": "太萌惹", "val": "φ( ̄∇ ̄o)" },
+ { "key": "咦咦咦", "val": "ヾ(´・ ・`。)ノ\"" },
+ { "key": "气呼呼", "val": "( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃" },
+ { "key": "我受到了惊吓", "val": "(ó﹏ò。)" },
+ { "key": "什么鬼", "val": "Σ(っ °Д °;)っ" },
+ { "key": "摸摸头", "val": "( ,,´・ω・)ノ\"(´っω・`。)" },
+ { "key": "无奈", "val": "╮(╯▽╰)╭ " },
+ { "key": "脸红", "val": "o(*////▽////*)q " },
+ { "key": "悲哀", "val": ">﹏<" },
+ { "key": "静静地看着你", "val": "( ๑´•ω•) \"(ㆆᴗㆆ)" },
+ { "key": "不要哇", "val": "(。•ˇ‸ˇ•。)" }
+ ]
+ },
+ {
+ "name": "Emoji",
+ "type": "emoji",
+ "items": [
+ { "key": "", "val": "😀" },
+ { "key": "", "val": "😃" },
+ { "key": "", "val": "😄" },
+ { "key": "", "val": "😁" },
+ { "key": "", "val": "😆" },
+ { "key": "", "val": "😅" },
+ { "key": "", "val": "😂" },
+ { "key": "", "val": "😊" },
+ { "key": "", "val": "😉" },
+ { "key": "", "val": "👀" },
+ { "key": "", "val": "😌" },
+ { "key": "", "val": "😍" },
+ { "key": "", "val": "😘" },
+ { "key": "", "val": "😋" },
+ { "key": "", "val": "😜" },
+ { "key": "", "val": "😝" },
+ { "key": "", "val": "😎" },
+ { "key": "", "val": "😏" },
+ { "key": "", "val": "😒" },
+ { "key": "", "val": "😟" },
+ { "key": "", "val": "😕" },
+ { "key": "", "val": "😖" },
+ { "key": "", "val": "😫" },
+ { "key": "", "val": "😩" },
+ { "key": "", "val": "😠" },
+ { "key": "", "val": "😲" },
+ { "key": "", "val": "😵" },
+ { "key": "", "val": "😳" },
+ { "key": "", "val": "😱" },
+ { "key": "", "val": "😨" },
+ { "key": "", "val": "😢" },
+ { "key": "", "val": "😭" },
+ { "key": "", "val": "😷" },
+ { "key": "", "val": "✋" },
+ { "key": "", "val": "✌️" },
+ { "key": "", "val": "👊" },
+ { "key": "", "val": "👋" },
+ { "key": "", "val": "👏" },
+ { "key": "", "val": "👍" },
+ { "key": "", "val": "👎" },
+ { "key": "", "val": "❤️" },
+ { "key": "", "val": "🎉" },
+ { "key": "", "val": "🚀" }
+ ]
+ },
+ {
+ "name": "滑稽",
+ "type": "image",
+ "items": [
+ {
+ "key": "原味稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%8E%9F%E5%91%B3%E7%A8%BD.png"
+ },
+ {
+ "key": "还是算了",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%BF%98%E6%98%AF%E7%AE%97%E4%BA%86.png"
+ },
+ {
+ "key": "蓝纹稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%93%9D%E7%BA%B9%E7%A8%BD.jpg"
+ },
+ {
+ "key": "随稽应变",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E9%9A%8F%E7%A8%BD%E5%BA%94%E5%8F%98.jpg"
+ },
+ {
+ "key": "蠕动",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%A0%95%E5%8A%A8.gif"
+ },
+ {
+ "key": "束手无稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%9D%9F%E6%89%8B%E6%97%A0%E7%A8%BD.jpg"
+ },
+ {
+ "key": "微笑默叹以为妙绝",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%BE%AE%E7%AC%91%E9%BB%98%E5%8F%B9%E4%BB%A5%E4%B8%BA%E5%A6%99%E7%BB%9D.png"
+ },
+ {
+ "key": "喝嘤料",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%96%9D%E5%98%A4%E6%96%99.jpg"
+ },
+ {
+ "key": "暗中观察",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%9A%97%E4%B8%AD%E8%A7%82%E5%AF%9F.jpg"
+ },
+ {
+ "key": "高兴",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E9%AB%98%E5%85%B4.jpg"
+ },
+ {
+ "key": "惊稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%83%8A%E7%A8%BD.jpg"
+ },
+ {
+ "key": "可这和我的帅有什么关系",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%8F%AF%E8%BF%99%E5%92%8C%E6%88%91%E7%9A%84%E5%B8%85%E6%9C%89%E4%BB%80%E4%B9%88%E5%85%B3%E7%B3%BB.jpg"
+ },
+ {
+ "key": "狱稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%8B%B1%E7%A8%BD.jpg"
+ },
+ {
+ "key": "梆",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%A2%86.jpg"
+ },
+ {
+ "key": "吃鱼摆摆",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%90%83%E9%B1%BC%E6%91%86%E6%91%86.gif"
+ },
+ {
+ "key": "跃跃欲试 3",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%B7%83%E8%B7%83%E6%AC%B2%E8%AF%95_3.gif"
+ },
+ {
+ "key": "突然滑稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%AA%81%E7%84%B6%E6%BB%91%E7%A8%BD.jpg"
+ },
+ {
+ "key": "扶墙怂",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%89%B6%E5%A2%99%E6%80%82.jpg"
+ },
+ {
+ "key": "阔以",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E9%98%94%E4%BB%A5.jpg"
+ },
+ {
+ "key": "不得行",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E4%B8%8D%E5%BE%97%E8%A1%8C.jpg"
+ },
+ {
+ "key": "少儿不宜",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%B0%91%E5%84%BF%E4%B8%8D%E5%AE%9C.jpg"
+ },
+ {
+ "key": "稽日可期",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%A8%BD%E6%97%A5%E5%8F%AF%E6%9C%9F.jpg"
+ },
+ {
+ "key": "哎",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%93%8E.jpg"
+ },
+ {
+ "key": "别看丢人",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%88%AB%E7%9C%8B%E4%B8%A2%E4%BA%BA.jpg"
+ },
+ {
+ "key": "地稽 2",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%9C%B0%E7%A8%BD_2.jpg"
+ },
+ {
+ "key": "地稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%9C%B0%E7%A8%BD.jpg"
+ },
+ {
+ "key": "老阔有点扣",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%80%81%E9%98%94%E6%9C%89%E7%82%B9%E6%89%A3.gif"
+ },
+ {
+ "key": "啊哈哈",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%95%8A%E5%93%88%E5%93%88.jpg"
+ },
+ {
+ "key": "无稽可奈",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%97%A0%E7%A8%BD%E5%8F%AF%E5%A5%88.jpg"
+ },
+ {
+ "key": "老实巴交",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%80%81%E5%AE%9E%E5%B7%B4%E4%BA%A4.jpg"
+ },
+ {
+ "key": "紧张",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%B4%A7%E5%BC%A0.jpg"
+ },
+ {
+ "key": "摇摆稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%91%87%E6%91%86%E7%A8%BD.gif"
+ },
+ {
+ "key": "又不是不能用",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%8F%88%E4%B8%8D%E6%98%AF%E4%B8%8D%E8%83%BD%E7%94%A8.jpg"
+ },
+ {
+ "key": "一时滑稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E4%B8%80%E6%97%B6%E6%BB%91%E7%A8%BD.jpg"
+ },
+ {
+ "key": "无法接受",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%97%A0%E6%B3%95%E6%8E%A5%E5%8F%97.jpg"
+ },
+ {
+ "key": "嘤雄豪稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%98%A4%E9%9B%84%E8%B1%AA%E7%A8%BD.jpg"
+ },
+ {
+ "key": "相视双稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%9B%B8%E8%A7%86%E5%8F%8C%E7%A8%BD.jpg"
+ },
+ {
+ "key": "稽皮发麻",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%A8%BD%E7%9A%AE%E5%8F%91%E9%BA%BB.jpg"
+ },
+ {
+ "key": "地稽 3",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%9C%B0%E7%A8%BD_3.jpg"
+ },
+ {
+ "key": "地稽委屈",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%9C%B0%E7%A8%BD%E5%A7%94%E5%B1%88.jpg"
+ },
+ {
+ "key": "地稽抚摸",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%9C%B0%E7%A8%BD%E6%8A%9A%E6%91%B8.gif"
+ },
+ {
+ "key": "绝望",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%BB%9D%E6%9C%9B.jpg"
+ },
+ {
+ "key": "气稽败坏",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%B0%94%E7%A8%BD%E8%B4%A5%E5%9D%8F.jpg"
+ },
+ {
+ "key": "当场去世",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%BD%93%E5%9C%BA%E5%8E%BB%E4%B8%96.jpg"
+ },
+ {
+ "key": "喝酒",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%96%9D%E9%85%92.jpg"
+ },
+ {
+ "key": "老衲摆摊算命",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%80%81%E8%A1%B2%E6%91%86%E6%91%8A%E7%AE%97%E5%91%BD.gif"
+ },
+ {
+ "key": "老哥,稳",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%80%81%E5%93%A5%EF%BC%8C%E7%A8%B3.jpg"
+ },
+ {
+ "key": "自闭稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%87%AA%E9%97%AD%E7%A8%BD.jpg"
+ },
+ {
+ "key": "无话可说",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%97%A0%E8%AF%9D%E5%8F%AF%E8%AF%B4.jpg"
+ },
+ {
+ "key": "跃跃欲试",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%B7%83%E8%B7%83%E6%AC%B2%E8%AF%95.jpg"
+ },
+ {
+ "key": "跃跃欲试 2",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E8%B7%83%E8%B7%83%E6%AC%B2%E8%AF%95_2.jpg"
+ },
+ {
+ "key": "满脑子骚操作",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E6%BB%A1%E8%84%91%E5%AD%90%E9%AA%9A%E6%93%8D%E4%BD%9C.gif"
+ },
+ {
+ "key": "稽之舞",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E7%A8%BD%E4%B9%8B%E8%88%9E.gif"
+ },
+ {
+ "key": "将稽就稽",
+ "val": "https://gcore.jsdelivr.net/gh/qwqcode/huaji/%E5%B0%86%E7%A8%BD%E5%B0%B1%E7%A8%BD.gif"
+ }
+ ]
+ }
+]
diff --git a/packages/artalk-sidebar/src/assets/favicon.png b/docs/public/favicon.png
similarity index 100%
rename from packages/artalk-sidebar/src/assets/favicon.png
rename to docs/public/favicon.png
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
new file mode 100644
index 000000000..415cf7dbf
--- /dev/null
+++ b/docs/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "types": ["vite/client"],
+ "resolveJsonModule": true
+ },
+ "include": ["**/.vitepress/**/*"],
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 000000000..9d2d2a4c4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,134 @@
+module github.com/ArtalkJS/Artalk
+
+go 1.19
+
+require (
+ github.com/PuerkitoBio/goquery v1.8.0
+ github.com/aliyun/alibaba-cloud-sdk-go v1.62.108
+ github.com/allegro/bigcache v1.2.1
+ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
+ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
+ github.com/blang/semver v3.5.1+incompatible
+ github.com/bradfitz/gomemcache v0.0.0-20221031212613-62deef7fc822
+ github.com/cheggaaa/pb/v3 v3.1.0
+ github.com/eko/gocache/lib/v4 v4.1.2
+ github.com/eko/gocache/store/bigcache/v4 v4.1.2
+ github.com/eko/gocache/store/memcache/v4 v4.1.2
+ github.com/eko/gocache/store/redis/v4 v4.1.2
+ github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc
+ github.com/go-testfixtures/testfixtures/v3 v3.8.1
+ github.com/gofiber/fiber/v2 v2.40.1
+ github.com/golang-jwt/jwt v3.2.2+incompatible
+ github.com/jedib0t/go-pretty/v6 v6.4.3
+ github.com/jeremywohl/flatten v1.0.1
+ github.com/knadh/koanf v1.4.4
+ github.com/microcosm-cc/bluemonday v1.0.21
+ github.com/nikoksr/notify v0.36.0
+ github.com/qwqcode/go-aliyun-email v0.0.0-20180120030821-cb6e7b1382bf
+ github.com/rhysd/go-github-selfupdate v1.2.3
+ github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5
+ github.com/samber/lo v1.37.0
+ github.com/sirupsen/logrus v1.9.0
+ github.com/spf13/cobra v1.6.1
+ github.com/steambap/captcha v1.4.1
+ github.com/stretchr/testify v1.8.1
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.570
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tms v1.0.570
+ github.com/tidwall/gjson v1.14.4
+ github.com/vmihailenco/msgpack v4.0.4+incompatible
+ github.com/x-cray/logrus-prefixed-formatter v0.5.2
+ github.com/yuin/goldmark v1.5.3
+ golang.org/x/crypto v0.4.0
+ golang.org/x/sync v0.1.0
+ golang.org/x/term v0.3.0
+ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
+ gopkg.in/yaml.v3 v3.0.1
+ gorm.io/driver/mysql v1.4.5
+ gorm.io/driver/postgres v1.4.6
+ gorm.io/driver/sqlite v1.4.4
+ gorm.io/driver/sqlserver v1.4.1
+ gorm.io/gorm v1.24.3
+)
+
+require (
+ github.com/VividCortex/ewma v1.2.0 // indirect
+ github.com/andybalholm/brotli v1.0.4 // indirect
+ github.com/andybalholm/cascadia v1.3.1 // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/blinkbean/dingtalk v0.0.0-20210905093040-7d935c0f7e19 // indirect
+ github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/denisenkom/go-mssqldb v0.12.3 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/fatih/color v1.13.0 // indirect
+ github.com/fsnotify/fsnotify v1.6.0 // indirect
+ github.com/go-sql-driver/mysql v1.7.0 // indirect
+ github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+ github.com/golang/mock v1.6.0 // indirect
+ github.com/golang/protobuf v1.5.2 // indirect
+ github.com/google/go-github/v30 v30.1.0 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/google/uuid v1.3.0 // indirect
+ github.com/gorilla/css v1.0.0 // indirect
+ github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/pgtype v1.13.0 // indirect
+ github.com/jackc/pgx/v4 v4.17.2 // indirect
+ github.com/jackc/pgx/v5 v5.2.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.15.9 // indirect
+ github.com/line/line-bot-sdk-go v7.8.0+incompatible // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mattn/go-runewidth v0.0.14 // indirect
+ github.com/mattn/go-sqlite3 v1.14.16 // indirect
+ github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
+ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
+ github.com/microsoft/go-mssqldb v0.19.0 // indirect
+ github.com/mitchellh/copystructure v1.2.0 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/mitchellh/reflectwalk v1.0.2 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/onsi/gomega v1.24.2 // indirect
+ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/prometheus/client_golang v1.14.0 // indirect
+ github.com/prometheus/client_model v0.3.0 // indirect
+ github.com/prometheus/common v0.39.0 // indirect
+ github.com/prometheus/procfs v0.9.0 // indirect
+ github.com/rivo/uniseg v0.4.3 // indirect
+ github.com/slack-go/slack v0.12.1 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/stretchr/objx v0.5.0 // indirect
+ github.com/tcnksm/go-gitconfig v0.1.2 // indirect
+ github.com/technoweenie/multipartstreamer v1.0.1 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/ulikunitz/xz v0.5.11 // indirect
+ github.com/utahta/go-linenotify v0.5.0 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.41.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+ golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 // indirect
+ golang.org/x/image v0.2.0 // indirect
+ golang.org/x/net v0.4.0 // indirect
+ golang.org/x/oauth2 v0.3.0 // indirect
+ golang.org/x/sys v0.3.0 // indirect
+ golang.org/x/text v0.5.0 // indirect
+ google.golang.org/appengine v1.6.7 // indirect
+ google.golang.org/protobuf v1.28.1 // indirect
+ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 000000000..ec643b353
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,868 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSajX3Oz4G5Gm7P+mbqE9FVaXXFYTkCM=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
+github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
+github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
+github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
+github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
+github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA=
+github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
+github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.108 h1:bQ1VT+mvjnNiUVnQpIBStbLD/qeFjr0KL+vr/mOVuE0=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.108/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
+github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc=
+github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
+github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk=
+github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
+github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
+github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
+github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
+github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
+github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
+github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw=
+github.com/aws/aws-sdk-go-v2/credentials v1.4.3/go.mod h1:FNNC6nQZQUuyhq5aE5c7ata8o9e4ECGmS4lAXC7o1mQ=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0/go.mod h1:gqlclDEZp4aqJOancXK6TN24aKhT0W0Ae9MHk3wzTMM=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4/go.mod h1:ZcBrrI3zBKlhGFNYWvju0I3TR93I7YIgAfy82Fh4lcQ=
+github.com/aws/aws-sdk-go-v2/service/appconfig v1.4.2/go.mod h1:FZ3HkCe+b10uFZZkFdvf98LHW21k49W8o8J366lqVKY=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72HRZDLMtmVQiLG2tLfQcaWLCssELvGl+Zf2WVxMmR8=
+github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk=
+github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g=
+github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
+github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+github.com/blinkbean/dingtalk v0.0.0-20210905093040-7d935c0f7e19 h1:pamuM2sgLJLoMWfchc6y071z8ifalajU7btZmZNhoH4=
+github.com/blinkbean/dingtalk v0.0.0-20210905093040-7d935c0f7e19/go.mod h1:9BaLuGSBqY3vT5hstValh48DbsKO7vaHaJnG9pXwbto=
+github.com/bradfitz/gomemcache v0.0.0-20221031212613-62deef7fc822 h1:hjXJeBcAMS1WGENGqDpzvmgS43oECTx8UXq31UBu0Jw=
+github.com/bradfitz/gomemcache v0.0.0-20221031212613-62deef7fc822/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04=
+github.com/cheggaaa/pb/v3 v3.1.0/go.mod h1:YjrevcBqadFDaGQKRdmZxTY42pXEqda48Ea3lt0K/BE=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
+github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw=
+github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/eko/gocache/lib/v4 v4.1.2 h1:cX54GhJJsfc5jvCEaPW8595h9Pq6bbNfkv0o/669Tw4=
+github.com/eko/gocache/lib/v4 v4.1.2/go.mod h1:FqyrANKct257VFHVVs11m6V2syGobOmHycQCyRSMwu0=
+github.com/eko/gocache/store/bigcache/v4 v4.1.2 h1:8uMDpgxTG7BvyLHBFqL3Ao809bVrXfrWqo7v6ALiwTw=
+github.com/eko/gocache/store/bigcache/v4 v4.1.2/go.mod h1:Lut5Sk/yI835w02tmwx4ecezYQo445L5sdICsk1zgho=
+github.com/eko/gocache/store/memcache/v4 v4.1.2 h1:ke3OolL6a/fgoSAvuRD42q6Q/80hDnbDrZeIjjguDlg=
+github.com/eko/gocache/store/memcache/v4 v4.1.2/go.mod h1:0raU4CSQgzihTahA4dxrxsafHt8j2NH1ATL33lSDpX4=
+github.com/eko/gocache/store/redis/v4 v4.1.2 h1:PtpjQu8Q698kpC3H06As5As15s+hJofV8NtkddVX5aQ=
+github.com/eko/gocache/store/redis/v4 v4.1.2/go.mod h1:P2HeqvNwqkdwajYro+qwBWquhdYpN0LgfH2rJ3jK7yg=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc h1:jZY+lpZB92nvBo2f31oPC/ivGll6NcsnEOORm8Fkr4M=
+github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc/go.mod h1:25mL1NKxbJhB63ihiK8MnNeTRd+xAizd6bOdydrTLUQ=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
+github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
+github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
+github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/go-testfixtures/testfixtures/v3 v3.8.1 h1:uonwvepqRvSgddcrReZQhojTlWlmOlHkYAb9ZaOMWgU=
+github.com/go-testfixtures/testfixtures/v3 v3.8.1/go.mod h1:Kdu7YeMC0KRXVHdaQ91Vmx3pcjoTF63h4f1qTJDdXLA=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofiber/fiber/v2 v2.40.1 h1:pc7n9VVpGIqNsvg9IPLQhyFEMJL8gCs1kneH5D1pIl4=
+github.com/gofiber/fiber/v2 v2.40.1/go.mod h1:Gko04sLksnHbzLSRBFWPFdzM9Ws9pRxvvIaohJK1dsk=
+github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
+github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
+github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
+github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
+github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v0.0.0-20171113160352-8c31c18f31ed/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
+github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ=
+github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
+github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
+github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
+github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY=
+github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
+github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
+github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
+github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
+github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
+github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
+github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q=
+github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
+github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
+github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
+github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
+github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
+github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
+github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
+github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
+github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
+github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
+github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
+github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
+github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
+github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
+github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
+github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
+github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
+github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
+github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
+github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
+github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
+github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
+github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
+github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
+github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
+github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
+github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
+github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
+github.com/jackc/pgtype v1.13.0 h1:XkIc7A+1BmZD19bB2NxrtjJweHxQ9agqvM+9URc68Cg=
+github.com/jackc/pgtype v1.13.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
+github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
+github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
+github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
+github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
+github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
+github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
+github.com/jackc/pgx/v5 v5.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8=
+github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk=
+github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle/v2 v2.1.2/go.mod h1:2lpufsF5mRHO6SuZkm0fNYxM6SWHfvyFj62KwNzgels=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/jedib0t/go-pretty/v6 v6.4.3 h1:2n9BZ0YQiXGESUSR+6FLg0WWWE80u+mIz35f0uHWcIE=
+github.com/jedib0t/go-pretty/v6 v6.4.3/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI=
+github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs=
+github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
+github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
+github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
+github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/knadh/koanf v1.4.4 h1:d2jY5nCCeoaiqvEKSBW9rEc93EfNy/XWgWsSB3j7JEA=
+github.com/knadh/koanf v1.4.4/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
+github.com/line/line-bot-sdk-go v7.8.0+incompatible h1:Uf9/OxV0zCVfqyvwZPH8CrdiHXXmMRa/L91G3btQblQ=
+github.com/line/line-bot-sdk-go v7.8.0+incompatible/go.mod h1:0RjLjJEAU/3GIcHkC3av6O4jInAbt25nnZVmOFUgDBg=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
+github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
+github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
+github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
+github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
+github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
+github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
+github.com/microsoft/go-mssqldb v0.19.0 h1:LMRSgLcNMF8paPX14xlyQBmBH+jnFylPsYpVZf86eHM=
+github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook=
+github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
+github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
+github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
+github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nikoksr/notify v0.36.0 h1:OeO/COtxZYLjtFuxBhpeVLfCFdGt48KKgOHKu43w8H0=
+github.com/nikoksr/notify v0.36.0/go.mod h1:U5h6rVleLTcAJASy7kRdD4vtsFBBxirWQKYX8NJ4jcw=
+github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.24.2 h1:J/tulyYK6JwBldPViHJReihxxZ+22FHs0piGjQAvoUE=
+github.com/onsi/gomega v1.24.2/go.mod h1:gs3J10IS7Z7r7eXRoNJIrNqU4ToQukCJhFtKrWgHWnk=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
+github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
+github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
+github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
+github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
+github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
+github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1-0.20161029093637-248dadf4e906/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
+github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
+github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI=
+github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
+github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
+github.com/qwqcode/go-aliyun-email v0.0.0-20180120030821-cb6e7b1382bf h1:FH9O7I17HCdVYbK1IMxTGgD3bVuTzyO45bR94iE+T/U=
+github.com/qwqcode/go-aliyun-email v0.0.0-20180120030821-cb6e7b1382bf/go.mod h1:5N3D7E1/M3G95YK4jPjcPRf/A7XBis0ni4VMmcVnnRY=
+github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
+github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
+github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
+github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 h1:mZHayPoR0lNmnHyvtYjDeq0zlVHn9K/ZXoy17ylucdo=
+github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5HgEKCvEIIrSpFI3ozzG5xOKA2DVlEX/gGnewM=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
+github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
+github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw=
+github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
+github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
+github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/slack-go/slack v0.12.1 h1:X97b9g2hnITDtNsNe5GkGx6O2/Sz/uC20ejRZN6QxOw=
+github.com/slack-go/slack v0.12.1/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
+github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
+github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/steambap/captcha v1.4.1 h1:OmMdxLCWCqJvsFaFYwRpvMckIuvI6s8s1LsBrBw97P0=
+github.com/steambap/captcha v1.4.1/go.mod h1:oC9T7IfEgnrhzjDz5Djf1H7GPffCzRMbsQfFkJmhlnk=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
+github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
+github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
+github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.570 h1:aL8rFQfuQq3G+WeSG1RlPibbfBju024qPC4dli8oqu8=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.570/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tms v1.0.570 h1:9aCzZ9NDu7UIf7yVuqPBK5dQZHViNyTbUSIsI2H66Pk=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tms v1.0.570/go.mod h1:jIUzBhvKj6TddPQXPK4gsHjDEWsP5MTP6TOMy0GiZts=
+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
+github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
+github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
+github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
+github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
+github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/utahta/go-linenotify v0.5.0 h1:E1tJaB/XhqRY/iz203FD0MaHm10DjQPOq5/Mem2A3Gs=
+github.com/utahta/go-linenotify v0.5.0/go.mod h1:KsvBXil2wx+ByaCR0e+IZKTbp4pDesc7yjzRigLf6pE=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.41.0 h1:zeR0Z1my1wDHTRiamBCXVglQdbUwgb9uWG3k1HQz6jY=
+github.com/valyala/fasthttp v1.41.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
+github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
+github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
+github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
+github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
+go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
+go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
+go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY=
+go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
+go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
+go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
+go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
+go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
+go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
+golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 h1:m9O6OTJ627iFnN2JIWfdqlZCzneRO6EEBsHXI25P8ws=
+golang.org/x/exp v0.0.0-20221230185412-738e83a70c30/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
+golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
+golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20171115151908-9dfe39835686/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
+golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
+golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
+golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
+golang.org/x/sync v0.0.0-20171101214715-fd80eb99c8f6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
+golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
+golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
+google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
+gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
+gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
+gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
+gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/mysql v1.4.5 h1:u1lytId4+o9dDaNcPCFzNv7h6wvmc92UjNk3z8enSBU=
+gorm.io/driver/mysql v1.4.5/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
+gorm.io/driver/postgres v1.4.6 h1:1FPESNXqIKG5JmraaH2bfCVlMQ7paLoCreFxDtqzwdc=
+gorm.io/driver/postgres v1.4.6/go.mod h1:UJChCNLFKeBqQRE+HrkFUbKbq9idPXmTOk2u4Wok8S4=
+gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
+gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
+gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
+gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
+gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
+gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
+gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
+gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg=
+gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/i18n/en.yml b/i18n/en.yml
new file mode 100644
index 000000000..383c27632
--- /dev/null
+++ b/i18n/en.yml
@@ -0,0 +1,75 @@
+"Access denied":
+"Account":
+"Admin access required":
+"Admin":
+"Cannot reply to this comment":
+"Captcha required":
+"Checking for updates":
+"Comment count":
+"Comment failed":
+"Comment":
+"Config file read failed":
+"Confirm to continue?":
+"Contains invalid URL":
+"Create admin account":
+"Current version is the latest":
+"Downloading":
+"Email":
+"Enter {{name}}":
+"Export complete":
+"Export error":
+"File":
+"First comment":
+"Image exceeds {{file_size}} limit":
+"Image upload forbidden":
+"Import complete":
+"Invalid request":
+"Invalid request. Please check your `trusted_domains` config.":
+"Invalid {{name}}":
+"Link":
+"Login failed":
+"Name":
+"New version available":
+"Nickname":
+"No comment":
+"Notify":
+"Page fetch failed":
+"Page":
+"Parameter":
+"Parent comment":
+"Password update failed":
+"Password updated":
+"Password":
+"Please review":
+"Retype {{name}}":
+"Save failed":
+"Saving":
+"Services restart complete":
+"Site `{{name}}` not found. Please create it in control center.":
+"Site":
+"Sub-comment":
+"Target Site":
+"Task executing in background, please wait...":
+"Task in progress, please wait a moment":
+"Type":
+"URL Resolver":
+"Unable to get `{{name}}`":
+"Unspecified":
+"Unsupported formats":
+"Update complete":
+"Update failed":
+"Upload image via {{method}} failed":
+"User":
+"Username":
+"Verification failed":
+"Working directory retrieval failed":
+"Wrong captcha":
+"{{count}} items imported":
+"{{done}} of {{total}} done":
+"{{name}} already exists":
+"{{name}} cannot be empty":
+"{{name}} creation failed":
+"{{name}} deletion failed":
+"{{name}} is required":
+"{{name}} not found":
+"{{name}} save failed":
diff --git a/i18n/zh-CN.yml b/i18n/zh-CN.yml
new file mode 100644
index 000000000..5156de06a
--- /dev/null
+++ b/i18n/zh-CN.yml
@@ -0,0 +1,75 @@
+"Access denied": 无权限
+"Account": 账户
+"Admin access required": 需要管理员权限
+"Admin": 管理员
+"Cannot reply to this comment": 无法回复此评论
+"Captcha required": 需要验证码
+"Checking for updates": 正在检查更新
+"Comment count": 评论数
+"Comment failed": 评论失败
+"Comment": 评论
+"Config file read failed": 配置文件读取失败
+"Confirm to continue?": 确认继续?
+"Contains invalid URL": 包含无效的 URL
+"Create admin account": 创建管理员账户
+"Current version is the latest": 当前版本已是最新的
+"Downloading": 下载中
+"Email": 邮箱
+"Enter {{name}}": 输入{{name}}
+"Export complete": 导出完毕
+"Export error": 导出失败
+"File": 文件
+"First comment": 第一条评论
+"Image exceeds {{file_size}} limit": 图片超过大小限制 {{file_size}}
+"Image upload forbidden": 禁止上传图片
+"Import complete": 导入完毕
+"Invalid request": 无效的请求
+"Invalid request. Please check your `trusted_domains` config.": 请求无效, 请检查 `trusted_domains` 配置项
+"Invalid {{name}}": 无效的{{name}}
+"Link": 链接
+"Login failed": 登陆失败
+"Name": 名称
+"New version available": 有更新可用
+"Nickname": 昵称
+"No comment": 无评论
+"Notify": 通知
+"Page fetch failed": 页面获取失败
+"Page": 页面
+"Parameter": 参数
+"Parent comment": 父评论
+"Password update failed": 密码修改失败
+"Password updated": 密码已修改
+"Password": 密码
+"Please review": 请过目
+"Retype {{name}}": 重新输入{{name}}
+"Save failed": 保存失败
+"Saving": 保存中
+"Services restart complete": 服务重启完毕
+"Site `{{name}}` not found. Please create it in control center.": 未找到站点:`{{name}}`,请在控制台创建站点
+"Site": 站点
+"Sub-comment": 子评论
+"Target Site": 目标站点
+"Task executing in background, please wait...": 任务已开始在后台执行,请稍后...
+"Task in progress, please wait a moment": 任务执行中,请稍后
+"Type": 类型
+"URL Resolver": URL 解析器
+"Unable to get `{{name}}`": 无法获取 `{{name}}`
+"Unspecified": 未指定
+"Unsupported formats": 不支持的格式
+"Update complete": 更新完毕
+"Update failed": 更新失败
+"Upload image via {{method}} failed": 通过 {{method}} 上传图片失败
+"User": 用户
+"Username": 用户名
+"Verification failed": 验证失败
+"Working directory retrieval failed": 工作目录获取失败
+"Wrong captcha": 验证码错误
+"{{count}} items imported": 已导入 {{count}} 个项目
+"{{done}} of {{total}} done": 已完成 {{done}} 共 {{total}} 个
+"{{name}} already exists": '{{name}}已存在'
+"{{name}} cannot be empty": '{{name}}不能为空'
+"{{name}} creation failed": '{{name}}创建失败'
+"{{name}} deletion failed": '{{name}}删除失败'
+"{{name}} is required": '{{name}}必须填写'
+"{{name}} not found": '{{name}}未找到'
+"{{name}} save failed": '{{name}}保存失败'
diff --git a/internal/anti_spam/anti_spam.go b/internal/anti_spam/anti_spam.go
new file mode 100644
index 000000000..4446c5188
--- /dev/null
+++ b/internal/anti_spam/anti_spam.go
@@ -0,0 +1,162 @@
+package anti_spam
+
+import (
+ "fmt"
+ "io/ioutil"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+var AntiSpamReplaceKeywords *[]string
+
+const LOG_TAG = "[Spam Interception] "
+
+func SyncSpamCheck(comment *entity.Comment, fiberCtx *fiber.Ctx) {
+ // 拦截评论
+ BlockCommentBy := func(blocker string) {
+ logrus.Info(fmt.Sprintf(LOG_TAG+"%s Successful blocking of comments ID=%d CONT=%s", blocker, comment.ID, strconv.Quote(comment.Content)))
+ if comment.IsPending {
+ return
+ }
+ comment.IsPending = true // 改为待审状态
+ query.UpdateComment(comment)
+ }
+
+ // 拦截失败处理
+ BlockFailBy := func(blocker string, err error) {
+ logrus.Error(fmt.Sprintf(LOG_TAG+"%s Interception error occurred ID=%d Err: %s", blocker, comment.ID, strconv.Quote(comment.Content)), err)
+ }
+
+ // 统一拦截处理
+ ApiCommonHandle := func(blocker string, isPass bool, err error) {
+ // ApiFailBlock mode
+ isApiFailBlock := config.Instance.Moderator.ApiFailBlock
+
+ if err != nil {
+ // Api 发生错误
+ BlockFailBy(blocker, err) // 报告错误
+ if isApiFailBlock {
+ BlockCommentBy(blocker) // 仍然拦截
+ }
+ } else if !isPass {
+ // Api 未发生错误,并且 not pass
+ BlockCommentBy(blocker) // 拦截评论
+ }
+ }
+
+ // Prepare data for Spam-Check
+ user := query.FetchUserForComment(comment)
+ siteURL := ""
+ if comment.SiteName != "" {
+ site := query.FindSite(comment.SiteName)
+ siteURL = query.CookSite(&site).FirstUrl
+ }
+ if siteURL == "" { // 从 referer 中提取网站
+ if pr, err := url.Parse(string(fiberCtx.Request().Header.Referer())); err == nil && pr.Scheme != "" && pr.Host != "" {
+ siteURL = fmt.Sprintf("%s://%s", pr.Scheme, pr.Host)
+ }
+ }
+
+ // Akismet
+ akismetKey := strings.TrimSpace(config.Instance.Moderator.AkismetKey)
+ if akismetKey != "" {
+ isPass, err := Akismet(&AkismetParams{
+ Blog: siteURL,
+
+ UserIP: fiberCtx.IP(),
+ UserAgent: string(fiberCtx.Request().Header.UserAgent()),
+
+ CommentType: "comment",
+ CommentAuthor: user.Name,
+ CommentAuthorEmail: user.Email,
+ CommentContent: comment.Content,
+ }, akismetKey)
+
+ ApiCommonHandle("Akismet", isPass, err)
+ }
+
+ // 腾讯云
+ tencentConf := config.Instance.Moderator.Tencent
+ if tencentConf.Enabled {
+ isPass, err := Tencent(TencentParams{
+ SecretID: tencentConf.SecretID,
+ SecretKey: tencentConf.SecretKey,
+ Region: tencentConf.Region,
+
+ Content: comment.Content,
+ CommentID: comment.ID,
+ UserID: comment.UserID,
+ UserIP: comment.IP,
+ UserName: user.Name,
+ })
+
+ ApiCommonHandle("腾讯云", isPass, err)
+ }
+
+ // 阿里云
+ aliyunConf := config.Instance.Moderator.Aliyun
+ if aliyunConf.Enabled {
+ isPass, err := Aliyun(AliyunParams{
+ AccessKeyID: aliyunConf.AccessKeyID,
+ AccessKeySecret: aliyunConf.AccessKeySecret,
+ Region: aliyunConf.Region,
+
+ Content: comment.Content,
+ CommentID: comment.ID,
+ })
+
+ ApiCommonHandle("阿里云", isPass, err)
+ }
+
+ // 关键字过滤
+ keywordsConf := config.Instance.Moderator.Keywords
+ if keywordsConf.Enabled {
+ // 懒加载,初始化
+ if AntiSpamReplaceKeywords == nil {
+ AntiSpamReplaceKeywords = &[]string{}
+ // 加载文件
+ for _, f := range keywordsConf.Files {
+ buf, err := ioutil.ReadFile(f)
+ if err != nil {
+ logrus.Error("Failed to load Keyword Dictionary file:" + f)
+ } else {
+ fileContent := string(buf)
+ *AntiSpamReplaceKeywords = append(*AntiSpamReplaceKeywords, utils.SplitAndTrimSpace(fileContent, keywordsConf.FileSep)...)
+ }
+ }
+ }
+
+ // 关键词过滤
+ handleContent := comment.Content
+ replaced := false
+ for _, keyword := range *AntiSpamReplaceKeywords {
+ if strings.Contains(handleContent, keyword) {
+ if keywordsConf.Pending {
+ BlockCommentBy("Keyword")
+ break
+ }
+
+ if keywordsConf.ReplacTo != "" {
+ handleContent = strings.Replace(handleContent, keyword, strings.Repeat(keywordsConf.ReplacTo, len([]rune(keyword))), -1)
+ replaced = true
+ }
+ }
+ }
+
+ if !keywordsConf.Pending && replaced && keywordsConf.ReplacTo != "" {
+ logrus.Info(fmt.Sprintf(LOG_TAG+"Keyword Replacement Comments ID=%d Original=%s Processed=%s", comment.ID, strconv.Quote(comment.Content), strconv.Quote(handleContent)))
+
+ // 保存评论
+ comment.Content = handleContent
+ query.UpdateComment(comment)
+ }
+ }
+}
diff --git a/internal/anti_spam/anti_spam_akismet.go b/internal/anti_spam/anti_spam_akismet.go
new file mode 100644
index 000000000..ff45c4d77
--- /dev/null
+++ b/internal/anti_spam/anti_spam_akismet.go
@@ -0,0 +1,86 @@
+package anti_spam
+
+import (
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "reflect"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/sirupsen/logrus"
+)
+
+type AkismetParams struct {
+ Blog string `name:"blog"` // required
+ UserIP string `name:"user_ip"` // required
+ UserAgent string `name:"user_agent"` // required
+
+ CommentType string `name:"comment_type"`
+ CommentAuthor string `name:"comment_author"`
+ CommentAuthorEmail string `name:"comment_author_email"`
+ CommentAuthorURL string `name:"comment_author_url"`
+ CommentContent string `name:"comment_content"`
+
+ UserRole string `name:"user_role"`
+ Referrer string `name:"referrer"`
+ Permalink string `name:"permalink"`
+ BlogLang string `name:"blog_lang"`
+ BlogCharset string `name:"blog_charset"`
+}
+
+// @link https://akismet.com/development/api/#comment-check
+func Akismet(params *AkismetParams, key string) (isPass bool, err error) {
+ form := url.Values{}
+
+ v := reflect.ValueOf(*params)
+ t := v.Type()
+ for i := 0; i < v.Type().NumField(); i++ {
+ if v.Field(i).String() != "" {
+ form.Add(t.Field(i).Tag.Get("name"), v.Field(i).String())
+ }
+ }
+
+ client := &http.Client{}
+
+ reqBody := strings.NewReader(form.Encode())
+ api := fmt.Sprintf("https://%s.rest.akismet.com/1.1/comment-check", key)
+ req, err := http.NewRequest("POST", api, reqBody)
+ if err != nil {
+ return false, err
+ }
+
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return false, err
+ }
+
+ defer resp.Body.Close()
+ respBody, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return false, err
+ }
+
+ respStr := string(respBody)
+
+ if config.Instance.Debug {
+ logrus.Info("akismet Spam Detection Response ", respStr)
+ }
+
+ switch respStr {
+ case "true":
+ // is a spam comment
+ isPass = false
+ return isPass, nil
+ case "false":
+ // not a spam comment
+ isPass = true
+ return isPass, nil
+ }
+
+ return false, errors.New(respStr)
+}
diff --git a/internal/anti_spam/anti_spam_aliyun.go b/internal/anti_spam/anti_spam_aliyun.go
new file mode 100644
index 000000000..fafaf501f
--- /dev/null
+++ b/internal/anti_spam/anti_spam_aliyun.go
@@ -0,0 +1,67 @@
+package anti_spam
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/aliyun/alibaba-cloud-sdk-go/services/green"
+ "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+)
+
+type AliyunParams struct {
+ AccessKeyID string
+ AccessKeySecret string
+ Region string
+
+ Content string
+ CommentID uint
+}
+
+// 阿里云反垃圾
+// @link https://help.aliyun.com/document_detail/70409.html
+// @link https://help.aliyun.com/document_detail/107743.html 接入地址
+func Aliyun(p AliyunParams) (isPass bool, err error) {
+ // Prepare Request
+ if p.Region == "" {
+ p.Region = "cn-shanghai"
+ }
+
+ client, err := green.NewClientWithAccessKey(p.Region, p.AccessKeyID, p.AccessKeySecret)
+ if err != nil {
+ return false, err
+ }
+
+ reqJSON := fmt.Sprintf(`{"scenes":["antispam"],"tasks":[{"content":%s}]}`, strconv.Quote(p.Content))
+
+ // Send Request
+ textScanReq := green.CreateTextScanRequest()
+ textScanReq.SetContent([]byte(reqJSON))
+ textScanResp, err := client.TextScan(textScanReq)
+ if err != nil {
+ return false, err
+ }
+
+ if textScanResp.GetHttpStatus() != 200 {
+ return false, errors.New("response got: " + strconv.Itoa(textScanResp.GetHttpStatus()))
+ }
+
+ // Handle Respone
+ // @link https://help.aliyun.com/document_detail/70439.html
+ respRaw := textScanResp.GetHttpContentString()
+ dataRaw := gjson.Get(respRaw, "data.0.results.0.suggestion")
+ if !dataRaw.Exists() {
+ return false, errors.New("unexpected JSON: " + respRaw)
+ }
+
+ // Get Result
+ suggestion := dataRaw.String()
+
+ if config.Instance.Debug {
+ logrus.Info("[阿里云垃圾检测] ", fmt.Sprintf("%d 请求响应 %s\n%s", p.CommentID, suggestion, respRaw))
+ }
+
+ return (suggestion == "pass"), nil
+}
diff --git a/internal/anti_spam/anti_spam_tencent.go b/internal/anti_spam/anti_spam_tencent.go
new file mode 100644
index 000000000..d4ed3ddbf
--- /dev/null
+++ b/internal/anti_spam/anti_spam_tencent.go
@@ -0,0 +1,83 @@
+package anti_spam
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/sirupsen/logrus"
+
+ // 腾讯啊腾讯.... 怎么这么多引入啊?
+ tCommon "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
+ tErr "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
+ tProfile "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
+ tms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tms/v20200713"
+)
+
+type TencentParams struct {
+ SecretID string
+ SecretKey string
+ Region string
+
+ Content string
+ CommentID uint
+
+ UserName string
+ UserID uint
+ UserIP string
+}
+
+// 腾讯云文本内容安全 TMS
+// @link https://cloud.tencent.com/document/product/1124/51860
+// @link https://console.cloud.tencent.com/cms/text/overview
+func Tencent(p TencentParams) (isPass bool, err error) {
+ // Prepare Request Sign
+ if p.Region == "" {
+ p.Region = "ap-guangzhou"
+ }
+
+ credential := tCommon.NewCredential(p.SecretID, p.SecretKey)
+
+ cpf := tProfile.NewClientProfile()
+ cpf.HttpProfile.Endpoint = "tms.tencentcloudapi.com"
+ client, err := tms.NewClient(credential, p.Region, cpf)
+ if err != nil {
+ return false, errors.New("func NewClient calls error: " + err.Error())
+ }
+
+ // Prepare Request Data
+ dataID := fmt.Sprintf("comment-%d", p.CommentID)
+ userID := fmt.Sprintf("%d", p.UserID)
+
+ request := tms.NewTextModerationRequest()
+ request.DataId = &dataID
+ request.User = &tms.User{
+ UserId: &userID,
+ Nickname: &p.UserName,
+ }
+ request.Device = &tms.Device{
+ IP: &p.UserIP,
+ }
+
+ contentBase64 := base64.StdEncoding.EncodeToString([]byte(p.Content))
+ request.Content = &contentBase64
+
+ // Send Request
+ response, err := client.TextModeration(request)
+ if _, hasErr := err.(*tErr.TencentCloudSDKError); hasErr {
+ return false, errors.New("an API error has returned: " + err.Error())
+ }
+ if err != nil {
+ return false, err
+ }
+
+ // Get Result
+ suggestion := response.Response.Suggestion
+
+ if config.Instance.Debug {
+ logrus.Info("[腾讯云垃圾检测] ", fmt.Sprintf("%s 请求响应 %s\n%s", dataID, response.ToJsonString(), request.ToJsonString()))
+ }
+
+ return (suggestion != nil && *suggestion == "Pass"), nil
+}
diff --git a/internal/artransfer/artrans.go b/internal/artransfer/artrans.go
new file mode 100644
index 000000000..d6bc8d10e
--- /dev/null
+++ b/internal/artransfer/artrans.go
@@ -0,0 +1,269 @@
+package artransfer
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/cheggaaa/pb/v3"
+)
+
+var ArtransImporter = &_ArtransImporter{
+ ImporterInfo: ImporterInfo{
+ Name: "artrans",
+ Desc: "Import from Artrans",
+ Note: "",
+ },
+}
+
+type _ArtransImporter struct {
+ ImporterInfo
+}
+
+func (imp *_ArtransImporter) Run(basic *BasicParams, payload []string) {
+ // 读取文件
+ jsonStr, jErr := JsonFileReady(payload)
+ if jErr != nil {
+ logFatal(jErr)
+ return
+ }
+
+ ImportArtransByStr(basic, jsonStr)
+}
+
+func ImportArtransByStr(basic *BasicParams, str string) {
+ // 解析内容
+ comments := []entity.Artran{}
+ dErr := JsonDecodeFAS(str, &comments)
+ if dErr != nil {
+ logFatal(dErr)
+ return
+ }
+
+ ImportArtrans(basic, comments)
+}
+
+func ImportArtrans(basic *BasicParams, srcComments []entity.Artran) {
+ if len(srcComments) == 0 {
+ logFatal(i18n.T("No comment"))
+ return
+ }
+
+ if basic.TargetSiteUrl != "" && !utils.ValidateURL(basic.TargetSiteUrl) {
+ logFatal(i18n.T("Invalid {{name}}", map[string]interface{}{"name": i18n.T("Target Site") + " " + "URL"}))
+ return
+ }
+
+ // 汇总
+ print("# " + i18n.T("Please review") + ":\n\n")
+
+ // 第一条评论
+ PrintEncodeData(i18n.T("First comment"), srcComments[0])
+
+ showTSiteName := basic.TargetSiteName
+ showTSiteUrl := basic.TargetSiteUrl
+ if showTSiteName == "" {
+ showTSiteName = i18n.T("Unspecified")
+
+ }
+ if showTSiteUrl == "" {
+ showTSiteUrl = i18n.T("Unspecified")
+ }
+
+ // 目标站点名和目标站点URL都不为空,才开启 URL 解析器
+ showUrlResolver := "off"
+ if basic.UrlResolver {
+ showUrlResolver = "on"
+ }
+ // if basic.TargetSiteName != "" && basic.TargetSiteUrl != "" {
+ // basic.UrlResolver = true
+ // showUrlResolver = "on"
+ // }
+
+ PrintTable([][]interface{}{
+ {i18n.T("Target Site") + " " + i18n.T("Name"), showTSiteName},
+ {i18n.T("Target Site") + " URL", showTSiteUrl},
+ {i18n.T("Comment count"), fmt.Sprintf("%d", len(srcComments))},
+ {i18n.T("URL Resolver"), showUrlResolver},
+ })
+
+ print("\n")
+
+ // 确认开始
+ if !Confirm(i18n.T("Confirm to continue?")) {
+ os.Exit(0)
+ }
+
+ // 准备导入评论
+ print("\n")
+
+ importComments := []entity.Comment{}
+ srcIdToIndexMap := map[string]uint{} // 源 ID 映射表 srcID => index
+ createdDates := map[int]time.Time{}
+ updatedDates := map[int]time.Time{}
+
+ // 解析 comments
+ for i, c := range srcComments {
+ srcIdToIndexMap[c.ID] = uint(i + 1) // 防 0 出没
+ }
+
+ for i, c := range srcComments {
+ siteName := c.SiteName
+ siteUrls := c.SiteUrls
+
+ if basic.TargetSiteName != "" {
+ siteName = basic.TargetSiteName
+ }
+ if basic.TargetSiteUrl != "" {
+ siteUrls = basic.TargetSiteUrl
+ }
+
+ // 准备 site
+ site, sErr := SiteReady(siteName, siteUrls)
+ if sErr != nil {
+ logFatal(sErr)
+ return
+ }
+
+ // 准备 user
+ user := query.FindCreateUser(c.Nick, c.Email, c.Link)
+ if !user.IsAdmin {
+ userModified := false
+ if c.BadgeName != "" && c.BadgeName != user.BadgeName {
+ user.BadgeName = c.BadgeName
+ userModified = true
+ }
+ if c.BadgeColor != "" && c.BadgeColor != user.BadgeColor {
+ user.BadgeColor = c.BadgeColor
+ userModified = true
+ }
+ if userModified {
+ query.UpdateUser(&user)
+ }
+ }
+
+ // 准备 page
+ nPageKey := c.PageKey
+ if basic.UrlResolver { // 使用 URL 解析器
+ splittedURLs := utils.SplitAndTrimSpace(basic.TargetSiteUrl, ",")
+ nPageKey = UrlResolverGetPageKey(splittedURLs[0], c.PageKey)
+ }
+
+ page := query.FindCreatePage(nPageKey, c.PageTitle, site.Name)
+
+ adminOnlyVal := c.PageAdminOnly == utils.ToString(true)
+ if page.AdminOnly != adminOnlyVal {
+ page.AdminOnly = adminOnlyVal
+ query.UpdatePage(&page)
+ }
+
+ voteUp, _ := strconv.Atoi(c.VoteUp)
+ voteDown, _ := strconv.Atoi(c.VoteDown)
+
+ // 创建新 comment 实例
+ nComment := entity.Comment{
+ Rid: srcIdToIndexMap[c.Rid], // [-1-] rid => index+1
+
+ Content: c.Content,
+
+ UA: c.UA,
+ IP: c.IP,
+
+ IsCollapsed: c.IsCollapsed == utils.ToString(true),
+ IsPending: c.IsPending == utils.ToString(true),
+ IsPinned: c.IsPinned == utils.ToString(true),
+
+ VoteUp: voteUp,
+ VoteDown: voteDown,
+
+ UserID: user.ID,
+ PageKey: page.Key,
+ SiteName: site.Name,
+ }
+
+ // 时间还原
+ createdDates[i] = ParseDate(c.CreatedAt)
+ if c.UpdatedAt != "" {
+ updatedDates[i] = ParseDate(c.UpdatedAt)
+ } else {
+ updatedDates[i] = ParseDate(c.CreatedAt)
+ }
+
+ importComments = append(importComments, nComment)
+ }
+
+ println(i18n.T("Saving") + "...")
+
+ // Batch Insert
+ // @link https://gorm.io/docs/create.html#Batch-Insert
+ db.DB().CreateInBatches(&importComments, 100)
+
+ // ID 变更映射表 index => new_db_id
+ indexToDbIdMap := map[uint]uint{}
+ for i, savedComment := range importComments {
+ indexToDbIdMap[uint(i+1)] = savedComment.ID
+ }
+
+ // 进度条
+ var bar *pb.ProgressBar
+ if HttpOutput == nil {
+ bar = pb.StartNew(len(srcComments))
+ }
+
+ total := len(srcComments)
+
+ for i, savedComment := range importComments {
+ // 日期恢复
+ // @see https://gorm.io/zh_CN/docs/conventions.html#CreatedAt
+ // @see https://github.com/go-gorm/gorm/issues/4827#issuecomment-960480148 无语...
+ // TODO
+ // savedComment.CreatedAt = createdDates[i] // 无效
+ // savedComment.UpdatedAt = updatedDates[i]
+
+ updateData := map[string]interface{}{
+ "CreatedAt": createdDates[i],
+ "UpdatedAt": updatedDates[i],
+ }
+
+ // Rid 重建
+ if savedComment.Rid != 0 {
+ updateData["Rid"] = indexToDbIdMap[savedComment.Rid] // [-2-] index+1 => db_new_id
+ }
+
+ db.DB().Model(&savedComment).Updates(updateData)
+
+ // Vote 重建 (伪投票)
+ if savedComment.VoteUp > 0 {
+ for i := 0; i < savedComment.VoteUp; i++ {
+ query.NewVote(savedComment.ID, entity.VoteTypeCommentUp, 0, "", "")
+ }
+ }
+ if savedComment.VoteDown > 0 {
+ for i := 0; i < savedComment.VoteDown; i++ {
+ query.NewVote(savedComment.ID, entity.VoteTypeCommentDown, 0, "", "")
+ }
+ }
+
+ if bar != nil {
+ bar.Increment()
+ }
+ if HttpOutput != nil && i%50 == 0 {
+ print(fmt.Sprintf("%.0f%%... ", float64(i)/float64(total)*100))
+ }
+ }
+
+ if bar != nil {
+ bar.Finish()
+ }
+ if HttpOutput != nil {
+ println()
+ }
+
+ logInfo(i18n.T("{{count}} items imported", map[string]interface{}{"count": len(srcComments)}))
+}
diff --git a/internal/artransfer/example.go b/internal/artransfer/example.go
new file mode 100644
index 000000000..21cde622e
--- /dev/null
+++ b/internal/artransfer/example.go
@@ -0,0 +1,16 @@
+package artransfer
+
+var ExampleImporter = &_ExampleImporter{
+ ImporterInfo: ImporterInfo{
+ Name: "example",
+ Desc: "Import data from ",
+ Note: "",
+ },
+}
+
+type _ExampleImporter struct {
+ ImporterInfo
+}
+
+func (imp *_ExampleImporter) Run(basic *BasicParams, payload []string) {
+}
diff --git a/internal/artransfer/exporter.go b/internal/artransfer/exporter.go
new file mode 100644
index 000000000..72504bf73
--- /dev/null
+++ b/internal/artransfer/exporter.go
@@ -0,0 +1,29 @@
+package artransfer
+
+import (
+ "encoding/json"
+
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "gorm.io/gorm"
+)
+
+func ExportArtransString(dbScopes ...func(*gorm.DB) *gorm.DB) (string, error) {
+ comments := []entity.Comment{}
+ db.DB().Scopes(dbScopes...).Find(&comments)
+
+ artrans := []entity.Artran{}
+ for _, c := range comments {
+ ct := query.CommentToArtran(&c)
+ artrans = append(artrans, ct)
+ }
+
+ jsonByte, err := json.Marshal(artrans)
+ if err != nil {
+ return "", err
+ }
+ jsonStr := string(jsonByte)
+
+ return jsonStr, nil
+}
diff --git a/internal/artransfer/importer.go b/internal/artransfer/importer.go
new file mode 100644
index 000000000..0af778a51
--- /dev/null
+++ b/internal/artransfer/importer.go
@@ -0,0 +1,344 @@
+package artransfer
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/url"
+ "os"
+ "reflect"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/araddon/dateparse"
+)
+
+func RunImportArtrans(payload []string) {
+ basic := GetBasicParamsFrom(payload)
+
+ name := ArtransImporter.ImporterInfo.Name
+ desc := ArtransImporter.ImporterInfo.Desc
+ note := ArtransImporter.ImporterInfo.Note
+
+ print("\n")
+ tableData := [][]interface{}{
+ {"Artransfer - Import"},
+ {strings.ToUpper(name)},
+ {desc},
+ }
+ if note != "" {
+ tableData = append(tableData, []interface{}{note})
+ }
+ PrintTable(tableData)
+ print("\n")
+
+ //t1 := time.Now()
+ ArtransImporter.Run(basic, payload)
+ //elapsed := time.Since(t1)
+
+ print("\n")
+ logInfo(i18n.T("Import complete")) //,耗时: ", elapsed)
+}
+
+type ImporterInfo struct {
+ Name string
+ Desc string
+ Note string
+}
+
+func GetImporterInfo(instance interface{}) ImporterInfo {
+ var info ImporterInfo
+ j, _ := json.Marshal(instance)
+ json.Unmarshal(j, &info)
+ return info
+}
+
+type BasicParams struct {
+ TargetSiteName string
+ TargetSiteUrl string
+
+ UrlResolver bool
+}
+
+func GetBasicParamsFrom(payload []string) *BasicParams {
+ basic := BasicParams{}
+
+ basic.UrlResolver = false // 默认关闭
+
+ GetParamsFrom(payload).To(map[string]interface{}{
+ "t_name": &basic.TargetSiteName,
+ "t_url": &basic.TargetSiteUrl,
+ "t_url_resolver": &basic.UrlResolver,
+ })
+
+ if !basic.UrlResolver {
+ logWarn("Target site URL resolver disabled")
+ }
+
+ return &basic
+}
+
+func RequiredBasicTargetSite(basic *BasicParams) error {
+ if basic.TargetSiteName == "" {
+ return errors.New(i18n.T("{{name}} is required", map[string]interface{}{"name": "t_name:"}))
+ }
+ if basic.TargetSiteUrl == "" {
+ return errors.New(i18n.T("{{name}} is required", map[string]interface{}{"name": "t_url:"}))
+ }
+ if !utils.ValidateURL(basic.TargetSiteUrl) {
+ return errors.New("invalid URL for parameter `t_url:`")
+ }
+
+ return nil
+}
+
+// 站点准备
+func SiteReady(tSiteName string, tSiteUrls string) (entity.Site, error) {
+ site := query.FindSite(tSiteName)
+ if site.IsEmpty() {
+ // 创建新站点
+ site = entity.Site{}
+ site.Name = tSiteName
+ site.Urls = tSiteUrls
+ err := query.CreateSite(&site)
+ if err != nil {
+ return entity.Site{}, errors.New("failed to create site")
+ }
+ } else {
+ // 追加 URL
+ siteCooked := query.CookSite(&site)
+
+ urlExist := func(tUrl string) bool {
+ for _, u := range siteCooked.Urls {
+ if u == tUrl {
+ return true
+ }
+ }
+ return false
+ }
+
+ tUrlsSpit := utils.SplitAndTrimSpace(tSiteUrls, ",")
+
+ rUrls := []string{}
+ for _, u := range tUrlsSpit {
+ if !urlExist(u) {
+ rUrls = append(rUrls, u) // prepend 不存在的站点
+ }
+ }
+
+ if len(rUrls) > 0 {
+ // 保存
+ rUrls = append(rUrls, siteCooked.Urls...)
+ site.Urls = strings.Join(rUrls, ",")
+ err := query.UpdateSite(&site)
+ if err != nil {
+ return entity.Site{}, errors.New("update site data failed")
+ }
+ }
+ }
+
+ return site, nil
+}
+
+func JsonFileReady(payload []string) (string, error) {
+ var jsonFile, jsonData string
+ GetParamsFrom(payload).To(map[string]interface{}{
+ "json_file": &jsonFile,
+ "json_data": &jsonData,
+ })
+
+ // 直接给 JSON 内容,不去读取文件
+ if jsonData != "" {
+ return jsonData, nil
+ }
+
+ if jsonFile == "" {
+
+ return "", errors.New(i18n.T("{{name}} is required", map[string]interface{}{"name": "json_file:"}))
+ }
+ if _, err := os.Stat(jsonFile); errors.Is(err, os.ErrNotExist) {
+ return "", errors.New(i18n.T("{{name}} not found", map[string]interface{}{"name": i18n.T("File")}))
+ }
+
+ buf, err := ioutil.ReadFile(jsonFile)
+ if err != nil {
+ return "", errors.New("file open failed" + ": " + err.Error())
+ }
+
+ return string(buf), nil
+}
+
+// PageKey (commentUrlVal 不确定是否为完整 URL 还是一个 path)
+//
+// @examples
+// ("https://github.com", "/1.html") => "https://github.com/1.html"
+// ("https://github.com", "https://xxx.com/1.html") => "https://github.com/1.html"
+// ("https://github.com/", "/1.html") => "https://github.com/1.html"
+// ("", "/1.html") => "/1.html"
+// ("", "https://xxx.com/1.html") => "https://xxx.com/1.html"
+// ("https://github.com/233", "/1/") => "https://github.com/1/"
+func UrlResolverGetPageKey(baseUrlRaw string, commentUrlRaw string) string {
+ if baseUrlRaw == "" {
+ return commentUrlRaw
+ }
+
+ baseUrl, err := url.Parse(baseUrlRaw)
+ if err != nil {
+ return commentUrlRaw
+ }
+
+ commentUrl, err := url.Parse(commentUrlRaw)
+ if err != nil {
+ return commentUrlRaw
+ }
+
+ // "https://artalk.js.org/guide/describe.html?233" => "/guide/describe.html?233"
+ commentUrl.Scheme = ""
+ commentUrl.Host = ""
+
+ // 解决拼接路径中的相对地址,例如:https://atk.xxx/abc/../artalk => https://atk.xxx/artalk
+ url := baseUrl.ResolveReference(commentUrl)
+
+ return url.String()
+}
+
+func ParseDate(s string) time.Time {
+ denverLoc, _ := time.LoadLocation(config.Instance.TimeZone) // 时区
+ time.Local = denverLoc
+ t, _ := dateparse.ParseIn(s, denverLoc)
+
+ return t
+}
+
+type _getParamsTo struct {
+ To func(variables map[string]interface{})
+}
+
+func GetParamsFrom(payload []string) _getParamsTo {
+ a := _getParamsTo{}
+ a.To = func(variables map[string]interface{}) {
+ for _, pVal := range payload {
+ for fromName, toVar := range variables {
+ if !strings.HasPrefix(pVal, fromName+":") {
+ continue
+ }
+
+ valStr := strings.TrimPrefix(pVal, fromName+":")
+
+ switch reflect.ValueOf(toVar).Interface().(type) {
+ case *string:
+ *toVar.(*string) = valStr
+ case *bool:
+ *toVar.(*bool) = strings.EqualFold(valStr, "true")
+ case *int:
+ num, err := strconv.Atoi(valStr)
+ if err != nil {
+ *toVar.(*int) = num
+ }
+ }
+ break
+ }
+ }
+ }
+ return a
+}
+
+func GetArrayParamsFrom(payload []string, key string) []string {
+ arr := []string{}
+ for _, pVal := range payload {
+ if strings.HasPrefix(pVal, key+":") {
+ arr = append(arr, strings.TrimPrefix(pVal, key+":"))
+ }
+ }
+
+ return arr
+}
+
+func CheckIfJsonArr(str string) bool {
+ x := bytes.TrimSpace([]byte(str))
+ return len(x) > 0 && x[0] == '['
+}
+
+func CheckIfJsonObj(str string) bool {
+ x := bytes.TrimSpace([]byte(str))
+ return len(x) > 0 && x[0] == '{'
+}
+
+func TryConvertLineJsonToArr(str string) (string, error) {
+ // 尝试将一行一行的 Obj 转成 Arr
+ arrTmp := []map[string]interface{}{}
+ for _, line := range strings.Split(strings.TrimSpace(str), "\n") {
+ var tmp map[string]interface{}
+ err := json.Unmarshal([]byte(line), &tmp)
+ if err != nil {
+ return "", err
+ }
+ arrTmp = append(arrTmp, tmp)
+ }
+ r, err := json.Marshal(arrTmp)
+ if err != nil {
+ return "", err
+ }
+ return string(r), nil
+}
+
+// Json Decode (FAS: Fields All String Type)
+// 解析 json 为字段全部是 string 类型的 struct
+func JsonDecodeFAS(str string, fasStructure interface{}) error {
+ if !CheckIfJsonArr(str) {
+ var err error
+ str, err = TryConvertLineJsonToArr(str)
+ if err != nil {
+ return errors.New("JSON of array type is required: " + err.Error())
+ }
+ }
+
+ err := json.Unmarshal([]byte(utils.JsonObjInArrAnyStr(str)), fasStructure) // lib.ToString()
+ if err != nil {
+ return errors.New("failed to parse JSON: " + err.Error())
+ }
+
+ return nil
+}
+
+func HideJsonLongText(key string, text string) string {
+ r := regexp.MustCompile(key + `:"(.+?)"`)
+ sm := r.FindStringSubmatch(text)
+ postText := ""
+ if len(sm) > 0 {
+ postText = sm[1]
+ }
+
+ text = r.ReplaceAllString(text, fmt.Sprintf(key+": ", utf8.RuneCountInString(postText)))
+ return text
+}
+
+// 传入 ID 变更表 (原始ID => 数据库已存在记录的ID) rid 将根据此替换
+func RebuildRid(idChanges map[uint]uint) {
+ for _, newId := range idChanges {
+ nComment := query.FindComment(newId)
+ if nComment.Rid == 0 {
+ continue
+ }
+ if newId, isExist := idChanges[nComment.Rid]; isExist {
+ nComment.Rid = newId
+ err := query.UpdateComment(&nComment)
+ if err != nil {
+ logError(fmt.Sprintf("[rid 更新] new_id:%d new_rid:%d", nComment.ID, newId), err)
+ }
+ }
+ }
+
+ print("\n")
+ logInfo("RID 重构完毕")
+}
diff --git a/internal/artransfer/output.go b/internal/artransfer/output.go
new file mode 100644
index 000000000..51f07da9b
--- /dev/null
+++ b/internal/artransfer/output.go
@@ -0,0 +1,138 @@
+package artransfer
+
+import (
+ "bufio"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/jedib0t/go-pretty/v6/table"
+ "github.com/sirupsen/logrus"
+)
+
+// TODO 只支持单线执行,同时请求两个地方会出问题
+var HttpOutput func(continueRun bool, text string)
+var Assumeyes bool = false
+
+func logError(a ...interface{}) {
+ if HttpOutput != nil {
+ HttpOutput(true, "[ERROR] "+fmt.Sprint(a...))
+ return
+ }
+
+ logrus.Error(a...)
+}
+
+func logFatal(a ...interface{}) {
+ if HttpOutput != nil {
+ HttpOutput(false, "[FATAL] "+fmt.Sprint(a...))
+ return
+ }
+
+ logrus.Fatal(a...)
+}
+
+func logWarn(a ...interface{}) {
+ if HttpOutput != nil {
+ HttpOutput(true, "[WARN] "+fmt.Sprint(a...))
+ return
+ }
+
+ logrus.Warn(a...)
+}
+
+func logInfo(a ...interface{}) {
+ if HttpOutput != nil {
+ HttpOutput(true, "[INFO] "+fmt.Sprint(a...))
+ return
+ }
+
+ logrus.Info(a...)
+}
+
+func print(a ...interface{}) {
+ if HttpOutput != nil {
+ HttpOutput(true, fmt.Sprint(a...))
+ return
+ }
+
+ fmt.Print(a...)
+}
+
+func printf(format string, a ...interface{}) {
+ print(fmt.Sprintf(format, a...))
+}
+
+func println(a ...interface{}) {
+ print(fmt.Sprintln(a...))
+}
+
+func PrintTable(rows [][]interface{}) {
+ if HttpOutput != nil {
+ println("-------------------------")
+ for _, row := range rows {
+ l := len(row)
+ print(" + ")
+ for i, col := range row {
+ print(col)
+ if i < l-1 {
+ print(": ")
+ }
+ }
+ println()
+ }
+ println("-------------------------")
+ return
+ }
+
+ t := table.NewWriter()
+
+ for _, r := range rows {
+ t.AppendRow(r)
+ }
+
+ tStyle := table.StyleLight
+ tStyle.Options.SeparateRows = true
+ t.SetStyle(tStyle)
+
+ println(t.Render())
+}
+
+func PrintEncodeData(dataType string, val interface{}) {
+ print(SprintEncodeData(dataType, val))
+}
+
+func SprintEncodeData(dataType string, val interface{}) string {
+ return fmt.Sprintf("[%s]\n\n %#v\n\n", dataType, val)
+}
+
+func Confirm(s string) bool {
+ if Assumeyes {
+ printf("%s [y/n]: y", s)
+ return true
+ }
+
+ r := bufio.NewReader(os.Stdin)
+
+ for {
+ printf("%s [y/n]: ", s)
+
+ res, err := r.ReadString('\n')
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Empty input (i.e. "\n")
+ if len(res) < 2 {
+ continue
+ }
+
+ resp := strings.ToLower(strings.TrimSpace(res))
+ if resp == "y" || resp == "yes" {
+ return true
+ } else if resp == "n" || resp == "no" {
+ return false
+ }
+ }
+}
diff --git a/internal/cache/actions.go b/internal/cache/actions.go
new file mode 100644
index 000000000..565322694
--- /dev/null
+++ b/internal/cache/actions.go
@@ -0,0 +1,209 @@
+package cache
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/eko/gocache/lib/v4/store"
+ "github.com/sirupsen/logrus"
+ "golang.org/x/sync/singleflight"
+)
+
+var (
+ CacheFindGroup = new(singleflight.Group)
+)
+
+func FindAndStoreCache(name string, dest interface{}, queryDBResult func() interface{}) error {
+ // SingleFlight 防止缓存击穿 (Cache breakdown)
+ v, err, _ := CacheFindGroup.Do(name, func() (interface{}, error) {
+ err := FindCache(name, dest)
+
+ // cache hit 直接返回结果
+ if err == nil {
+ return dest, nil
+ }
+
+ // cache miss 查数据库
+ result := queryDBResult()
+ if err := StoreCache(name, result); err != nil {
+ return nil, err
+ }
+ return result, nil
+ })
+
+ if err != nil {
+ return err
+ }
+
+ if v != nil {
+ reflect.ValueOf(dest).Elem().Set(reflect.ValueOf(v).Elem()) // similar to `*dest = &v`
+ }
+
+ return nil
+}
+
+func FindCache(name string, dest interface{}) error {
+ if !config.Instance.Cache.Enabled {
+ return errors.New("cache disabled")
+ }
+
+ // `Get()` is Thread Safe, so no need to add Mutex
+ // @see https://github.com/go-redis/redis/issues/23
+ _, err := CACHE.Get(Ctx, name, dest)
+ if err != nil {
+ return err
+ }
+
+ logrus.Debug("[Cache Hit] " + name)
+
+ return nil
+}
+
+func StoreCache(name string, source interface{}) error {
+ if !config.Instance.Cache.Enabled {
+ return nil
+ }
+
+ // `Set()` is Thread Safe too, no need to add Mutex either
+ err := CACHE.Set(Ctx, name, source,
+ store.WithExpiration(time.Duration(config.Instance.Cache.GetExpiresTime())),
+ )
+ if err != nil {
+ return err
+ }
+
+ logrus.Debug("[写入缓存] " + name)
+
+ return nil
+}
+
+func DelCache(name string) error {
+ if !config.Instance.Cache.Enabled {
+ return nil
+ }
+
+ return CACHE.Delete(Ctx, name)
+}
+
+func UserCacheSave(user *entity.User) error {
+ // 缓存 ID
+ err := StoreCache(fmt.Sprintf("user#id=%d", user.ID), user)
+ if err != nil {
+ return err
+ }
+
+ // 缓存 Name x Email
+ err = StoreCache(fmt.Sprintf("user#name=%s;email=%s", strings.ToLower(user.Name), strings.ToLower(user.Email)), user)
+ if err != nil {
+ return err
+ }
+
+ return err
+}
+
+func UserCacheDel(user *entity.User) {
+ DelCache(fmt.Sprintf("user#id=%d", user.ID))
+ DelCache(fmt.Sprintf("user#name=%s;email=%s", strings.ToLower(user.Name), strings.ToLower(user.Email)))
+}
+
+func SiteCacheSave(site *entity.Site) error {
+ // 缓存 ID
+ err := StoreCache(fmt.Sprintf("site#id=%d", site.ID), site)
+ if err != nil {
+ return err
+ }
+
+ // 缓存 Name
+ err = StoreCache(fmt.Sprintf("site#name=%s", site.Name), site)
+ if err != nil {
+ return err
+ }
+
+ return err
+}
+
+func SiteCacheDel(site *entity.Site) {
+ DelCache(fmt.Sprintf("site#id=%d", site.ID))
+ DelCache(fmt.Sprintf("site#name=%s", site.Name))
+}
+
+func PageCacheSave(page *entity.Page) error {
+ // 缓存 ID
+ err := StoreCache(fmt.Sprintf("page#id=%d", page.ID), page)
+ if err != nil {
+ return err
+ }
+
+ // 缓存 Key x SiteName
+ err = StoreCache(fmt.Sprintf("page#key=%s;site_name=%s", page.Key, page.SiteName), page)
+ if err != nil {
+ return err
+ }
+
+ return err
+}
+
+func PageCacheDel(page *entity.Page) {
+ DelCache(fmt.Sprintf("page#id=%d", page.ID))
+ DelCache(fmt.Sprintf("page#key=%s;site_name=%s", page.Key, page.SiteName))
+}
+
+func CommentCacheSave(comment *entity.Comment) error {
+ // 缓存 ID
+ err := StoreCache(fmt.Sprintf("comment#id=%d", comment.ID), comment)
+ if err != nil {
+ return err
+ }
+
+ // 缓存 Rid
+ if comment.Rid != 0 {
+ ChildCommentCacheSave(comment.Rid, comment.ID)
+ }
+
+ return err
+}
+
+func CommentCacheDel(comment *entity.Comment) {
+ DelCache(fmt.Sprintf("comment#id=%d", comment.ID))
+
+ // 清除 Rid 缓存
+ ChildCommentCacheDel(comment.ID)
+ if comment.Rid != 0 {
+ ChildCommentCacheDel(comment.Rid)
+ }
+}
+
+// 缓存 父ID=>子ID 评论数据
+func ChildCommentCacheSave(parentID uint, childID uint) {
+ var childIDs []uint
+ var cacheName = fmt.Sprintf("parent-comments#pid=%d", parentID)
+
+ err := FindCache(cacheName, &childIDs)
+ if err != nil { // 初始化
+ childIDs = []uint{}
+ }
+
+ isExist := false
+ for _, i := range childIDs {
+ if i == childID {
+ isExist = true
+ break
+ }
+ }
+
+ // append if Not exist
+ if !isExist {
+ childIDs = append(childIDs, childID)
+ }
+
+ StoreCache(cacheName, childIDs)
+}
+
+func ChildCommentCacheDel(parentID uint) {
+ DelCache(fmt.Sprintf("parent-comments#pid=%d", parentID))
+}
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
new file mode 100644
index 000000000..99d0530ee
--- /dev/null
+++ b/internal/cache/cache.go
@@ -0,0 +1,83 @@
+package cache
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/allegro/bigcache"
+ "github.com/bradfitz/gomemcache/memcache"
+ "github.com/eko/gocache/lib/v4/cache"
+ "github.com/eko/gocache/lib/v4/marshaler"
+ "github.com/eko/gocache/lib/v4/store"
+ bigcache_store "github.com/eko/gocache/store/bigcache/v4"
+ memcache_store "github.com/eko/gocache/store/memcache/v4"
+ redis_store "github.com/eko/gocache/store/redis/v4"
+ "github.com/go-redis/redis/v8"
+ "github.com/sirupsen/logrus"
+)
+
+var (
+ CACHE *marshaler.Marshaler
+)
+
+var Ctx = context.Background()
+
+func OpenCache() (err error) {
+ cacheType := config.Instance.Cache.Type
+
+ var cacheStore store.StoreInterface
+
+ switch cacheType {
+
+ case config.CacheTypeBuiltin:
+ // 内建缓存
+ bigcacheClient, err := bigcache.NewBigCache(bigcache.DefaultConfig(
+ // Tip: 内建缓存过期时间是一样的,只有 Redis/Memcache 才能设置单个 item 的
+ time.Duration(config.Instance.Cache.GetExpiresTime()),
+ ))
+ if err != nil {
+ return err
+ }
+ cacheStore = bigcache_store.NewBigcache(bigcacheClient) // No options provided (as second argument)
+
+ case config.CacheTypeRedis:
+ // Redis
+ network := "tcp"
+ if config.Instance.Cache.Redis.Network != "" {
+ network = config.Instance.Cache.Redis.Network
+ }
+
+ cacheStore = redis_store.NewRedis(redis.NewClient(&redis.Options{
+ Network: network,
+ Addr: config.Instance.Cache.Server,
+ Username: config.Instance.Cache.Redis.Username,
+ Password: config.Instance.Cache.Redis.Password,
+ DB: config.Instance.Cache.Redis.DB,
+ }))
+
+ case config.CacheTypeMemcache:
+ // Memcache
+ servers := strings.Split(config.Instance.Cache.Server, ",")
+ cacheStore = memcache_store.NewMemcache(
+ memcache.New(servers...),
+ store.WithExpiration(time.Duration(config.Instance.Cache.GetExpiresTime())),
+ )
+
+ default:
+ logrus.Fatal("Invalid cache type `" + cacheType + "`, please check config option `cache.type`")
+
+ }
+
+ cacheInstance := cache.New[any](cacheStore)
+
+ // marshaler wrapper
+ // marshaler using VmihailencoMsgpack
+ // @link https://github.com/vmihailenco/msgpack
+ // Benchmarks
+ // @link https://github.com/alecthomas/go_serialization_benchmarks
+ CACHE = marshaler.New(cacheInstance)
+
+ return
+}
diff --git a/internal/cache/db.go b/internal/cache/db.go
new file mode 100644
index 000000000..3374ad4d2
--- /dev/null
+++ b/internal/cache/db.go
@@ -0,0 +1,10 @@
+package cache
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "gorm.io/gorm"
+)
+
+func DB() *gorm.DB {
+ return db.DB()
+}
diff --git a/internal/cache/operate.go b/internal/cache/operate.go
new file mode 100644
index 000000000..3e8f13eef
--- /dev/null
+++ b/internal/cache/operate.go
@@ -0,0 +1,111 @@
+package cache
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/sirupsen/logrus"
+)
+
+// 缓存预热
+func CacheWarmUp() {
+ // Users
+ {
+ start := time.Now()
+
+ var items []entity.User
+ DB().Find(&items)
+
+ for _, item := range items {
+ UserCacheSave(&item)
+ }
+
+ logrus.Debug(fmt.Sprintf("[Users] 缓存完毕 (共 %d 个,耗时:%s)", len(items), time.Since(start)))
+ }
+
+ // Sites
+ {
+ start := time.Now()
+
+ var items []entity.Site
+ DB().Find(&items)
+
+ for _, item := range items {
+ SiteCacheSave(&item)
+ }
+
+ logrus.Debug(fmt.Sprintf("[Sites] 缓存完毕 (共 %d 个,耗时:%s)", len(items), time.Since(start)))
+ }
+
+ // Pages
+ {
+ start := time.Now()
+
+ var items []entity.Page
+ DB().Find(&items)
+
+ for _, item := range items {
+ PageCacheSave(&item)
+ }
+
+ logrus.Debug(fmt.Sprintf("[Pages] 缓存完毕 (共 %d 个,耗时:%s)", len(items), time.Since(start)))
+ }
+
+ // Comments
+ {
+ start := time.Now()
+
+ var items []entity.Comment
+ DB().Find(&items)
+
+ for _, item := range items {
+ CommentCacheSave(&item)
+ }
+
+ logrus.Debug(fmt.Sprintf("[Comments] 缓存完毕 (共 %d 个,耗时:%s)", len(items), time.Since(start)))
+ }
+}
+
+// 清空缓存
+func CacheFlushAll() {
+ // Users
+ {
+ var items []entity.User
+ DB().Find(&items)
+
+ for _, item := range items {
+ UserCacheDel(&item)
+ }
+ }
+
+ // Sites
+ {
+ var items []entity.Site
+ DB().Find(&items)
+
+ for _, item := range items {
+ SiteCacheDel(&item)
+ }
+ }
+
+ // Pages
+ {
+ var items []entity.Page
+ DB().Find(&items)
+
+ for _, item := range items {
+ PageCacheDel(&item)
+ }
+ }
+
+ // Comments
+ {
+ var items []entity.Comment
+ DB().Find(&items)
+
+ for _, item := range items {
+ CommentCacheDel(&item)
+ }
+ }
+}
diff --git a/internal/captcha/captcha.go b/internal/captcha/captcha.go
new file mode 100644
index 000000000..c3f11d303
--- /dev/null
+++ b/internal/captcha/captcha.go
@@ -0,0 +1,13 @@
+package captcha
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed pages/*
+var pages embed.FS
+
+func GetPage(name string) (fs.File, error) {
+ return pages.Open("pages/" + name)
+}
diff --git a/internal/captcha/geetest.go b/internal/captcha/geetest.go
new file mode 100644
index 000000000..6e8184d95
--- /dev/null
+++ b/internal/captcha/geetest.go
@@ -0,0 +1,86 @@
+package captcha
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/tidwall/gjson"
+)
+
+// geetest 服务地址
+const GEETEST_API_SERVER string = "http://gcaptcha4.geetest.com"
+
+// hmac-sha256 加密: CAPTCHA_KEY,lot_number
+func hmac_encode(key string, data string) string {
+ mac := hmac.New(sha256.New, []byte(key))
+ mac.Write([]byte(data))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+type GeetestParams struct {
+ CaptchaID string `json:"captcha_id"`
+ LotNumber string `json:"lot_number"`
+ PassToken string `json:"pass_token"`
+ GenTime string `json:"gen_time"`
+ CaptchaOutput string `json:"captcha_output"`
+}
+
+func GeetestCheck(paramsJSON string) (isPass bool, reason string, err error) {
+ geetestConf := config.Instance.Captcha.Geetest
+
+ var p GeetestParams
+ err = json.Unmarshal([]byte(paramsJSON), &p)
+ if err != nil {
+ return false, "", errors.New("request params json parse err: " + err.Error())
+ }
+
+ // 生成签名
+ signToken := hmac_encode(geetestConf.CaptchaKey, p.LotNumber)
+
+ // 向极验转发前端数据 + sign_token 签名
+ form_data := make(url.Values)
+ form_data["lot_number"] = []string{p.LotNumber}
+ form_data["captcha_output"] = []string{p.CaptchaOutput}
+ form_data["pass_token"] = []string{p.PassToken}
+ form_data["gen_time"] = []string{p.GenTime}
+ form_data["sign_token"] = []string{signToken}
+
+ // 发起 POST 请求
+ url := GEETEST_API_SERVER + "/validate" + "?captcha_id=" + geetestConf.CaptchaID
+ cli := http.Client{Timeout: time.Second * 5} // 5s 超时
+ resp, err := cli.PostForm(url, form_data)
+ if err != nil || resp.StatusCode != 200 {
+ return false, "", errors.New("service interface exception: " + err.Error())
+ }
+
+ // 处理响应结果
+ resJsonBuf, _ := ioutil.ReadAll(resp.Body)
+ resJson := string(resJsonBuf)
+
+ gResult := gjson.Get(resJson, "result")
+ if !gResult.Exists() {
+ return false, "", errors.New("response results are not as expected: " + resJson)
+ }
+ result := gResult.String()
+
+ if result == "success" {
+ // 验证成功
+ return true, "", nil
+ } else {
+ // 验证失败
+ gReason := gjson.Get(resJson, "reason")
+ if gReason.Exists() {
+ reason = gReason.String()
+ }
+
+ return false, reason, nil
+ }
+}
diff --git a/internal/captcha/pages/geetest.html b/internal/captcha/pages/geetest.html
new file mode 100644
index 000000000..257e696b2
--- /dev/null
+++ b/internal/captcha/pages/geetest.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+ Geetest
+
+
+
+
+
+
+
+
+
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 000000000..2a37016b1
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,286 @@
+package config
+
+import "time"
+
+// Config 配置
+// @link https://godoc.org/github.com/mitchellh/mapstructure
+type Config struct {
+ AppKey string `koanf:"app_key" json:"app_key"` // 加密密钥
+ Debug bool `koanf:"debug" json:"debug"` // 调试模式
+ Locale string `koanf:"locale" json:"locale"` // 语言
+ TimeZone string `koanf:"timezone" json:"timezone"` // 时区
+ Host string `koanf:"host" json:"host"` // HTTP Server 监听 IP
+ Port int `koanf:"port" json:"port"` // HTTP Server 监听 Port
+ DB DBConf `koanf:"db" json:"db"` // 数据文件
+ Cache CacheConf `koanf:"cache" json:"cache"` // 缓存
+ Log LogConf `koanf:"log" json:"log"` // 日志文件
+ AllowOrigins []string `koanf:"allow_origins" json:"-"` // @deprecated 已废弃 (请使用 TrustedDomains)
+ TrustedDomains []string `koanf:"trusted_domains" json:"trusted_domains"` // 可信任的域名 (新)
+ SSL SSLConf `koanf:"ssl" json:"ssl"` // SSL
+ SiteDefault string `koanf:"site_default" json:"site_default"` // 默认站点名(当请求无指定 site_name 时使用)
+ AdminUsers []AdminUserConf `koanf:"admin_users" json:"admin_users"` // 管理员账户
+ LoginTimeout int `koanf:"login_timeout" json:"login_timeout"` // 登陆超时
+ Cookie CookieConf `koanf:"cookie" json:"cookie"` // Cookie
+ Moderator ModeratorConf `koanf:"moderator" json:"moderator"` // 评论审查
+ Captcha CaptchaConf `koanf:"captcha" json:"captcha"` // 验证码
+ Email EmailConf `koanf:"email" json:"email"` // 邮箱提醒
+ ImgUpload ImgUploadConf `koanf:"img_upload" json:"img_upload"` // 图片上传
+ AdminNotify AdminNotifyConf `koanf:"admin_notify" json:"admin_notify"` // 其他通知方式
+ Notify *AdminNotifyConf `koanf:"notify" json:"-"` // @deprecated 已废弃 (请使用 AdminNotify)
+ Frontend map[string]interface{} `koanf:"frontend" json:"frontend"`
+}
+
+type DBConf struct {
+ Type DBType `koanf:"type" json:"type"`
+ Dsn string `koanf:"dsn" json:"dsn"` // 最高优先级
+
+ File string `koanf:"file" json:"file"`
+ Name string `koanf:"name" json:"name"`
+
+ Host string `koanf:"host" json:"host"`
+ Port int `koanf:"port" json:"port"`
+ User string `koanf:"user" json:"user"`
+ Password string `koanf:"password" json:"password"`
+
+ TablePrefix string `koanf:"table_prefix" json:"table_prefix"`
+ Charset string `koanf:"charset" json:"charset"`
+}
+
+type CacheConf struct {
+ Enabled bool // 配置文件不允许修改
+ Type CacheType `koanf:"type" json:"type"`
+ Expires int `koanf:"expires" json:"expires"` // 过期时间
+ WarmUp bool `koanf:"warm_up" json:"warm_up"` // 启动时缓存预热
+ Server string `koanf:"server" json:"server"` // 缓存服务器
+ Redis RedisConf `koanf:"redis" json:"redis"`
+}
+
+func (c *CacheConf) GetExpiresTime() int64 {
+ if c.Expires == 0 {
+ return int64(30 * time.Minute) // 默认 30min
+ }
+
+ if c.Expires == -1 {
+ return -1 // Redis.KeepTTL = -1
+ }
+
+ return int64(time.Duration(c.Expires) * time.Minute)
+}
+
+type LogConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ Filename string `koanf:"filename" json:"filename"`
+}
+
+type SSLConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ CertPath string `koanf:"cert_path" json:"cert_path"`
+ KeyPath string `koanf:"key_path" json:"key_path"`
+}
+
+type AdminUserConf struct {
+ Name string `koanf:"name" json:"name"`
+ Email string `koanf:"email" json:"email"`
+ Link string `koanf:"link" json:"link"`
+ Password string `koanf:"password" json:"password"`
+ BadgeName string `koanf:"badge_name" json:"badge_name"`
+ BadgeColor string `koanf:"badge_color" json:"badge_color"`
+ ReceiveEmail *bool `koanf:"receive_email" json:"receive_email"`
+ Sites []string `koanf:"sites" json:"sites"`
+}
+
+type CookieConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+}
+
+type ModeratorConf struct {
+ PendingDefault bool `koanf:"pending_default" json:"pending_default"`
+ ApiFailBlock bool `koanf:"api_fail_block" json:"api_fail_block"` // API 请求错误仍然拦截
+ AkismetKey string `koanf:"akismet_key" json:"akismet_key"`
+ Tencent TencentAntispamConf `koanf:"tencent" json:"tencent"`
+ Aliyun AliyunAntispamConf `koanf:"aliyun" json:"aliyun"`
+ Keywords KeyWordsAntispamConf `koanf:"keywords" json:"keywords"`
+}
+
+// 腾讯云反垃圾
+type TencentAntispamConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ SecretID string `koanf:"secret_id" json:"secret_id"`
+ SecretKey string `koanf:"secret_key" json:"secret_key"`
+ Region string `koanf:"region" json:"region"`
+}
+
+// 阿里云反垃圾
+type AliyunAntispamConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ AccessKeyID string `koanf:"access_key_id" json:"access_key_id"`
+ AccessKeySecret string `koanf:"access_key_secret" json:"access_key_secret"`
+ Region string `koanf:"region" json:"region"`
+}
+
+// 关键词词库过滤
+type KeyWordsAntispamConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ Pending bool `koanf:"pending" json:"pending"`
+ Files []string `koanf:"files" json:"files"`
+ FileSep string `koanf:"file_sep" json:"file_sep"`
+ ReplacTo string `koanf:"replac_to" json:"replac_to"`
+}
+
+type CaptchaConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ Always bool `koanf:"always" json:"always"`
+ ActionTimeout int `koanf:"action_timeout" json:"-"` // @deprecated 已废弃 (请使用 ActionReset)
+ ActionReset int `koanf:"action_reset" json:"action_reset"`
+ ActionLimit int `koanf:"action_limit" json:"action_limit"`
+ Geetest GeetestConf `koanf:"geetest" json:"geetest"`
+}
+
+type GeetestConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ CaptchaID string `koanf:"captcha_id" json:"captcha_id"`
+ CaptchaKey string `koanf:"captcha_key" json:"captcha_key"`
+}
+
+type EmailConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"` // 总开关
+ SendType EmailSenderType `koanf:"send_type" json:"send_type"` // 发送方式
+ SendName string `koanf:"send_name" json:"send_name"` // 发件人名
+ SendAddr string `koanf:"send_addr" json:"send_addr"` // 发件人地址
+ MailSubject string `koanf:"mail_subject" json:"mail_subject"` // 邮件标题
+ MailSubjectToAdmin string `koanf:"mail_subject_to_admin" json:"-"` // @deprecated 已废弃 (请使用 AdminNotify.Email.MailSubject) - 邮件标题 (发送给管理员用)
+ MailTpl string `koanf:"mail_tpl" json:"mail_tpl"` // 邮件模板
+ SMTP SMTPConf `koanf:"smtp" json:"smtp"` // SMTP 配置
+ AliDM AliDMConf `koanf:"ali_dm" json:"ali_dm"` // 阿里云邮件配置
+}
+
+type SMTPConf struct {
+ Host string `koanf:"host" json:"host"`
+ Port int `koanf:"port" json:"port"`
+ Username string `koanf:"username" json:"username"`
+ Password string `koanf:"password" json:"password"`
+ From string `koanf:"from" json:"from"`
+}
+
+type AliDMConf struct {
+ AccessKeyId string `koanf:"access_key_id" json:"access_key_id"`
+ AccessKeySecret string `koanf:"access_key_secret" json:"access_key_secret"`
+ AccountName string `koanf:"account_name" json:"account_name"`
+ Region string `koanf:"region" json:"region"`
+}
+
+type DBType string
+
+const (
+ TypeMySql DBType = "mysql"
+ TypeSQLite DBType = "sqlite"
+ TypePostgreSQL DBType = "pgsql"
+ TypeMSSQL DBType = "mssql"
+)
+
+type CacheType string
+
+const (
+ CacheTypeBuiltin CacheType = "builtin" // 内建缓存
+ CacheTypeRedis CacheType = "redis"
+ CacheTypeMemcache CacheType = "memcache"
+ CacheTypeDisabled CacheType = "disabled" // 关闭缓存
+)
+
+type EmailSenderType string
+
+const (
+ TypeSMTP EmailSenderType = "smtp"
+ TypeAliDM EmailSenderType = "ali_dm"
+ TypeSendmail EmailSenderType = "sendmail"
+)
+
+// # Redis 配置
+// redis:
+//
+// network: "tcp"
+// username: ""
+// password: ""
+// db: 0
+type RedisConf struct {
+ Network string `koanf:"network" json:"network"` // tcp or unix
+ Username string `koanf:"username" json:"username"`
+ Password string `koanf:"password" json:"password"`
+ DB int `koanf:"db" json:"db"` // Redis 默认数据库 0
+}
+
+type ImgUploadConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"` // 总开关
+ Path string `koanf:"path" json:"path"` // 图片存放路径
+ MaxSize int64 `koanf:"max_size" json:"max_size"` // 图片大小限制
+ Quality string `koanf:"quality" json:"quality"` // 图片质量
+ PublicPath string `koanf:"public_path" json:"public_path"` // 图片 URL 基础路径
+ Upgit UpgitConf `koanf:"upgit" json:"upgit"` // upgit
+}
+
+type UpgitConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"` // 启用 upgit
+ Exec string `koanf:"exec" json:"exec"` // 启动命令
+ DelLocal bool `koanf:"del_local" json:"del_local"` // 上传后删除本地的图片
+}
+
+// 其他通知方式
+type AdminNotifyConf struct {
+ NotifyTpl string `koanf:"notify_tpl" json:"notify_tpl"` // 通知模板
+ NotifySubject string `koanf:"notify_subject" json:"notify_subject"` // 通知标题
+ Email *AdminEmailConf `koanf:"email" json:"email"` // 邮件通知
+ Telegram NotifyTelegramConf `koanf:"telegram" json:"telegram"` // TG
+ Lark NotifyLarkConf `koanf:"lark" json:"lark"` // 飞书
+ DingTalk NotifyDingTalkConf `koanf:"ding_talk" json:"ding_talk"` // 钉钉
+ Bark NotifyBarkConf `koanf:"bark" json:"bark"` // bark
+ Slack NotifySlackConf `koanf:"slack" json:"slack"` // slack
+ LINE NotifyLINEConf `koanf:"line" json:"line"` // LINE
+ WebHook NotifyWebHookConf `koanf:"webhook" json:"webhook"` // WebHook
+ NoiseMode bool `koanf:"noise_mode" json:"noise_mode"` // 嘈杂模式 (非回复管理员的评论也发送通知)
+}
+
+type AdminEmailConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"` // 管理员总开关
+ MailSubject string `koanf:"mail_subject" json:"mail_subject"` // 管理员邮件标题
+ MailTpl string `koanf:"mail_tpl" json:"mail_tpl"` // 管理员专用邮件模板
+}
+
+type NotifyTelegramConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ ApiToken string `koanf:"api_token" json:"api_token"`
+ Receivers []int64 `koanf:"receivers" json:"receivers"`
+}
+
+type NotifyDingTalkConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ Token string `koanf:"token" json:"token"`
+ Secret string `koanf:"secret" json:"secret"`
+}
+
+type NotifyLarkConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ WebhookURL string `koanf:"webhook_url" json:"webhook_url"`
+}
+
+type NotifyBarkConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ Server string `koanf:"server" json:"server"`
+}
+
+type NotifySlackConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ OauthToken string `koanf:"oauth_token" json:"oauth_token"`
+ Receivers []string `koanf:"receivers" json:"receivers"`
+}
+
+type NotifyLINEConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ ChannelSecret string `koanf:"channel_secret" json:"channel_secret"`
+ ChannelAccessToken string `koanf:"channel_access_token" json:"channel_access_token"`
+ Receivers []string `koanf:"receivers" json:"receivers"`
+}
+
+type NotifyWebHookConf struct {
+ Enabled bool `koanf:"enabled" json:"enabled"`
+ URL string `koanf:"url" json:"url"`
+}
diff --git a/internal/config/const.go b/internal/config/const.go
new file mode 100644
index 000000000..0b55bbd3f
--- /dev/null
+++ b/internal/config/const.go
@@ -0,0 +1,17 @@
+package config
+
+// Artalk 保留关键字
+
+// 所有站点
+const ATK_SITE_ALL = "__ATK_SITE_ALL"
+
+// Cookie 键
+const COOKIE_KEY_ATK_AUTH = "ATK_AUTH"
+
+// ctx keys
+const CTX_KEY_ATK_SITE_ID = "atk_site_id"
+const CTX_KEY_ATK_SITE_NAME = "atk_site_name"
+const CTX_KEY_ATK_SITE_ALL = "atk_site_all"
+
+// 图片上传目录路由重写路径
+var IMG_UPLOAD_PUBLIC_PATH = "/static/images"
diff --git a/internal/config/init.go b/internal/config/init.go
new file mode 100644
index 000000000..a348ad706
--- /dev/null
+++ b/internal/config/init.go
@@ -0,0 +1,126 @@
+package config
+
+import (
+ "strings"
+ "time"
+
+ "github.com/knadh/koanf"
+ "github.com/knadh/koanf/parsers/yaml"
+ "github.com/knadh/koanf/providers/file"
+ "github.com/sirupsen/logrus"
+)
+
+// 默认配置文件名
+const DEFAULT_CONF_FILE = "artalk.yml"
+
+var (
+ kf = koanf.New(".")
+ parser = yaml.Parser()
+
+ // 配置实例
+ Instance *Config
+ cfgFileLoaded string
+)
+
+func GetCfgFileLoaded() string {
+ return cfgFileLoaded
+}
+
+// Init 初始化配置
+func Init(cfgFile string) {
+ // load yaml config
+ if err := kf.Load(file.Provider(cfgFile), parser); err != nil {
+ logrus.Errorln(err)
+ logrus.Fatal("Config file read error")
+ }
+
+ Instance = &Config{}
+
+ if err := kf.Unmarshal("", Instance); err != nil {
+ logrus.Errorln(err)
+ logrus.Fatal("Config file parse error")
+ }
+
+ cfgFileLoaded = cfgFile
+
+ // 后续处理
+ postInit()
+}
+
+func postInit() {
+ // 检查 app_key 是否设置
+ if strings.TrimSpace(Instance.AppKey) == "" {
+ logrus.Fatal("Please check config file and set an `app_key` for data encryption")
+ }
+
+ // 设置时区
+ if strings.TrimSpace(Instance.TimeZone) == "" {
+ logrus.Fatal("Please check config file and set `timezone`")
+ }
+ denverLoc, _ := time.LoadLocation(Instance.TimeZone)
+ time.Local = denverLoc
+
+ // 默认站点配置
+ Instance.SiteDefault = strings.TrimSpace(Instance.SiteDefault)
+ if Instance.SiteDefault == "" {
+ logrus.Fatal("Please check config file and set `site_default`")
+ }
+
+ // 缓存配置
+ if Instance.Cache.Type == "" {
+ // 默认使用内建缓存
+ Instance.Cache.Type = CacheTypeBuiltin
+ }
+ if Instance.Cache.Type != CacheTypeDisabled {
+ // 非缓存禁用模式,Enabled = true
+ Instance.Cache.Enabled = true
+ }
+
+ // 配置文件 alias 处理
+ if Instance.Captcha.ActionLimit == 0 {
+ Instance.Captcha.Always = true
+ }
+
+ /* 检查废弃需更新配置 */
+ if Instance.Captcha.ActionTimeout != 0 {
+ logrus.Warn("The config option `captcha.action_timeout` is deprecated, please use `captcha.action_reset` instead")
+ if Instance.Captcha.ActionReset == 0 {
+ Instance.Captcha.ActionReset = Instance.Captcha.ActionTimeout
+ }
+ }
+ if len(Instance.AllowOrigins) != 0 {
+ logrus.Warn("The config option `allow_origins` is deprecated, please use `trusted_domains` instead")
+ if len(Instance.TrustedDomains) == 0 {
+ Instance.TrustedDomains = Instance.AllowOrigins
+ }
+ }
+
+ // @version < 2.2.0
+ if Instance.Notify != nil {
+ logrus.Warn("The config option `notify` is deprecated, please use `admin_notify` instead")
+ Instance.AdminNotify = *Instance.Notify
+ }
+ if Instance.AdminNotify.Email == nil {
+ Instance.AdminNotify.Email = &AdminEmailConf{
+ Enabled: true, // 默认开启管理员邮件通知
+ }
+ }
+ if Instance.Email.MailSubjectToAdmin != "" {
+ logrus.Warn("The config option `email.mail_subject_to_admin` is deprecated, please use `admin_notify.email.mail_subject` instead")
+ Instance.AdminNotify.Email.MailSubject = Instance.Email.MailSubjectToAdmin
+ }
+
+ // 管理员邮件通知配置继承
+ if Instance.AdminNotify.Email.MailSubject == "" {
+ if Instance.AdminNotify.NotifySubject != "" {
+ Instance.AdminNotify.Email.MailSubject = Instance.AdminNotify.NotifySubject
+ } else if Instance.Email.MailSubject != "" {
+ Instance.AdminNotify.Email.MailSubject = Instance.Email.MailSubject
+ }
+ }
+
+ // 默认待审模式下开启管理员通知嘈杂模式,保证管理员能看到待审核文章
+ if Instance.Moderator.PendingDefault {
+ Instance.AdminNotify.NoiseMode = true
+ }
+}
diff --git a/internal/config/var.go b/internal/config/var.go
new file mode 100644
index 000000000..8d87bfe45
--- /dev/null
+++ b/internal/config/var.go
@@ -0,0 +1,10 @@
+package config
+
+// 版本信息
+var (
+ Version string
+ CommitHash string
+)
+
+// 前端最小要求版本号
+var FeMinVersion string = "2.4.3"
diff --git a/internal/core/core.go b/internal/core/core.go
new file mode 100644
index 000000000..0a021a5d3
--- /dev/null
+++ b/internal/core/core.go
@@ -0,0 +1,150 @@
+package core
+
+import (
+ "io/ioutil"
+ "os"
+ "sync"
+
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/notify_launcher"
+
+ "github.com/rifflock/lfshook"
+ "github.com/sirupsen/logrus"
+ prefixed "github.com/x-cray/logrus-prefixed-formatter"
+)
+
+var firstLoad = true
+var mutex = sync.Mutex{}
+
+// 装载核心功能
+func LoadCore(cfgFile string, workDir string) {
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ firstLoad = false
+
+ initConfig(cfgFile, workDir)
+ initI18n()
+ initLog()
+ initCache()
+ initDB()
+ notify_launcher.Init() // 初始化 Notify 发射台
+
+ // 首次 Load
+ if firstLoad {
+ // 缓存预热
+ if config.Instance.Cache.Enabled && config.Instance.Cache.WarmUp {
+ cache.CacheWarmUp()
+ }
+ // 异步加载
+ // go func() {
+ // entity.CacheWarmUp()
+ // }()
+ }
+}
+
+// 仅装载配置
+func LoadConfOnly(cfgFile string, workDir string) {
+ initConfig(cfgFile, workDir)
+}
+
+// 1. 初始化配置
+func initConfig(cfgFile string, workDir string) {
+ // 切换工作目录
+ if workDir != "" {
+ if err := os.Chdir(workDir); err != nil {
+ logrus.Fatal("Working directory change error: ", err)
+ }
+ }
+
+ if cfgFile == "" {
+ cfgFile = config.DEFAULT_CONF_FILE
+
+ // 默认配置文件名 "artalk-go.yml"(for 向下兼容)
+ if _, err := os.Stat("artalk-go.yml"); err == nil {
+ cfgFile = "artalk-go.yml"
+ }
+ }
+
+ // 自动生成新配置文件
+ if !CheckFileExist(cfgFile) {
+ Gen("config", cfgFile, false)
+ }
+
+ config.Init(cfgFile)
+}
+
+func initI18n() {
+ i18n.Init(config.Instance.Locale)
+}
+
+// 2. 初始化日志
+func initLog() {
+ logrus.New()
+ if !config.Instance.Log.Enabled {
+ logrus.SetOutput(ioutil.Discard)
+ return
+ }
+
+ // 命令行输出格式
+ stdFormatter := &prefixed.TextFormatter{
+ DisableTimestamp: true,
+ ForceFormatting: true,
+ ForceColors: true,
+ DisableColors: false,
+ }
+
+ logrus.SetFormatter(stdFormatter)
+ logrus.SetOutput(os.Stdout)
+
+ if config.Instance.Debug {
+ logrus.SetLevel(logrus.DebugLevel)
+ } else {
+ logrus.SetLevel(logrus.InfoLevel)
+ }
+
+ // 日志输出到文件
+ if config.Instance.Log.Filename != "" {
+ fileFormatter := &prefixed.TextFormatter{
+ FullTimestamp: true,
+ TimestampFormat: "2006-01-02.15:04:05.000000",
+ ForceFormatting: true,
+ ForceColors: false,
+ DisableColors: true,
+ }
+
+ pathMap := lfshook.PathMap{
+ logrus.InfoLevel: config.Instance.Log.Filename,
+ logrus.DebugLevel: config.Instance.Log.Filename,
+ logrus.ErrorLevel: config.Instance.Log.Filename,
+ }
+
+ newHooks := make(logrus.LevelHooks)
+ newHooks.Add(lfshook.NewHook(
+ pathMap,
+ fileFormatter,
+ ))
+
+ //logrus.AddHook(lfshook.NewHook()) // 使用 Replace 而不使用 Add
+ logrus.StandardLogger().ReplaceHooks(newHooks)
+ }
+}
+
+// 3. 初始化缓存
+func initCache() {
+ err := cache.OpenCache()
+ if err != nil {
+ logrus.Error("[Cache] ", "Init cache error: ", err)
+ os.Exit(1)
+ }
+}
+
+// 4. 初始化数据库
+func initDB() {
+ db.InitDB()
+ db.MigrateModels()
+ SyncFromConf()
+}
diff --git a/internal/core/gen.go b/internal/core/gen.go
new file mode 100644
index 000000000..aff82f793
--- /dev/null
+++ b/internal/core/gen.go
@@ -0,0 +1,83 @@
+package core
+
+import (
+ "fmt"
+ "io/ioutil"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/pkged"
+ "github.com/sirupsen/logrus"
+)
+
+func Gen(genType string, specificPath string, overwrite bool) {
+ // 参数
+ if genType == "config" || genType == "conf" || genType == "artalk.yml" {
+ genType = "artalk.example.yml"
+ }
+
+ genPath := filepath.Base(genType)
+ if specificPath != "" {
+ genPath = specificPath
+ }
+
+ file, err := pkged.FS().Open(strings.TrimPrefix(genType, "/"))
+ if err != nil {
+ logrus.Fatal("Invalid built-in resource `"+genType+"`: ", err)
+ }
+
+ buf, err := ioutil.ReadAll(file)
+ if err != nil {
+ logrus.Fatal("Read built-in resources `"+genType+"` error: ", err)
+ }
+
+ // 自动生成 app_key
+ if strings.Contains(filepath.Base(genType), "artalk.example.yml") {
+ str := string(buf)
+ appKey := RandStringRunes(16)
+ str = strings.Replace(str, `app_key: ""`, fmt.Sprintf(`app_key: "%s"`, appKey), 1)
+ buf = []byte(str)
+ }
+
+ absPath, err := filepath.Abs(genPath)
+ if err != nil {
+ logrus.Fatal(err)
+ }
+ if s, err := os.Stat(absPath); err == nil && s.IsDir() {
+ absPath = filepath.Join(absPath, filepath.Base(genType))
+ }
+
+ if CheckFileExist(absPath) && !overwrite {
+ logrus.Fatal(i18n.T("{{name}} already exists", map[string]interface{}{"name": i18n.T("File")}) + ": " + absPath)
+ }
+
+ dst, err := os.Create(absPath)
+ if err != nil {
+ logrus.Fatal("Failed to create target file: ", err)
+ }
+ defer dst.Close()
+
+ if _, err = dst.Write(buf); err != nil {
+ logrus.Fatal("Failed to write target file: ", err)
+ }
+
+ logrus.Info("File Generated: " + absPath)
+}
+
+var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*")
+
+func RandStringRunes(n int) string {
+ b := make([]rune, n)
+ for i := range b {
+ b[i] = letterRunes[rand.Intn(len(letterRunes))]
+ }
+ return string(b)
+}
+
+func CheckFileExist(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/internal/core/sync.go b/internal/core/sync.go
new file mode 100644
index 000000000..728340f72
--- /dev/null
+++ b/internal/core/sync.go
@@ -0,0 +1,71 @@
+package core
+
+import (
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+)
+
+func SyncFromConf() {
+ // 初始化默认站点
+ query.FindCreateSite(config.Instance.SiteDefault)
+
+ // 导入配置文件的管理员用户
+ for _, admin := range config.Instance.AdminUsers {
+ user := query.FindUser(admin.Name, admin.Email)
+ receiveEmail := true // 默认允许接收邮件
+ if admin.ReceiveEmail != nil {
+ receiveEmail = *admin.ReceiveEmail
+ }
+ if user.IsEmpty() {
+ // create
+ user = entity.User{
+ Name: admin.Name,
+ Email: admin.Email,
+ Link: admin.Link,
+ Password: admin.Password,
+ BadgeName: admin.BadgeName,
+ BadgeColor: admin.BadgeColor,
+ IsAdmin: true,
+ IsInConf: true,
+ ReceiveEmail: receiveEmail,
+ SiteNames: strings.Join(admin.Sites, ","),
+ }
+ query.CreateUser(&user)
+ } else {
+ // update
+ user.Name = admin.Name
+ user.Email = admin.Email
+ user.Link = admin.Link
+ user.Password = admin.Password
+ user.BadgeName = admin.BadgeName
+ user.BadgeColor = admin.BadgeColor
+ user.IsAdmin = true
+ user.IsInConf = true
+ user.ReceiveEmail = receiveEmail
+ user.SiteNames = strings.Join(admin.Sites, ",")
+ query.UpdateUser(&user)
+ }
+ }
+
+ // 清理配置文件中不存在的用户
+ // var dbAdminUsers []User
+ // lib.DB.Model(&User{}).Where(&User{IsInConf: true}).Find(&dbAdminUsers)
+ // for _, dbU := range dbAdminUsers {
+ // isUserExist := func() bool {
+ // for _, confU := range config.Instance.AdminUsers {
+ // // 忽略大小写比较
+ // if strings.EqualFold(confU.Name, dbU.Name) && strings.EqualFold(confU.Email, dbU.Email) {
+ // return true
+ // }
+ // }
+ // return false
+ // }
+
+ // if !isUserExist() {
+ // DelUser(&dbU)
+ // }
+ // }
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 000000000..0f48df859
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,110 @@
+package db
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/sirupsen/logrus"
+ "gorm.io/driver/mysql"
+ "gorm.io/driver/postgres"
+ "gorm.io/driver/sqlite"
+ "gorm.io/driver/sqlserver"
+ "gorm.io/gorm"
+ "gorm.io/gorm/schema"
+)
+
+var dbInstance *gorm.DB
+
+func SetDB(db *gorm.DB) {
+ dbInstance = db
+}
+
+func DB() *gorm.DB {
+ return dbInstance
+}
+
+var gormConfig *gorm.Config
+
+func InitDB() {
+ var err error
+ db, err := OpenDB(config.Instance.DB.Type, config.Instance.DB.Dsn)
+ if err != nil {
+ logrus.Error("[DB] ", "Init database error: ", err)
+ os.Exit(1)
+ }
+ SetDB(db)
+}
+
+func OpenDB(dbType config.DBType, dsn string) (*gorm.DB, error) {
+ dbConf := config.Instance.DB
+
+ gormConfig = &gorm.Config{
+ Logger: NewGormLogger(),
+ NamingStrategy: schema.NamingStrategy{
+ TablePrefix: config.Instance.DB.TablePrefix,
+ },
+ }
+
+ if dsn == "" {
+ switch dbType {
+ case config.TypeSQLite:
+ if dbConf.File == "" {
+ logrus.Fatal("Please set `db.file` option in config file to specify a sqlite database path")
+ }
+ dsn = dbConf.File
+ case config.TypePostgreSQL:
+ dsn = fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable",
+ dbConf.Host,
+ dbConf.User,
+ dbConf.Password,
+ dbConf.Name,
+ dbConf.Port)
+ case config.TypeMySql, config.TypeMSSQL:
+ dsn = fmt.Sprintf("%s:%s@(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
+ dbConf.User,
+ dbConf.Password,
+ dbConf.Host,
+ dbConf.Port,
+ dbConf.Name,
+ dbConf.Charset,
+ )
+ }
+ }
+
+ switch dbType {
+ case config.TypeSQLite:
+ return OpenSQLite(dsn)
+ case config.TypeMySql:
+ return OpenMySql(dsn)
+ case config.TypePostgreSQL:
+ return OpenPostgreSQL(dsn)
+ case config.TypeMSSQL:
+ return OpenSqlServer(dsn)
+ }
+
+ return nil, errors.New(`unsupported database type "` + string(dbType) + `"`)
+}
+
+func OpenSQLite(filename string) (*gorm.DB, error) {
+ if err := utils.EnsureDir(filepath.Dir(filename)); err != nil {
+ return nil, err
+ }
+
+ return gorm.Open(sqlite.Open(filename), gormConfig)
+}
+
+func OpenMySql(dsn string) (*gorm.DB, error) {
+ return gorm.Open(mysql.Open(dsn), gormConfig)
+}
+
+func OpenPostgreSQL(dsn string) (*gorm.DB, error) {
+ return gorm.Open(postgres.Open(dsn), gormConfig)
+}
+
+func OpenSqlServer(dsn string) (*gorm.DB, error) {
+ return gorm.Open(sqlserver.Open(dsn), gormConfig)
+}
diff --git a/internal/db/gorm_logger.go b/internal/db/gorm_logger.go
new file mode 100644
index 000000000..177f921ea
--- /dev/null
+++ b/internal/db/gorm_logger.go
@@ -0,0 +1,61 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "gorm.io/gorm"
+ gormlogger "gorm.io/gorm/logger"
+ "gorm.io/gorm/utils"
+)
+
+type gLogger struct {
+ SlowThreshold time.Duration
+ SourceField string
+ SkipErrRecordNotFound bool
+}
+
+func NewGormLogger() *gLogger {
+ return &gLogger{
+ SkipErrRecordNotFound: true,
+ }
+}
+
+func (l *gLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
+ return l
+}
+
+func (l *gLogger) Info(ctx context.Context, s string, args ...interface{}) {
+ log.WithContext(ctx).Infof(s, args...)
+}
+
+func (l *gLogger) Warn(ctx context.Context, s string, args ...interface{}) {
+ log.WithContext(ctx).Warnf(s, args...)
+}
+
+func (l *gLogger) Error(ctx context.Context, s string, args ...interface{}) {
+ log.WithContext(ctx).Errorf(s, args...)
+}
+
+func (l *gLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
+ elapsed := time.Since(begin)
+ sql, _ := fc()
+ fields := log.Fields{}
+ if l.SourceField != "" {
+ fields[l.SourceField] = utils.FileWithLineNum()
+ }
+ if err != nil && !(errors.Is(err, gorm.ErrRecordNotFound) && l.SkipErrRecordNotFound) {
+ fields[log.ErrorKey] = err
+ log.WithContext(ctx).WithFields(fields).Errorf("%s [%s]", sql, elapsed)
+ return
+ }
+
+ if l.SlowThreshold != 0 && elapsed > l.SlowThreshold {
+ log.WithContext(ctx).WithFields(fields).Warnf("%s [%s]", sql, elapsed)
+ return
+ }
+
+ log.WithContext(ctx).WithFields(fields).Debugf("%s [%s]", sql, elapsed)
+}
diff --git a/internal/db/migrate.go b/internal/db/migrate.go
new file mode 100644
index 000000000..01bb5313e
--- /dev/null
+++ b/internal/db/migrate.go
@@ -0,0 +1,9 @@
+package db
+
+import "github.com/ArtalkJS/Artalk/internal/entity"
+
+func MigrateModels() {
+ // Migrate the schema
+ DB().AutoMigrate(&entity.Site{}, &entity.Page{}, &entity.User{},
+ &entity.Comment{}, &entity.Notify{}, &entity.Vote{}) // 注意表的创建顺序,因为有关联字段
+}
diff --git a/internal/email/email.go b/internal/email/email.go
new file mode 100644
index 000000000..b4eb9d7b9
--- /dev/null
+++ b/internal/email/email.go
@@ -0,0 +1,51 @@
+package email
+
+import (
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/sirupsen/logrus"
+)
+
+func AsyncSendTo(subject string, body string, toAddr string) {
+ if !config.Instance.Email.Enabled {
+ return
+ }
+
+ AddToQueue(Email{
+ FromAddr: config.Instance.Email.SendAddr,
+ FromName: config.Instance.Email.SendName,
+ ToAddr: toAddr,
+ Subject: subject,
+ Body: body,
+ })
+}
+
+func AsyncSend(notify *entity.Notify) {
+ if !config.Instance.Email.Enabled {
+ return
+ }
+
+ receiveUser := query.FetchUserForNotify(notify)
+
+ mailBody := RenderEmailBody(notify, receiveUser.IsAdmin)
+ mailSubject := ""
+ if !receiveUser.IsAdmin {
+ mailSubject = RenderCommon(config.Instance.Email.MailSubject, notify)
+ } else {
+ mailSubject = RenderCommon(config.Instance.AdminNotify.Email.MailSubject, notify)
+ }
+
+ logrus.Debug(time.Now(), " "+receiveUser.Email)
+
+ AddToQueue(Email{
+ FromAddr: config.Instance.Email.SendAddr,
+ FromName: RenderCommon(config.Instance.Email.SendName, notify),
+ ToAddr: receiveUser.Email,
+ Subject: mailSubject,
+ Body: mailBody,
+ LinkedNotify: notify,
+ })
+}
diff --git a/internal/email/email_tpl/default.html b/internal/email/email_tpl/default.html
new file mode 100644
index 000000000..c9ee647c1
--- /dev/null
+++ b/internal/email/email_tpl/default.html
@@ -0,0 +1,12 @@
+
+
Hi, {{nick}}:
+
+ 您在 “{{page_title}}” 收到了回复:
+
+
+
@{{reply_nick}}:
+
{{reply_content}}
+
+
回复 »
+
-- {{site_name}}
Powered By ArtalkGo
+
\ No newline at end of file
diff --git a/internal/email/notify_tpl/default.html b/internal/email/notify_tpl/default.html
new file mode 100644
index 000000000..313302aa0
--- /dev/null
+++ b/internal/email/notify_tpl/default.html
@@ -0,0 +1,5 @@
+@{{reply_nick}}:
+
+{{reply_content}}
+
+{{link_to_reply}}
\ No newline at end of file
diff --git a/internal/email/queue.go b/internal/email/queue.go
new file mode 100644
index 000000000..c5778b3a5
--- /dev/null
+++ b/internal/email/queue.go
@@ -0,0 +1,39 @@
+package email
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/sirupsen/logrus"
+)
+
+// Email Queue
+var emailCh chan Email
+
+func InitQueue() {
+ if emailCh != nil {
+ emailCh = make(chan Email) // TODO: add size limit
+ }
+
+ go func() {
+ for {
+ select {
+ case email := <-emailCh:
+ sender := NewSender(config.Instance.Email.SendType)
+
+ if sender.Send(email) { // 发送成功
+ if email.LinkedNotify != nil {
+ // 标记关联评论邮件发送状态
+ if err := query.NotifySetEmailed(email.LinkedNotify); err != nil {
+ logrus.Errorf("[Email] Flag associated comment email delivery status failed: %s", err)
+ continue
+ }
+ }
+ }
+ }
+ }
+ }()
+}
+
+func AddToQueue(email Email) {
+ emailCh <- email
+}
diff --git a/internal/email/render.go b/internal/email/render.go
new file mode 100644
index 000000000..ce53f0af9
--- /dev/null
+++ b/internal/email/render.go
@@ -0,0 +1,205 @@
+package email
+
+import (
+ "bytes"
+ "embed"
+ "errors"
+ "fmt"
+ "html"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+)
+
+//go:embed email_tpl/*
+//go:embed notify_tpl/*
+var internalTpl embed.FS
+
+func RenderCommon(str string, notify *entity.Notify, _renderType ...string) string {
+ // 渲染类型
+ renderType := "email" // 默认为邮件发送渲染
+ if len(_renderType) > 0 {
+ renderType = _renderType[0]
+ }
+
+ fromComment := query.FetchCommentForNotify(notify)
+ from := query.CookCommentForEmail(&fromComment)
+ toComment := query.FindNotifyParentComment(notify)
+ to := query.CookCommentForEmail(&toComment)
+
+ toUser := query.FetchUserForNotify(notify) // 发送目标用户
+
+ content := to.Content
+ replyContent := from.Content
+ if renderType == "notify" { // 多元推送内容
+ content = HandleEmoticonsImgTagsForNotify(to.ContentRaw)
+ replyContent = HandleEmoticonsImgTagsForNotify(from.ContentRaw)
+ }
+
+ cf := CommonFields{
+ From: from,
+ To: to,
+ Comment: from,
+ ParentComment: to,
+
+ Nick: toUser.Name,
+ Content: content,
+ ReplyNick: from.Nick,
+ ReplyContent: replyContent,
+ PageTitle: from.Page.Title,
+ PageURL: from.Page.URL,
+ SiteName: from.SiteName,
+ SiteURL: from.Site.FirstUrl,
+
+ LinkToReply: query.GetReadLinkByNotify(notify),
+ }
+
+ flat := utils.StructToFlatDotMap(&cf)
+
+ return ReplaceAllMustache(str, flat)
+}
+
+type CommonFields struct {
+ From entity.CookedCommentForEmail `json:"from"`
+ To entity.CookedCommentForEmail `json:"to"`
+ Comment entity.CookedCommentForEmail `json:"comment"`
+ ParentComment entity.CookedCommentForEmail `json:"parent_comment"`
+
+ Nick string `json:"nick"`
+ Content string `json:"content"`
+ ReplyNick string `json:"reply_nick"`
+ ReplyContent string `json:"reply_content"`
+
+ PageTitle string `json:"page_title"`
+ PageURL string `json:"page_url"`
+ SiteName string `json:"site_name"`
+ SiteURL string `json:"site_url"`
+
+ LinkToReply string `json:"link_to_reply"`
+}
+
+// 替换 {{ key }} 为 val
+func ReplaceAllMustache(data string, dict map[string]interface{}) string {
+ return utils.RenderMustaches(data, dict, func(k string, v interface{}) string {
+ return GetPurifiedValue(k, v)
+ })
+}
+
+// 净化文本,防止 XSS
+func GetPurifiedValue(k string, v interface{}) string {
+ val := fmt.Sprintf("%v", v)
+
+ // 白名单
+ ignoreEscapeKeys := []string{"reply_content", "content", "link_to_reply"}
+ if utils.ContainsStr(ignoreEscapeKeys, k) ||
+ strings.HasSuffix(k, ".content") || // 排除 entity.CookedComment.content
+ strings.HasSuffix(k, ".content_raw") {
+ return val
+ }
+
+ val = html.EscapeString(val)
+ return val
+}
+
+func HandleEmoticonsImgTagsForNotify(str string) string {
+ r := regexp.MustCompile(`
]*?atk-emoticon=["]([^"]*?)["][^>]*?>`)
+ return r.ReplaceAllStringFunc(str, func(m string) string {
+ ms := r.FindStringSubmatch(m)
+ if len(ms) < 2 {
+ return m
+ }
+ if ms[1] == "" {
+ return "[表情]"
+ }
+ return "[" + ms[1] + "]"
+ })
+}
+
+// 渲染邮件 Body 内容
+func RenderEmailBody(notify *entity.Notify, isSendToAdmin bool) string {
+ tplName := config.Instance.Email.MailTpl
+
+ // 发送给管理员的邮件单独使用管理员邮件模板
+ if isSendToAdmin {
+ tplName = config.Instance.AdminNotify.Email.MailTpl
+ }
+
+ // 配置文件未指定邮件模板路径,使用内置默认模板
+ if tplName == "" {
+ tplName = "default"
+ }
+
+ tpl := ""
+ if _, err := os.Stat(tplName); errors.Is(err, os.ErrNotExist) {
+ tpl = GetInternalEmailTpl(tplName)
+ } else {
+ // TODO 反复文件 IO 操作会导致性能下降,
+ // 之后优化可以改成程序启动时加载模板文件到内存中
+ tpl = GetExternalTpl(tplName)
+ }
+
+ tpl = RenderCommon(tpl, notify)
+
+ return tpl
+}
+
+// 渲染管理员推送 Body 内容
+func RenderNotifyBody(notify *entity.Notify) string {
+ tplName := config.Instance.AdminNotify.NotifyTpl
+ if tplName == "" {
+ tplName = "default"
+ }
+
+ tpl := ""
+ if _, err := os.Stat(tplName); errors.Is(err, os.ErrNotExist) {
+ tpl = GetInternalNotifyTpl(tplName)
+ } else {
+ tpl = GetExternalTpl(tplName)
+ }
+
+ tpl = RenderCommon(tpl, notify, "notify")
+
+ return tpl
+}
+
+// 获取内建邮件模版
+func GetInternalEmailTpl(tplName string) string {
+ return GetInternalTpl("email_tpl", tplName)
+}
+
+// 获取内建通知模版
+func GetInternalNotifyTpl(tplName string) string {
+ return GetInternalTpl("notify_tpl", tplName)
+}
+
+// 获取内建模版
+func GetInternalTpl(basePath string, tplName string) string {
+ filename := fmt.Sprintf("%s/%s.html", basePath, tplName)
+ f, err := internalTpl.Open(filename)
+ if err != nil {
+ return ""
+ }
+
+ buf := new(bytes.Buffer)
+ if _, err := buf.ReadFrom(f); err != nil {
+ return ""
+ }
+ contents := buf.String()
+
+ return contents
+}
+
+// 获取外置模版
+func GetExternalTpl(filename string) string {
+ buf, err := os.ReadFile(filename)
+ if err != nil {
+ return ""
+ }
+
+ return string(buf)
+}
diff --git a/internal/email/render_test.go b/internal/email/render_test.go
new file mode 100644
index 000000000..22171aa61
--- /dev/null
+++ b/internal/email/render_test.go
@@ -0,0 +1,25 @@
+package email
+
+import "testing"
+
+func TestHandleEmoticonsImgTagsForNotify(t *testing.T) {
+ tests := []struct {
+ name string
+ give string
+ want string
+ }{
+ {name: "Test1", give: `
`, want: `[testA]`},
+ {
+ name: "Test2",
+ give: `6Rs8OXba\n9u5PiJYiAf \n
\n1sKyAAIxssts36Rs8OXba9u5PiJYiAf \n
6Rs8OXba9u5PiJYiAf
`,
+ want: `6Rs8OXba\n9u5PiJYiAf \n[testA]\n1sKyAAIxssts36Rs8OXba9u5PiJYiAf \n[testB]6Rs8OXba9u5PiJYiAf[表情]`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := HandleEmoticonsImgTagsForNotify(tt.give); got != tt.want {
+ t.Errorf("HandleEmoticonsImgTagsForNotify() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/email/sender.go b/internal/email/sender.go
new file mode 100644
index 000000000..c4e6aec87
--- /dev/null
+++ b/internal/email/sender.go
@@ -0,0 +1,61 @@
+package email
+
+import (
+ "bytes"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "gopkg.in/gomail.v2"
+)
+
+type Email struct {
+ FromAddr string
+ FromName string
+ ToAddr string
+ Subject string
+ Body string
+ LinkedNotify *entity.Notify
+}
+
+// Sender is an interface for sending email.
+type Sender interface {
+ Send(email Email) bool
+}
+
+func NewSender(t config.EmailSenderType) Sender {
+ switch t {
+ case config.TypeSMTP:
+ return NewSmtpSender(&config.Instance.Email.SMTP)
+ case config.TypeAliDM:
+ return NewAliDMSender(&config.Instance.Email.AliDM)
+ case config.TypeSendmail:
+ return NewCmdSender()
+ default:
+ panic("Unknown email sender type")
+ }
+}
+
+func getCookedEmail(email Email) *gomail.Message {
+ m := gomail.NewMessage()
+
+ // 发送人
+ m.SetHeader("From", m.FormatAddress(email.FromAddr, email.FromName))
+ // 接收人
+ m.SetHeader("To", email.ToAddr)
+ // 抄送人
+ //m.SetAddressHeader("Cc", "dan@example.com", "Dan")
+ // 主题
+ m.SetHeader("Subject", email.Subject)
+ // 内容
+ m.SetBody("text/html", email.Body)
+ // 附件
+ //m.Attach("./file.png")
+
+ return m
+}
+
+func getEmailMineTxt(email Email) string {
+ emailBuffer := bytes.NewBuffer([]byte{})
+ getCookedEmail(email).WriteTo(emailBuffer)
+ return string(emailBuffer.Bytes()[:])
+}
diff --git a/internal/email/sender_alidm.go b/internal/email/sender_alidm.go
new file mode 100644
index 000000000..2453906de
--- /dev/null
+++ b/internal/email/sender_alidm.go
@@ -0,0 +1,52 @@
+package email
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/config"
+ aliyun_email "github.com/qwqcode/go-aliyun-email"
+ "github.com/samber/lo"
+ "github.com/sirupsen/logrus"
+)
+
+// AliDMSender implements Sender
+type AliDMSender struct {
+ conf *config.AliDMConf
+}
+
+var _ Sender = (*AliDMSender)(nil)
+
+// NewAliDMSender 阿里云邮件推送
+func NewAliDMSender(conf *config.AliDMConf) *AliDMSender {
+ return &AliDMSender{
+ conf: conf,
+ }
+}
+
+func (s *AliDMSender) Send(email Email) bool {
+ client := aliyun_email.NewClient(
+ s.conf.AccessKeyId,
+ s.conf.AccessKeySecret,
+ s.conf.AccountName,
+ email.FromName,
+ lo.If(s.conf.Region == "", aliyun_email.RegionCNHangZhou).Else(s.conf.Region),
+ )
+
+ req := &aliyun_email.SingleRequest{
+ ReplyToAddress: true,
+ AddressType: 1,
+ ToAddress: email.ToAddr,
+ Subject: email.Subject,
+ HtmlBody: email.Body,
+ }
+
+ resp, err := client.SingleRequest(req)
+ if err != nil {
+ logrus.Error("[Email] ", "Email sending failed via Aliyun DM", err)
+ return false
+ }
+
+ if config.Instance.Debug {
+ logrus.Debug(resp)
+ }
+
+ return true
+}
diff --git a/internal/email/sender_cmd.go b/internal/email/sender_cmd.go
new file mode 100644
index 000000000..203688b0f
--- /dev/null
+++ b/internal/email/sender_cmd.go
@@ -0,0 +1,69 @@
+package email
+
+import (
+ "io"
+ "os/exec"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/sirupsen/logrus"
+)
+
+// CmdSender implements Sender
+type CmdSender struct {
+}
+
+var _ Sender = (*CmdSender)(nil)
+
+// NewCmdSender sendmail
+func NewCmdSender() *CmdSender {
+ return &CmdSender{}
+}
+
+func (s *CmdSender) Send(email Email) bool {
+ LogTag := "[EMAIL] [sendmail] "
+ msg := getEmailMineTxt(email)
+
+ // 调用系统 sendmail
+ sendmail := exec.Command("/usr/sbin/sendmail", "-t", "-oi")
+ stdin, err := sendmail.StdinPipe()
+ if err != nil {
+ logrus.Error(LogTag, err)
+ return false
+ }
+
+ stdout, err := sendmail.StdoutPipe()
+ if err != nil {
+ logrus.Error(LogTag, err)
+ return false
+ }
+
+ if err := sendmail.Start(); err != nil {
+ logrus.Error(LogTag, err)
+ return false
+ }
+
+ if _, err := stdin.Write([]byte(msg)); err != nil {
+ logrus.Error(LogTag, err)
+ return false
+ }
+
+ if err := stdin.Close(); err != nil {
+ logrus.Error(LogTag, err)
+ return false
+ }
+
+ sentBytes, _ := io.ReadAll(stdout)
+ if err := sendmail.Wait(); err != nil {
+ logrus.Error(LogTag, err)
+ if exitError, ok := err.(*exec.ExitError); ok {
+ logrus.Error(LogTag, "Exit code is %d", exitError.ExitCode())
+ }
+ return false
+ }
+
+ if config.Instance.Debug {
+ logrus.Debug(string(sentBytes))
+ }
+
+ return true
+}
diff --git a/internal/email/sender_smtp.go b/internal/email/sender_smtp.go
new file mode 100644
index 000000000..8aadf04de
--- /dev/null
+++ b/internal/email/sender_smtp.go
@@ -0,0 +1,35 @@
+package email
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/sirupsen/logrus"
+ "gopkg.in/gomail.v2"
+)
+
+// SmtpSender implements Sender
+type SmtpSender struct {
+ dialer *gomail.Dialer
+}
+
+var _ Sender = (*SmtpSender)(nil)
+
+// NewSmtpSender SMTP
+func NewSmtpSender(smtp *config.SMTPConf) *SmtpSender {
+ d := gomail.NewDialer(smtp.Host, smtp.Port, smtp.Username, smtp.Password)
+
+ return &SmtpSender{
+ dialer: d,
+ }
+}
+
+func (s *SmtpSender) Send(email Email) bool {
+ m := getCookedEmail(email)
+
+ // 发送邮件
+ if err := s.dialer.DialAndSend(m); err != nil {
+ logrus.Error("[Email] ", "Email sending failed via SMTP ", err)
+ return false
+ }
+
+ return true
+}
diff --git a/internal/entity/artran.go b/internal/entity/artran.go
new file mode 100644
index 000000000..637e407cd
--- /dev/null
+++ b/internal/entity/artran.go
@@ -0,0 +1,40 @@
+package entity
+
+// 数据行囊 (n 个 Artran 组成一个 Artrans)
+// Fields All String type (FAS)
+type Artran struct {
+ ID string `json:"id"`
+ Rid string `json:"rid"`
+
+ Content string `json:"content"`
+
+ UA string `json:"ua"`
+ IP string `json:"ip"`
+ IsCollapsed string `json:"is_collapsed"` // bool => string "true" or "false"
+ IsPending string `json:"is_pending"` // bool
+ IsPinned string `json:"is_pinned"` // bool
+
+ // vote
+ VoteUp string `json:"vote_up"`
+ VoteDown string `json:"vote_down"`
+
+ // date
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+
+ // user
+ Nick string `json:"nick"`
+ Email string `json:"email"`
+ Link string `json:"link"`
+ BadgeName string `json:"badge_name"`
+ BadgeColor string `json:"badge_color"`
+
+ // page
+ PageKey string `json:"page_key"`
+ PageTitle string `json:"page_title"`
+ PageAdminOnly string `json:"page_admin_only"` // bool
+
+ // site
+ SiteName string `json:"site_name"`
+ SiteUrls string `json:"site_urls"`
+}
diff --git a/internal/entity/comment.go b/internal/entity/comment.go
new file mode 100644
index 000000000..fa1e28c82
--- /dev/null
+++ b/internal/entity/comment.go
@@ -0,0 +1,45 @@
+package entity
+
+import (
+ "sync"
+
+ "gorm.io/gorm"
+)
+
+type Comment struct {
+ gorm.Model
+
+ Content string
+
+ PageKey string `gorm:"index;size:255"`
+ SiteName string `gorm:"index;size:255"`
+
+ UserID uint `gorm:"index"`
+ UA string
+ IP string
+
+ Rid uint `gorm:"index"` // 父评论 ID
+
+ IsCollapsed bool `gorm:"default:false"` // 折叠
+ IsPending bool `gorm:"default:false"` // 待审
+ IsPinned bool `gorm:"default:false"` // 置顶
+
+ User User `gorm:"-"`
+ Page Page `gorm:"-"`
+ Site Site `gorm:"-"`
+
+ Once_User sync.Once `gorm:"-"`
+ Once_Page sync.Once `gorm:"-"`
+ Once_Site sync.Once `gorm:"-"`
+
+ VoteUp int
+ VoteDown int
+}
+
+func (c Comment) IsEmpty() bool {
+ return c.ID == 0
+}
+
+func (c Comment) IsAllowReply() bool {
+ return !c.IsCollapsed && !c.IsPending
+}
diff --git a/internal/entity/comment_cooked.go b/internal/entity/comment_cooked.go
new file mode 100644
index 000000000..52ccded54
--- /dev/null
+++ b/internal/entity/comment_cooked.go
@@ -0,0 +1,26 @@
+package entity
+
+type CookedComment struct {
+ ID uint `json:"id"`
+ Content string `json:"content"`
+ ContentMarked string `json:"content_marked"`
+ UserID uint `json:"user_id"`
+ Nick string `json:"nick"`
+ EmailEncrypted string `json:"email_encrypted"`
+ Link string `json:"link"`
+ UA string `json:"ua"`
+ Date string `json:"date"`
+ IsCollapsed bool `json:"is_collapsed"`
+ IsPending bool `json:"is_pending"`
+ IsPinned bool `json:"is_pinned"`
+ IsAllowReply bool `json:"is_allow_reply"`
+ Rid uint `json:"rid"`
+ BadgeName string `json:"badge_name"`
+ BadgeColor string `json:"badge_color"`
+ Visible bool `json:"visible"`
+ VoteUp int `json:"vote_up"`
+ VoteDown int `json:"vote_down"`
+ PageKey string `json:"page_key"`
+ PageURL string `json:"page_url"`
+ SiteName string `json:"site_name"`
+}
diff --git a/internal/entity/comment_for_email.go b/internal/entity/comment_for_email.go
new file mode 100644
index 000000000..72d957d66
--- /dev/null
+++ b/internal/entity/comment_for_email.go
@@ -0,0 +1,18 @@
+package entity
+
+type CookedCommentForEmail struct {
+ CookedComment
+ Content string `json:"content"`
+ ContentRaw string `json:"content_raw"`
+ Nick string `json:"nick"`
+ Email string `json:"email"`
+ IP string `json:"ip"`
+ Datetime string `json:"datetime"`
+ Date string `json:"date"`
+ Time string `json:"time"`
+ PageKey string `json:"page_key"`
+ PageTitle string `json:"page_title"`
+ Page CookedPage `json:"page"`
+ SiteName string `json:"site_name"`
+ Site CookedSite `json:"site"`
+}
diff --git a/internal/entity/notify.go b/internal/entity/notify.go
new file mode 100644
index 000000000..380f15372
--- /dev/null
+++ b/internal/entity/notify.go
@@ -0,0 +1,40 @@
+package entity
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "gorm.io/gorm"
+)
+
+type Notify struct {
+ gorm.Model
+
+ UserID uint `gorm:"index"` // 通知对象 (接收通知的用户 ID)
+ CommentID uint `gorm:"index"` // 待查看的评论
+
+ IsRead bool
+ ReadAt *time.Time
+ IsEmailed bool
+ EmailAt *time.Time
+
+ Key string `gorm:"index;size:255"`
+
+ Comment Comment `gorm:"-"`
+ Once_Comment sync.Once `gorm:"-"`
+}
+
+func (n Notify) IsEmpty() bool {
+ return n.ID == 0
+}
+
+func (n *Notify) SetComment(comment Comment) {
+ n.Comment = comment
+}
+
+// 操作时的验证密钥(判断是否本人操作)
+func (n *Notify) GenerateKey() {
+ n.Key = utils.GetMD5Hash(fmt.Sprintf("%v %v %v", n.UserID, n.CommentID, time.Now().Unix()))
+}
diff --git a/internal/entity/notify_cooked.go b/internal/entity/notify_cooked.go
new file mode 100644
index 000000000..9189acb60
--- /dev/null
+++ b/internal/entity/notify_cooked.go
@@ -0,0 +1,10 @@
+package entity
+
+type CookedNotify struct {
+ ID uint `json:"id"`
+ UserID uint `json:"user_id"`
+ CommentID uint `json:"comment_id"`
+ IsRead bool `json:"is_read"`
+ IsEmailed bool `json:"is_emailed"`
+ ReadLink string `json:"read_link"`
+}
diff --git a/internal/entity/page.go b/internal/entity/page.go
new file mode 100644
index 000000000..a6c1749b5
--- /dev/null
+++ b/internal/entity/page.go
@@ -0,0 +1,29 @@
+package entity
+
+import (
+ "sync"
+
+ "gorm.io/gorm"
+)
+
+type Page struct {
+ gorm.Model
+ Key string `gorm:"index;size:255"` // 页面 Key(一般为不含 hash/query 的完整 url)
+ Title string
+ AdminOnly bool
+
+ SiteName string `gorm:"index;size:255"`
+ Site Site `gorm:"-"`
+ Once_Site sync.Once `gorm:"-"`
+
+ AccessibleURL string
+
+ VoteUp int
+ VoteDown int
+
+ PV int
+}
+
+func (p Page) IsEmpty() bool {
+ return p.ID == 0
+}
diff --git a/internal/entity/page_cooked.go b/internal/entity/page_cooked.go
new file mode 100644
index 000000000..2a90252b9
--- /dev/null
+++ b/internal/entity/page_cooked.go
@@ -0,0 +1,13 @@
+package entity
+
+type CookedPage struct {
+ ID uint `json:"id"`
+ AdminOnly bool `json:"admin_only"`
+ Key string `json:"key"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ SiteName string `json:"site_name"`
+ VoteUp int `json:"vote_up"`
+ VoteDown int `json:"vote_down"`
+ PV int `json:"pv"`
+}
diff --git a/internal/entity/site.go b/internal/entity/site.go
new file mode 100644
index 000000000..272b3c8e3
--- /dev/null
+++ b/internal/entity/site.go
@@ -0,0 +1,15 @@
+package entity
+
+import (
+ "gorm.io/gorm"
+)
+
+type Site struct {
+ gorm.Model
+ Name string `gorm:"uniqueIndex;size:255"`
+ Urls string
+}
+
+func (s Site) IsEmpty() bool {
+ return s.ID == 0
+}
diff --git a/internal/entity/site_coocked.go b/internal/entity/site_coocked.go
new file mode 100644
index 000000000..31523dfdf
--- /dev/null
+++ b/internal/entity/site_coocked.go
@@ -0,0 +1,9 @@
+package entity
+
+type CookedSite struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ Urls []string `json:"urls"`
+ UrlsRaw string `json:"urls_raw"`
+ FirstUrl string `json:"first_url"`
+}
diff --git a/internal/entity/user.go b/internal/entity/user.go
new file mode 100644
index 000000000..6658bc084
--- /dev/null
+++ b/internal/entity/user.go
@@ -0,0 +1,39 @@
+package entity
+
+import (
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+)
+
+type User struct {
+ gorm.Model
+ Name string `gorm:"index;size:255"`
+ Email string `gorm:"index;size:255"`
+ Link string
+ Password string
+ BadgeName string
+ BadgeColor string
+ LastIP string
+ LastUA string
+ IsAdmin bool
+ SiteNames string
+ ReceiveEmail bool `gorm:"default:true"`
+
+ // 配置文件中添加的
+ IsInConf bool
+}
+
+func (u User) IsEmpty() bool {
+ return u.ID == 0
+}
+
+func (u *User) SetPasswordEncrypt(password string) (err error) {
+ var encrypted []byte
+ if encrypted, err = bcrypt.GenerateFromPassword(
+ []byte(password), bcrypt.DefaultCost,
+ ); err != nil {
+ return err
+ }
+ u.Password = "(bcrypt)" + string(encrypted)
+ return nil
+}
diff --git a/internal/entity/user_cooked.go b/internal/entity/user_cooked.go
new file mode 100644
index 000000000..f85b87d0c
--- /dev/null
+++ b/internal/entity/user_cooked.go
@@ -0,0 +1,14 @@
+package entity
+
+type CookedUser struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Link string `json:"link"`
+ BadgeName string `json:"badge_name"`
+ BadgeColor string `json:"badge_color"`
+ IsAdmin bool `json:"is_admin"`
+ SiteNames []string `json:"site_names"`
+ SiteNamesRaw string `json:"site_names_raw"`
+ ReceiveEmail bool `json:"receive_email"`
+}
diff --git a/internal/entity/user_for_admin.go b/internal/entity/user_for_admin.go
new file mode 100644
index 000000000..d0c00b7d9
--- /dev/null
+++ b/internal/entity/user_for_admin.go
@@ -0,0 +1,9 @@
+package entity
+
+type CookedUserForAdmin struct {
+ CookedUser
+ LastIP string `json:"last_ip"`
+ LastUA string `json:"last_ua"`
+ IsInConf bool `json:"is_in_conf"`
+ CommentCount int64 `json:"comment_count"`
+}
diff --git a/internal/entity/utils.go b/internal/entity/utils.go
new file mode 100644
index 000000000..7b5d766fa
--- /dev/null
+++ b/internal/entity/utils.go
@@ -0,0 +1,19 @@
+package entity
+
+func ContainsComment(comments []Comment, targetID uint) bool {
+ for _, c := range comments {
+ if c.ID == targetID {
+ return true
+ }
+ }
+ return false
+}
+
+func ContainsCookedComment(comments []CookedComment, targetID uint) bool {
+ for _, c := range comments {
+ if c.ID == targetID {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/entity/vote.go b/internal/entity/vote.go
new file mode 100644
index 000000000..a2b801a22
--- /dev/null
+++ b/internal/entity/vote.go
@@ -0,0 +1,35 @@
+package entity
+
+import (
+ "strings"
+
+ "gorm.io/gorm"
+)
+
+type VoteType string
+
+const (
+ VoteTypeCommentUp VoteType = "comment_up"
+ VoteTypeCommentDown VoteType = "comment_down"
+ VoteTypePageUp VoteType = "page_up"
+ VoteTypePageDown VoteType = "page_down"
+)
+
+type Vote struct {
+ gorm.Model
+
+ TargetID uint `gorm:"index"` // 投票对象
+ Type VoteType `gorm:"index"`
+
+ UserID uint `gorm:"index"` // 投票者
+ UA string
+ IP string
+}
+
+func (v *Vote) IsEmpty() bool {
+ return v.ID == 0
+}
+
+func (v *Vote) IsUp() bool {
+ return strings.HasSuffix(string(v.Type), "_up")
+}
diff --git a/internal/i18n/gen/main.go b/internal/i18n/gen/main.go
new file mode 100644
index 000000000..9bdc81aa4
--- /dev/null
+++ b/internal/i18n/gen/main.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+var (
+ workDir string
+ scanDirs string
+ outputFile string
+)
+
+func init() {
+ flag.StringVar(&workDir, "w", "", "specify work directory")
+ flag.StringVar(&scanDirs, "d", "./internal", "specify which directory to scan")
+ flag.StringVar(&outputFile, "o", "./i18n/en.yml", "specify output filename")
+}
+
+func main() {
+ flag.Parse()
+
+ if workDir != "" {
+ if err := os.Chdir(workDir); err != nil {
+ panic(err)
+ }
+ }
+
+ // Initialize an empty map to store the keys
+ keys := make(map[string]bool)
+
+ for _, dir := range strings.Split(scanDirs, ",") {
+ for _, k := range scan(strings.TrimSpace(dir)) {
+ // Add the key to the map if it doesn't already exist
+ if !keys[k] {
+ keys[k] = true
+ }
+ }
+ }
+
+ // Use the sort package to sort the keys alphabetically
+ var keysSlice []string
+ for key := range keys {
+ keysSlice = append(keysSlice, key)
+ }
+ sort.Strings(keysSlice)
+
+ // Use the ioutil package to write the keys to the YAML file
+ content := ""
+ for _, key := range keysSlice {
+ content += fmt.Sprintf("%s:\n", key)
+ }
+ err := os.WriteFile(outputFile, []byte(content), 0644)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func scan(dir string) []string {
+ result := []string{}
+
+ // Use the os package to search for Go files recursively
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if !info.IsDir() && strings.HasSuffix(path, ".go") {
+ start := time.Now()
+
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ buf, err := io.ReadAll(file)
+ if err != nil {
+ return err
+ }
+
+ codeStr := string(buf)
+ if !strings.Contains(codeStr, "i18n.T") {
+ return nil
+ }
+
+ extKeys := extractFromCode(codeStr)
+
+ if len(extKeys) > 0 {
+ result = append(result, extKeys...)
+ duration := time.Since(start)
+ fmt.Printf("found %d msgs in `%s` (duration: %v)\n", len(extKeys), path, duration)
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ panic(err)
+ }
+
+ return result
+}
+
+func extractFromCode(src string) []string {
+ fileSet := token.NewFileSet()
+ f, err := parser.ParseFile(fileSet, "", src, 0)
+ if err != nil {
+ panic(err)
+ }
+
+ var i18nKeySet []string
+ ast.Inspect(f, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+
+ if fun, ok := call.Fun.(*ast.SelectorExpr); ok {
+ if fun.Sel.Name != "T" {
+ return true
+ }
+ if ident, ok := fun.X.(*ast.Ident); ok {
+ if ident.Name != "i18n" {
+ return true
+ }
+ if lit, ok := call.Args[0].(*ast.BasicLit); ok {
+ i18nKeySet = append(i18nKeySet, lit.Value)
+ return true
+ }
+ }
+ }
+ return true
+ })
+
+ return i18nKeySet
+}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 000000000..4c306d2ac
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,45 @@
+package i18n
+
+import (
+ "fmt"
+
+ "github.com/ArtalkJS/Artalk/internal/pkged"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/sirupsen/logrus"
+ "gopkg.in/yaml.v3"
+)
+
+//go:generate go run ./gen -w ../../ -d internal,server,cmd -o i18n/en.yml
+
+var Locales map[string]string
+
+func Init(locale string) {
+ if locale == "" {
+ locale = "en"
+ }
+
+ yamlStr, err := pkged.FS().ReadFile(fmt.Sprintf("i18n/%s.yml", locale))
+ if err != nil {
+ logrus.Warn("invalid locale config please check, now it is set to `en`")
+ Init("en")
+ return
+ }
+
+ yaml.Unmarshal(yamlStr, &Locales)
+}
+
+func T(msg string, params ...map[string]interface{}) string {
+ v, ok := Locales[msg]
+ if !ok || v == "" {
+ v = msg
+ }
+ return msgParams(v, params...)
+}
+
+func msgParams(msg string, params ...map[string]interface{}) string {
+ if len(params) > 0 {
+ return utils.RenderMustaches(msg, params[0])
+ }
+
+ return msg
+}
diff --git a/internal/notify_launcher/init.go b/internal/notify_launcher/init.go
new file mode 100644
index 000000000..9c08e3b22
--- /dev/null
+++ b/internal/notify_launcher/init.go
@@ -0,0 +1,55 @@
+package notify_launcher
+
+import (
+ "context"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/email"
+ "github.com/nikoksr/notify"
+ "github.com/nikoksr/notify/service/dingding"
+ "github.com/nikoksr/notify/service/line"
+ "github.com/nikoksr/notify/service/slack"
+ "github.com/nikoksr/notify/service/telegram"
+)
+
+var Notify *notify.Notify
+var NotifyCtx = context.Background()
+
+func Init() {
+ // 初始化邮件队列
+ email.InitQueue()
+
+ // 初始化 Notify
+ Notify = notify.New()
+
+ // Telegram
+ tgConf := config.Instance.AdminNotify.Telegram
+ if tgConf.Enabled {
+ telegramService, _ := telegram.New(tgConf.ApiToken)
+ telegramService.AddReceivers(tgConf.Receivers...)
+ Notify.UseServices(telegramService)
+ }
+
+ // 钉钉
+ dingTalkConf := config.Instance.AdminNotify.DingTalk
+ if dingTalkConf.Enabled {
+ dingTalkService := dingding.New(&dingding.Config{Token: dingTalkConf.Token, Secret: dingTalkConf.Secret})
+ Notify.UseServices(dingTalkService)
+ }
+
+ // Slack
+ slackConf := config.Instance.AdminNotify.Slack
+ if slackConf.Enabled {
+ slackService := slack.New(slackConf.OauthToken)
+ slackService.AddReceivers(slackConf.Receivers...)
+ Notify.UseServices(slackService)
+ }
+
+ // LINE
+ LINEConf := config.Instance.AdminNotify.LINE
+ if LINEConf.Enabled {
+ lineService, _ := line.New(config.Instance.AdminNotify.LINE.ChannelSecret, config.Instance.AdminNotify.LINE.ChannelAccessToken)
+ lineService.AddReceivers(LINEConf.Receivers...)
+ Notify.UseServices(lineService)
+ }
+}
diff --git a/internal/notify_launcher/notify_launcher.go b/internal/notify_launcher/notify_launcher.go
new file mode 100644
index 000000000..6ac34e1e6
--- /dev/null
+++ b/internal/notify_launcher/notify_launcher.go
@@ -0,0 +1,255 @@
+package notify_launcher
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "html"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/email"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+
+ "github.com/sirupsen/logrus"
+)
+
+// 通知发送 (from comment to parentComment)
+func SendNotify(comment *entity.Comment, pComment *entity.Comment) {
+ isRootComment := pComment == nil || pComment.IsEmpty()
+ isEmailToAdminOff := !config.Instance.AdminNotify.Email.Enabled
+ isAdminNoiseModeOn := config.Instance.AdminNotify.NoiseMode
+
+ // ==============
+ // 邮件回复对方
+ // ==============
+ if !isRootComment {
+ (func() {
+ // 自己回复自己,不提醒
+ if comment.UserID == pComment.UserID {
+ return
+ }
+
+ // 待审状态评论回复不邮件通知 (管理员审核通过后才发送)
+ if comment.IsPending {
+ return
+ }
+
+ // 对方个人设定关闭邮件接收
+ if !query.FetchUserForComment(pComment).ReceiveEmail {
+ return
+ }
+
+ // 对方是管理员,但是管理员邮件接收关闭 (用于开启多元推送后禁用邮件通知管理员)
+ if query.FetchUserForComment(pComment).IsAdmin && isEmailToAdminOff {
+ return
+ }
+
+ notify := query.FindCreateNotify(pComment.UserID, comment.ID)
+ notify.SetComment(*comment)
+ query.NotifySetInitial(¬ify)
+
+ // 邮件通知
+ email.AsyncSend(¬ify)
+ })()
+ }
+
+ // ==============
+ // 邮件通知管理员
+ // ==============
+ if isRootComment || isAdminNoiseModeOn {
+ for _, admin := range query.GetAllAdmins() {
+ // 配置文件关闭管理员邮件接收
+ if isEmailToAdminOff {
+ continue
+ }
+
+ // 管理员自己回复自己,不提醒
+ if comment.UserID == admin.ID {
+ continue
+ }
+
+ // 用户回复对象是该管理员,不提醒
+ // (避免当 NoiseModeOn = true 时,重复发送)
+ if pComment.UserID == admin.ID {
+ continue
+ }
+
+ // 管理员评论不回复给其他管理员
+ if query.FetchUserForComment(comment).IsAdmin {
+ continue
+ }
+
+ // 只发送给对应站点管理员
+ if admin.SiteNames != "" && !utils.ContainsStr(query.CookUser(&admin).SiteNames, comment.SiteName) {
+ continue
+ }
+
+ // 该管理员单独设定关闭接收邮件
+ if !admin.ReceiveEmail {
+ continue
+ }
+
+ notify := query.FindCreateNotify(admin.ID, comment.ID)
+ notify.SetComment(*comment)
+ query.NotifySetInitial(¬ify)
+
+ // 发送邮件给管理员
+ email.AsyncSend(¬ify)
+ }
+ }
+
+ // 管理员多元推送
+ AdminNotify(comment, pComment)
+}
+
+func AdminNotify(comment *entity.Comment, pComment *entity.Comment) {
+ adminNotifyConf := config.Instance.AdminNotify
+
+ // 忽略来自管理员的评论
+ coUser := query.FetchUserForComment(comment)
+ if coUser.IsAdmin {
+ return
+ }
+
+ if !adminNotifyConf.NoiseMode {
+ // 如果不是 root 评论 且 回复目标不是管理员,直接忽略
+ isRootComment := pComment == nil || pComment.IsEmpty()
+ if !isRootComment && !query.FetchUserForComment(pComment).IsAdmin {
+ return
+ }
+ }
+
+ // 评论内容文字截断
+ // coContent := lib.TruncateString(comment.Content, 280)
+ // if len([]rune(coContent)) > 280 {
+ // coContent = coContent + "..."
+ // }
+
+ // 通知消息
+ firstAdminUser := query.GetAllAdminIDs()[0]
+ notify := query.FindCreateNotify(firstAdminUser, comment.ID)
+
+ subject := ""
+ if adminNotifyConf.NotifySubject != "" {
+ subject = email.RenderCommon(adminNotifyConf.NotifySubject, ¬ify, "notify")
+ }
+
+ body := email.RenderNotifyBody(¬ify)
+ if comment.IsPending {
+ body = "[待审状态评论]\n\n" + body
+ }
+
+ logrus.Debug(time.Now(), " 多元推送")
+
+ // 使用 Notify 库发送
+ go func() {
+ err := Notify.Send(NotifyCtx, subject, html.EscapeString(body))
+ if err != nil {
+ logrus.Error("[Notify]", err)
+ }
+ }()
+
+ // 飞书
+ if config.Instance.AdminNotify.Lark.Enabled {
+ go func() {
+ SendLark(subject, body)
+ }()
+ }
+
+ // Bark
+ if config.Instance.AdminNotify.Bark.Enabled {
+ go func() {
+ SendBark(subject, body)
+ }()
+ }
+
+ // WebHook
+ if config.Instance.AdminNotify.WebHook.Enabled {
+ go func() {
+ SendWebHook(subject, body, comment, pComment)
+ }()
+ }
+}
+
+// 飞书发送
+func SendLark(title string, msg string) {
+ larkConf := config.Instance.AdminNotify.Lark
+ if !larkConf.Enabled {
+ return
+ }
+
+ if title != "" {
+ msg = title + "\n\n" + msg
+ }
+
+ sendData := fmt.Sprintf(`{"msg_type":"text","content":{"text":%s}}`, strconv.Quote(msg))
+ result, err := http.Post(larkConf.WebhookURL, "application/json", strings.NewReader(sendData))
+ if err != nil {
+ logrus.Error("[飞书] ", "Failed to send msg:", err)
+ return
+ }
+
+ defer result.Body.Close()
+}
+
+// Bark 发送
+func SendBark(title string, msg string) {
+ barkConf := config.Instance.AdminNotify.Bark
+ if !barkConf.Enabled {
+ return
+ }
+
+ if title == "" {
+ title = "Artalk"
+ }
+
+ result, err := http.Get(fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(barkConf.Server, "/"), url.QueryEscape(title), url.QueryEscape(msg)))
+ if err != nil {
+ logrus.Error("[Bark] ", "Failed to send msg:", err)
+ return
+ }
+
+ defer result.Body.Close()
+}
+
+type NotifyWebHookReqBody struct {
+ NotifySubject string `json:"notify_subject"`
+ NotifyBody string `json:"notify_body"`
+ Comment interface{} `json:"comment"`
+ ParentComment interface{} `json:"parent_comment"`
+}
+
+// WebHook 发送
+func SendWebHook(subject string, body string, comment *entity.Comment, pComment *entity.Comment) {
+ webhookConf := config.Instance.AdminNotify.WebHook
+ if !webhookConf.Enabled {
+ return
+ }
+
+ reqData := NotifyWebHookReqBody{
+ NotifySubject: subject,
+ NotifyBody: body,
+ Comment: query.CookComment(comment),
+ }
+ if !pComment.IsEmpty() {
+ reqData.ParentComment = query.CookComment(pComment)
+ } else {
+ reqData.ParentComment = nil
+ }
+
+ jsonByte, _ := json.Marshal(reqData)
+ result, err := http.Post(webhookConf.URL, "application/json", bytes.NewReader(jsonByte))
+ if err != nil {
+ logrus.Error("[WebHook Push] ", "Failed to send msg:", err)
+ return
+ }
+
+ defer result.Body.Close()
+}
diff --git a/internal/pkged/pkged.go b/internal/pkged/pkged.go
new file mode 100644
index 000000000..650283165
--- /dev/null
+++ b/internal/pkged/pkged.go
@@ -0,0 +1,13 @@
+package pkged
+
+import "embed"
+
+var fs embed.FS
+
+func SetFS(embedFs embed.FS) {
+ fs = embedFs
+}
+
+func FS() embed.FS {
+ return fs
+}
diff --git a/internal/query/association.go b/internal/query/association.go
new file mode 100644
index 000000000..109339856
--- /dev/null
+++ b/internal/query/association.go
@@ -0,0 +1,77 @@
+package query
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/entity"
+)
+
+// ===============
+// Comment
+// ===============
+
+func FetchUserForComment(c *entity.Comment) entity.User {
+ if c.User.IsEmpty() {
+ c.Once_User.Do(func() {
+ user := FindUserByID(c.UserID)
+ c.User = user
+ })
+ }
+
+ return c.User
+}
+
+func FetchPageForComment(c *entity.Comment) entity.Page {
+ if c.Page.IsEmpty() {
+ c.Once_Page.Do(func() {
+ page := FindPage(c.PageKey, c.SiteName)
+ c.Page = page
+ })
+ }
+
+ return c.Page
+}
+
+func FetchSiteForComment(c *entity.Comment) entity.Site {
+ if c.Site.IsEmpty() {
+ c.Once_Site.Do(func() {
+ site := FindSite(c.SiteName)
+ c.Site = site
+ })
+ }
+
+ return c.Site
+}
+
+// ===============
+// Page
+// ===============
+
+func FetchSiteForPage(p *entity.Page) entity.Site {
+ if p.Site.IsEmpty() {
+ p.Once_Site.Do(func() {
+ site := FindSite(p.SiteName)
+ p.Site = site
+ })
+ }
+
+ return p.Site
+}
+
+// ===============
+// Notify
+// ===============
+
+func FetchCommentForNotify(n *entity.Notify) entity.Comment {
+ if n.Comment.IsEmpty() {
+ n.Once_Comment.Do(func() {
+ comment := FindComment(n.CommentID)
+ n.Comment = comment
+ })
+ }
+
+ return n.Comment
+}
+
+// 获取接收通知的用户
+func FetchUserForNotify(n *entity.Notify) entity.User {
+ return FindUserByID(n.UserID)
+}
diff --git a/internal/query/cook.go b/internal/query/cook.go
new file mode 100644
index 000000000..ae9a5f82b
--- /dev/null
+++ b/internal/query/cook.go
@@ -0,0 +1,232 @@
+package query
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+)
+
+// ===============
+// Comment
+// ===============
+
+func CookComment(c *entity.Comment) entity.CookedComment {
+ user := FetchUserForComment(c)
+ page := FetchPageForComment(c)
+
+ markedContent, _ := utils.Marked(c.Content)
+
+ return entity.CookedComment{
+ ID: c.ID,
+ Content: c.Content,
+ ContentMarked: markedContent,
+ UserID: c.UserID,
+ Nick: user.Name,
+ EmailEncrypted: utils.GetMD5Hash(user.Email),
+ Link: user.Link,
+ UA: c.UA,
+ Date: c.CreatedAt.Local().Format("2006-01-02 15:04:05"),
+ IsCollapsed: c.IsCollapsed,
+ IsPending: c.IsPending,
+ IsPinned: c.IsPinned,
+ IsAllowReply: c.IsAllowReply(),
+ Rid: c.Rid,
+ BadgeName: user.BadgeName,
+ BadgeColor: user.BadgeColor,
+ Visible: true,
+ VoteUp: c.VoteUp,
+ VoteDown: c.VoteDown,
+ PageKey: c.PageKey,
+ PageURL: GetPageAccessibleURL(&page),
+ SiteName: c.SiteName,
+ }
+}
+
+func CookAllComments(comments []entity.Comment) []entity.CookedComment {
+ cookedComments := []entity.CookedComment{}
+ for _, c := range comments {
+ cookedComments = append(cookedComments, CookComment(&c))
+ }
+ return cookedComments
+}
+
+func CookCommentForEmail(c *entity.Comment) entity.CookedCommentForEmail {
+ user := FetchUserForComment(c)
+ page := FetchPageForComment(c)
+ site := FetchSiteForComment(c)
+ content, _ := utils.Marked(c.Content)
+
+ return entity.CookedCommentForEmail{
+ Content: content,
+ ContentRaw: c.Content,
+ Nick: user.Name,
+ Email: user.Email,
+ IP: c.IP,
+ Datetime: c.CreatedAt.Local().Format("2006-01-02 15:04:05"),
+ Date: c.CreatedAt.Local().Format("2006-01-02"),
+ Time: c.CreatedAt.Local().Format("15:04:05"),
+ PageKey: c.PageKey,
+ PageTitle: page.Title,
+ Page: CookPage(&page),
+ SiteName: c.SiteName,
+ Site: CookSite(&site),
+ CookedComment: entity.CookedComment{
+ ID: c.ID,
+ EmailEncrypted: utils.GetMD5Hash(user.Email),
+ Link: user.Link,
+ UA: c.UA,
+ IsCollapsed: c.IsCollapsed,
+ IsPending: c.IsPending,
+ IsPinned: c.IsPinned,
+ IsAllowReply: c.IsAllowReply(),
+ Rid: c.Rid,
+ BadgeName: user.BadgeName,
+ BadgeColor: user.BadgeColor,
+ },
+ }
+}
+
+func CommentToArtran(c *entity.Comment) entity.Artran {
+ user := FetchUserForComment(c)
+ page := FetchPageForComment(c)
+ site := FetchSiteForComment(c)
+
+ return entity.Artran{
+ ID: utils.ToString(c.ID),
+ Rid: utils.ToString(c.Rid),
+ Content: c.Content,
+ UA: c.UA,
+ IP: c.IP,
+ IsCollapsed: utils.ToString(c.IsCollapsed),
+ IsPending: utils.ToString(c.IsPending),
+ IsPinned: utils.ToString(c.IsPinned),
+ VoteUp: utils.ToString(c.VoteUp),
+ VoteDown: utils.ToString(c.VoteDown),
+ CreatedAt: c.CreatedAt.String(),
+ UpdatedAt: c.UpdatedAt.String(),
+ Nick: user.Name,
+ Email: user.Email,
+ Link: user.Link,
+ BadgeName: user.BadgeName,
+ BadgeColor: user.BadgeColor,
+ PageKey: page.Key,
+ PageTitle: page.Title,
+ PageAdminOnly: utils.ToString(page.AdminOnly),
+ SiteName: site.Name,
+ SiteUrls: site.Urls,
+ }
+}
+
+// ===============
+// Page
+// ===============
+
+func CookPage(p *entity.Page) entity.CookedPage {
+ return entity.CookedPage{
+ ID: p.ID,
+ AdminOnly: p.AdminOnly,
+ Key: p.Key,
+ URL: GetPageAccessibleURL(p),
+ Title: p.Title,
+ SiteName: p.SiteName,
+ VoteUp: p.VoteUp,
+ VoteDown: p.VoteDown,
+ PV: p.PV,
+ }
+}
+
+func CookAllPages(pages []entity.Page) []entity.CookedPage {
+ cookedPages := []entity.CookedPage{}
+ for _, p := range pages {
+ cookedPages = append(cookedPages, CookPage(&p))
+ }
+ return cookedPages
+}
+
+// ===============
+// Site
+// ===============
+
+func CookSite(s *entity.Site) entity.CookedSite {
+ splitUrls := utils.SplitAndTrimSpace(s.Urls, ",")
+ firstUrl := ""
+ if len(splitUrls) > 0 {
+ firstUrl = splitUrls[0]
+ }
+
+ return entity.CookedSite{
+ ID: s.ID,
+ Name: s.Name,
+ Urls: splitUrls,
+ UrlsRaw: s.Urls,
+ FirstUrl: firstUrl,
+ }
+}
+
+func FindAllSitesCooked() []entity.CookedSite {
+ sites := FindAllSites()
+
+ var cookedSites []entity.CookedSite
+ for _, s := range sites {
+ cookedSites = append(cookedSites, CookSite(&s))
+ }
+
+ return cookedSites
+}
+
+// ===============
+// User
+// ===============
+
+func CookUser(u *entity.User) entity.CookedUser {
+ splitSites := utils.SplitAndTrimSpace(u.SiteNames, ",")
+
+ return entity.CookedUser{
+ ID: u.ID,
+ Name: u.Name,
+ Email: u.Email,
+ Link: u.Link,
+ BadgeName: u.BadgeName,
+ BadgeColor: u.BadgeColor,
+ IsAdmin: u.IsAdmin,
+ SiteNames: splitSites,
+ SiteNamesRaw: u.SiteNames,
+ ReceiveEmail: u.ReceiveEmail,
+ }
+}
+
+func UserToCookedForAdmin(u *entity.User) entity.CookedUserForAdmin {
+ cookedUser := CookUser(u)
+ var commentCount int64
+ DB().Model(&entity.Comment{}).Where("user_id = ?", u.ID).Count(&commentCount)
+
+ return entity.CookedUserForAdmin{
+ CookedUser: cookedUser,
+ LastIP: u.LastIP,
+ LastUA: u.LastUA,
+ IsInConf: u.IsInConf,
+ CommentCount: commentCount,
+ }
+}
+
+// ===============
+// Notify
+// ===============
+
+func CookNotify(n *entity.Notify) entity.CookedNotify {
+ return entity.CookedNotify{
+ ID: n.ID,
+ UserID: n.UserID,
+ CommentID: n.CommentID,
+ IsRead: n.IsRead,
+ IsEmailed: n.IsEmailed,
+ ReadLink: GetReadLinkByNotify(n),
+ }
+}
+
+func CookAllNotifies(notifies []entity.Notify) []entity.CookedNotify {
+ cookedNotifies := []entity.CookedNotify{}
+ for _, n := range notifies {
+ cookedNotifies = append(cookedNotifies, CookNotify(&n))
+ }
+ return cookedNotifies
+}
diff --git a/internal/query/db.go b/internal/query/db.go
new file mode 100644
index 000000000..eb9920eb1
--- /dev/null
+++ b/internal/query/db.go
@@ -0,0 +1,10 @@
+package query
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "gorm.io/gorm"
+)
+
+func DB() *gorm.DB {
+ return db.DB()
+}
diff --git a/internal/query/db_test.go b/internal/query/db_test.go
new file mode 100644
index 000000000..6a8939f28
--- /dev/null
+++ b/internal/query/db_test.go
@@ -0,0 +1,59 @@
+package query
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/go-testfixtures/testfixtures/v3"
+)
+
+var fixtures *testfixtures.Loader
+
+func TestMain(m *testing.M) {
+ var err error
+
+ // 加载测试配置
+ config.Init("./testdata/model_test_conf.yml")
+
+ // 初始化测试数据库
+ dbFilename := "../../data/test.db"
+ utils.EnsureDir(filepath.Dir(dbFilename))
+ dbInstance, err := gorm.Open(sqlite.Open(dbFilename), &gorm.Config{
+ Logger: db.NewGormLogger(),
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ db.SetDB(dbInstance)
+ db.MigrateModels()
+
+ sqlDB, err := dbInstance.DB()
+ if err != nil {
+ panic(err)
+ }
+
+ fixtures, err = testfixtures.New(
+ testfixtures.Database(sqlDB), // You database connection
+ testfixtures.Dialect("sqlite"), // Available: "postgresql", "timescaledb", "mysql", "mariadb", "sqlite" and "sqlserver"
+ testfixtures.Directory("fixtures"), // The directory containing the YAML files
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ os.Exit(m.Run())
+}
+
+func reloadTestDatabase() {
+ if err := fixtures.Load(); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/query/fixtures/comments.yml b/internal/query/fixtures/comments.yml
new file mode 100644
index 000000000..c30c7c2ee
--- /dev/null
+++ b/internal/query/fixtures/comments.yml
@@ -0,0 +1,344 @@
+- id: 1000
+ rid: 0
+ content: "Hello Artalk, 你好 Artalk!"
+ page_key: /test/1000.html
+ site_name: Site A
+ user_id: 1000
+ ua: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36
+ ip: 10.90.1.1
+ created_at: 2022-04-29 14:44:04
+ updated_at: 2022-04-29 14:44:04
+
+- id: 1001
+ rid: 1000
+ content: "回复测试 1001"
+ page_key: /test/1000.html
+ site_name: Site A
+ user_id: 1001
+ ua: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
+ ip: 10.90.2.101
+ created_at: 2022-04-29 14:44:13
+ updated_at: 2022-04-29 14:44:13
+
+- id: 1002
+ rid: 1001
+ content: "测试二层嵌套回复 A"
+ page_key: /test/1000.html
+ site_name: Site A
+ user_id: 1001
+ ua: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
+ ip: 10.90.2.101
+ created_at: 2022-04-29 16:50:03
+ updated_at: 2022-04-29 16:50:03
+
+- id: 1003
+ rid: 1001
+ content: "测试二层嵌套回复 B"
+ page_key: /test/1000.html
+ site_name: Site A
+ user_id: 1001
+ ua: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
+ ip: 10.90.2.101
+ created_at: 2022-04-29 16:52:37
+ updated_at: 2022-04-29 16:52:37
+
+- id: 1004
+ rid: 1002
+ content: "测试第三层嵌套回复"
+ page_key: /test/1000.html
+ site_name: Site A
+ user_id: 1001
+ ua: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
+ ip: 10.90.2.101
+ created_at: 2022-04-29 16:50:15
+ updated_at: 2022-04-29 16:50:15
+
+- id: 1005
+ rid: 0
+ content: "「我愛花,我愛詩歌…薇鷗萊特這個名字正是從我最喜歡的薔薇、砂糖與紫羅蘭的詩中得來的。」\n\n《紫羅蘭永恆花園》原作者曉佳奈在小說下卷的前言中這樣寫道。而紫羅蘭的花語是「忠貞不渝的愛情」,正是「永恆」。另外,故事中其他角色的姓名也有許多源於花卉:\n\n - **吉爾伯特** 少佐的姓 **布甘比利亞** 來自三角梅(Bougainvillea),寓意是「熱忱、熱戀」。\n - **嘉德麗雅** 這個名字源自蘭科中的嘉德麗雅蘭(Cattleya),象徵「成熟女性的魅力」。\n - **埃麗卡** 這個名字英語又有歐石楠(Erica)的意思,花語是「孤獨、愛的幸福」。\n - **艾麗絲** 的名字來自希臘神話,同時也指鳶尾花(Iris),花語是「等待愛情」。\n - **莉莉安**(Lilian)這個名字源自拉丁語的百合(Lilium),其花語是「純潔、偉大的愛」。\n - **娜麗** 這個名字源自娜麗花(Nerine),花語是「期待再次見面」。\n - **璐琪莉亞** 這個名字出自法語滇丁香(Luculia),花語是「端莊、優雅的人」。\n - **絡丹瑟** 這個名字是源自希臘語的鱗托菊(Rhodanthe),花語是「永恆的感情」。\n - **布魯貝露** 這個名字英語意為風鈴草(Bluebell),花語是「亙古不變的心」。\n - **依貝莉斯** 這個名字源自希臘語屈曲花(Iberis),花語是「心靈誘惑」。\n - **利昂** 的姓 **斯蒂法諾蒂斯** 出自馬達加斯加茉莉的學名(Stephanotis floribunda),花語是「共赴悠遠旅途、傾聽」。\n - **安** 和她媽媽 **克拉拉** 的姓 **瑪格諾利亞** 出自法語木蘭(Magnolia),其花語是「崇高」。\n\n可以說,這是一部花與愛的故事。\n\n> Roses are red. Violets are blue. Sugar is sweet. And so are you.\n> 玫瑰如此嫣紅,紫羅蘭如此綺麗,砂糖如此甜蜜,正如我心愛的你。"
+ page_key: /test/1000.html
+ site_name: Site A
+ user_id: 1002
+ ua: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1
+ ip: 10.90.2.102
+ created_at: 2022-04-29 14:44:59
+ updated_at: 2022-04-29 14:44:59
+
+- id: 1006
+ rid: 0
+ content: "在测试站点 2 的评论"
+ page_key: /site_b/1001.html
+ site_name: Site B
+ user_id: 1001
+ ua: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
+ ip: 10.90.2.101
+ created_at: 2022-04-29 14:46:43
+ updated_at: 2022-04-29 14:46:43
+
+# ---------- 分页测试 ----------
+# str = ""
+# for (let i = 10; i <= 60; i++) {
+# str += `
+# - id: 10${i}
+# content: "评论 10${i}"
+# page_key: /test_pagination.html
+# site_name: Site A
+# user_id: 1001`
+# }
+# console.log(str)
+- id: 1010
+ content: "评论 1010"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1011
+ content: "评论 1011"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1012
+ content: "评论 1012"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1013
+ content: "评论 1013"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1014
+ content: "评论 1014"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1015
+ content: "评论 1015"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1016
+ content: "评论 1016"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1017
+ content: "评论 1017"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1018
+ content: "评论 1018"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1019
+ content: "评论 1019"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1020
+ content: "评论 1020"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1021
+ content: "评论 1021"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1022
+ content: "评论 1022"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1023
+ content: "评论 1023"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1024
+ content: "评论 1024"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1025
+ content: "评论 1025"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1026
+ content: "评论 1026"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1027
+ content: "评论 1027"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1028
+ content: "评论 1028"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1029
+ content: "评论 1029"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1030
+ content: "评论 1030"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1031
+ content: "评论 1031"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1032
+ content: "评论 1032"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1033
+ content: "评论 1033"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1034
+ content: "评论 1034"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1035
+ content: "评论 1035"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1036
+ content: "评论 1036"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1037
+ content: "评论 1037"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1038
+ content: "评论 1038"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1039
+ content: "评论 1039"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1040
+ content: "评论 1040"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1041
+ content: "评论 1041"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1042
+ content: "评论 1042"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1043
+ content: "评论 1043"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1044
+ content: "评论 1044"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1045
+ content: "评论 1045"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1046
+ content: "评论 1046"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1047
+ content: "评论 1047"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1048
+ content: "评论 1048"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1049
+ content: "评论 1049"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1050
+ content: "评论 1050"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1051
+ content: "评论 1051"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1052
+ content: "评论 1052"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1053
+ content: "评论 1053"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1054
+ content: "评论 1054"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1055
+ content: "评论 1055"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1056
+ content: "评论 1056"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1057
+ content: "评论 1057"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1058
+ content: "评论 1058"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1059
+ content: "评论 1059"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+- id: 1060
+ content: "评论 1060"
+ page_key: /test_pagination.html
+ site_name: Site A
+ user_id: 1001
+# -----------------------------
diff --git a/internal/query/fixtures/pages.yml b/internal/query/fixtures/pages.yml
new file mode 100644
index 000000000..9be646392
--- /dev/null
+++ b/internal/query/fixtures/pages.yml
@@ -0,0 +1,23 @@
+- id: 1000
+ key: /test/1000.html
+ title: 测试页面标题 1000
+ admin_only: false
+ site_name: Site A
+ created_at: 2022-04-29 14:41:37
+ updated_at: 2022-04-29 14:41:37
+
+- id: 1001
+ key: /site_b/1001.html
+ title: 测试页面标题 1001
+ admin_only: true
+ site_name: Site B
+ created_at: 2022-04-29 14:45:44
+ updated_at: 2022-04-29 14:45:44
+
+- id: 1002
+ key: /test_pagination.html
+ title: 分页测试页面
+ admin_only: false
+ site_name: Site A
+ created_at: 2022-04-29 14:47:59
+ updated_at: 2022-04-29 14:47:59
\ No newline at end of file
diff --git a/internal/query/fixtures/sites.yml b/internal/query/fixtures/sites.yml
new file mode 100644
index 000000000..590ae159f
--- /dev/null
+++ b/internal/query/fixtures/sites.yml
@@ -0,0 +1,11 @@
+- id: 1000
+ name: Site A
+ urls: http://localhost:8080/,https://qwqaq.com
+ created_at: 2022-04-29 14:38:40
+ updated_at: 2022-04-29 14:38:40
+
+- id: 1001
+ name: Site B
+ urls: http://artalk.js.org/sub_folder/,http://localhost:8081
+ created_at: 2022-04-29 14:38:40
+ updated_at: 2022-04-29 14:38:40
diff --git a/internal/query/fixtures/users.yml b/internal/query/fixtures/users.yml
new file mode 100644
index 000000000..75657a9a3
--- /dev/null
+++ b/internal/query/fixtures/users.yml
@@ -0,0 +1,33 @@
+- id: 1000
+ name: admin
+ email: admin@qwqaq.com
+ link: https://qwqaq.com
+ password: 123456
+ badge_name: 管理员
+ badge_color: #fff
+ last_ip: 10.90.1.1
+ last_ua: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36
+ is_admin: true
+ is_in_conf: true
+ site_names:
+ receive_email: true
+ created_at: 2022-04-29 14:31:53
+ updated_at: 2022-04-29 14:31:53
+
+- id: 1001
+ name: userA
+ email: user_a@qwqaq.com
+ link: https://example.org/?user=A
+ last_ip: 10.90.2.101
+ last_ua: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
+ created_at: 2022-04-29 14:36:47
+ updated_at: 2022-04-29 14:36:47
+
+- id: 1002
+ name: userB
+ email: user_b@qwqaq.com
+ link: https://example.org/?user=B
+ last_ip: 10.90.2.102
+ last_ua: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1
+ created_at: 2022-04-29 14:36:47
+ updated_at: 2022-04-29 14:36:47
diff --git a/internal/query/query.go b/internal/query/query.go
new file mode 100644
index 000000000..111ecddf5
--- /dev/null
+++ b/internal/query/query.go
@@ -0,0 +1,9 @@
+package query
+
+import "github.com/ArtalkJS/Artalk/internal/entity"
+
+func GetUserAllCommentIDs(userID uint) []uint {
+ userAllCommentIDs := []uint{}
+ DB().Model(&entity.Comment{}).Select("id").Where("user_id = ?", userID).Find(&userAllCommentIDs)
+ return userAllCommentIDs
+}
diff --git a/internal/query/query_del.go b/internal/query/query_del.go
new file mode 100644
index 000000000..0e2fce368
--- /dev/null
+++ b/internal/query/query_del.go
@@ -0,0 +1,114 @@
+package query
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+)
+
+func DelComment(comment *entity.Comment) error {
+ // 清除 notify
+ if err := DB().Unscoped().Where("comment_id = ?", comment.ID).Delete(&entity.Notify{}).Error; err != nil {
+ return err
+ }
+
+ // 清除 vote
+ if err := DB().Unscoped().Where(
+ "target_id = ? AND (type = ? OR type = ?)",
+ comment.ID,
+ string(entity.VoteTypeCommentUp),
+ string(entity.VoteTypeCommentDown),
+ ).Delete(&entity.Vote{}).Error; err != nil {
+ return err
+ }
+
+ // 删除 comment
+ err := DB().Unscoped().Delete(comment).Error
+ if err != nil {
+ return err
+ }
+
+ // 删除缓存
+ cache.CommentCacheDel(comment)
+
+ return nil
+}
+
+// 删除所有子评论
+func DelCommentChildren(parentID uint) error {
+ var rErr error
+ children := FindCommentChildren(parentID)
+ for _, c := range children {
+ err := DelComment(&c)
+ if err != nil {
+ rErr = err
+ }
+ }
+ return rErr
+}
+
+func DelPage(page *entity.Page) error {
+ err := DB().Unscoped().Delete(page).Error
+ if err != nil {
+ return err
+ }
+
+ // 删除所有相关内容
+ var comments []entity.Comment
+ DB().Where("page_key = ? AND site_name = ?", page.Key, page.SiteName).Find(&comments)
+
+ for _, c := range comments {
+ DelComment(&c)
+ }
+
+ // 删除 vote
+ DB().Unscoped().Where(
+ "target_id = ? AND (type = ? OR type = ?)",
+ page.ID,
+ string(entity.VoteTypePageUp),
+ string(entity.VoteTypePageDown),
+ ).Delete(&entity.Vote{})
+
+ // 删除缓存
+ cache.PageCacheDel(page)
+
+ return nil
+}
+
+func DelSite(site *entity.Site) error {
+ err := DB().Unscoped().Delete(&site).Error
+ if err != nil {
+ return err
+ }
+
+ // 删除所有相关内容
+ var pages []entity.Page
+ DB().Where("site_name = ?", site.Name).Find(&pages)
+ for _, p := range pages {
+ DelPage(&p)
+ }
+
+ // 删除缓存
+ cache.SiteCacheDel(site)
+
+ return nil
+}
+
+func DelUser(user *entity.User) error {
+ err := DB().Unscoped().Delete(&user).Error
+ if err != nil {
+ return err
+ }
+
+ // 删除所有相关内容
+ var comments []entity.Comment
+ DB().Where("user_id = ?", user.ID).Find(&comments)
+ for _, c := range comments {
+ DelComment(&c) // 删除主评论
+ DelCommentChildren(c.ID) // 删除子评论
+ }
+
+ // 删除缓存
+ cache.UserCacheDel(user)
+
+ return nil
+}
diff --git a/internal/query/query_del_test.go b/internal/query/query_del_test.go
new file mode 100644
index 000000000..c9b760b12
--- /dev/null
+++ b/internal/query/query_del_test.go
@@ -0,0 +1,68 @@
+package query
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDelComment(t *testing.T) {
+ reloadTestDatabase()
+
+ comment := FindComment(1000)
+ assert.False(t, comment.IsEmpty(), "评论找不到")
+
+ err := DelComment(&comment)
+ assert.NoError(t, err, "评论删除错误")
+
+ assert.True(t, FindComment(1000).IsEmpty(), "评论没有删成功")
+}
+
+func TestDelCommentChildren(t *testing.T) {
+ reloadTestDatabase()
+
+ parentID := uint(1000)
+ err := DelCommentChildren(parentID)
+ assert.NoError(t, err, "评论删除错误")
+
+ assert.True(t, FindComment(1004).IsEmpty(), "子评论没有删干净")
+}
+
+func TestDelPage(t *testing.T) {
+ reloadTestDatabase()
+
+ page := FindPageByID(1000)
+ assert.False(t, page.IsEmpty(), "页面找不到")
+
+ err := DelPage(&page)
+ assert.NoError(t, err, "页面删除发生错误")
+
+ assert.True(t, FindPageByID(1000).IsEmpty(), "页面没有删成功")
+ assert.True(t, FindComment(1004).IsEmpty(), "页面评论没有删干净")
+}
+
+func TestDelSite(t *testing.T) {
+ reloadTestDatabase()
+
+ site := FindSiteByID(1000)
+ assert.False(t, site.IsEmpty(), "站点找不到")
+
+ err := DelSite(&site)
+ assert.NoError(t, err, "站点删除发生错误")
+
+ assert.True(t, FindSiteByID(1000).IsEmpty(), "站点没有删成功")
+ assert.True(t, FindPageByID(1000).IsEmpty(), "站点页面没有删干净")
+ assert.True(t, FindComment(1004).IsEmpty(), "站点评论没有删干净")
+}
+
+func TestDelUser(t *testing.T) {
+ reloadTestDatabase()
+
+ user := FindUserByID(1000)
+ assert.False(t, user.IsEmpty(), "用户找不到")
+
+ err := DelUser(&user)
+ assert.NoError(t, err, "用户删除发生错误")
+
+ assert.True(t, FindUserByID(1000).IsEmpty(), "用户没有删成功")
+}
diff --git a/internal/query/query_find.go b/internal/query/query_find.go
new file mode 100644
index 000000000..236980e03
--- /dev/null
+++ b/internal/query/query_find.go
@@ -0,0 +1,268 @@
+package query
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+)
+
+func FindComment(id uint, checkers ...func(*entity.Comment) bool) entity.Comment {
+ var comment entity.Comment
+
+ cache.FindAndStoreCache(fmt.Sprintf("comment#id=%d", id), &comment, func() interface{} {
+ DB().Where("id = ?", id).First(&comment)
+ return &comment
+ })
+
+ // the case with checkers
+ for _, c := range checkers {
+ if !c(&comment) {
+ return entity.Comment{}
+ }
+ }
+
+ return comment
+}
+
+// (Cached:parent-comments)
+func FindCommentChildrenShallow(parentID uint, checkers ...func(*entity.Comment) bool) []entity.Comment {
+ var children []entity.Comment
+ var childIDs []uint
+
+ cache.FindAndStoreCache(fmt.Sprintf("parent-comments#pid=%d", parentID), &childIDs, func() interface{} {
+ DB().Model(&entity.Comment{}).Where(&entity.Comment{Rid: parentID}).Select("id").Find(&childIDs)
+ return &childIDs
+ })
+
+ for _, childID := range childIDs {
+ child := FindComment(childID, checkers...)
+ if !child.IsEmpty() {
+ children = append(children, child)
+ }
+ }
+
+ return children
+}
+
+func FindCommentChildren(parentID uint, checkers ...func(*entity.Comment) bool) []entity.Comment {
+ allChildren := []entity.Comment{}
+ _findCommentChildrenOnce(&allChildren, parentID, checkers...) // TODO: children 数量限制
+ return allChildren
+}
+
+func _findCommentChildrenOnce(source *[]entity.Comment, parentID uint, checkers ...func(*entity.Comment) bool) {
+ // TODO 子评论排序问题
+ children := FindCommentChildrenShallow(parentID, checkers...)
+
+ for _, child := range children {
+ *source = append(*source, child)
+ _findCommentChildrenOnce(source, child.ID, checkers...) // recurse
+ }
+}
+
+// 查找用户 (精确查找 name & email)
+func FindUser(name string, email string) entity.User {
+ var user entity.User
+
+ // 查询缓存
+ cache.FindAndStoreCache(fmt.Sprintf("user#name=%s;email=%s", strings.ToLower(name), strings.ToLower(email)), &user, func() interface{} {
+ // 不区分大小写
+ DB().Where("LOWER(name) = LOWER(?) AND LOWER(email) = LOWER(?)", name, email).First(&user)
+ return &user
+ })
+
+ return user
+}
+
+// 查找用户 ID (仅根据 email)
+func FindUserIdsByEmail(email string) []uint {
+ var userIds = []uint{}
+
+ // 查询缓存
+ cache.FindAndStoreCache(fmt.Sprintf("user_id#email=%s", strings.ToLower(email)), &userIds, func() interface{} {
+ DB().Model(&entity.User{}).Where("LOWER(email) = LOWER(?)", email).Pluck("id", &userIds)
+
+ return &userIds
+ })
+
+ return userIds
+}
+
+// 查找用户 (仅根据 email)
+func FindUsersByEmail(email string) []entity.User {
+ userIds := FindUserIdsByEmail(email)
+
+ users := []entity.User{}
+ for _, id := range userIds {
+ users = append(users, FindUserByID(id))
+ }
+
+ return users
+}
+
+// 查找用户 (通过 ID)
+func FindUserByID(id uint) entity.User {
+ var user entity.User
+
+ // 查询缓存
+ cache.FindAndStoreCache(fmt.Sprintf("user#id=%d", id), &user, func() interface{} {
+ DB().Where("id = ?", id).First(&user)
+ return &user
+ })
+
+ return user
+}
+
+func FindPage(key string, siteName string) entity.Page {
+ var page entity.Page
+
+ cache.FindAndStoreCache(fmt.Sprintf("page#key=%s;site_name=%s", key, siteName), &page, func() interface{} {
+ DB().Where(&entity.Page{Key: key, SiteName: siteName}).First(&page)
+ return &page
+ })
+
+ return page
+}
+
+func FindPageByID(id uint) entity.Page {
+ var page entity.Page
+
+ cache.FindAndStoreCache(fmt.Sprintf("page#id=%d", id), &page, func() interface{} {
+ DB().Where("id = ?", id).First(&page)
+ return &page
+ })
+
+ return page
+}
+
+func FindSite(name string) entity.Site {
+ var site entity.Site
+
+ // 查询缓存
+ cache.FindAndStoreCache(fmt.Sprintf("site#name=%s", name), &site, func() interface{} {
+ DB().Where("name = ?", name).First(&site)
+ return &site
+ })
+
+ return site
+}
+
+func FindSiteByID(id uint) entity.Site {
+ var site entity.Site
+
+ cache.FindAndStoreCache(fmt.Sprintf("site#id=%d", id), &site, func() interface{} {
+ DB().Where("id = ?", id).First(&site)
+ return &site
+ })
+
+ return site
+}
+
+func FindAllSites() []entity.Site {
+ var sites []entity.Site
+ DB().Model(&entity.Site{}).Find(&sites)
+
+ return sites
+}
+
+// #region Notify
+func FindNotify(userID uint, commentID uint) entity.Notify {
+ var notify entity.Notify
+ DB().Where("user_id = ? AND comment_id = ?", userID, commentID).First(¬ify)
+ return notify
+}
+
+func FindNotifyByKey(key string) entity.Notify {
+ var notify entity.Notify
+ DB().Where(entity.Notify{Key: key}).First(¬ify)
+ return notify
+}
+
+func FindUnreadNotifies(userID uint) []entity.Notify {
+ if userID == 0 {
+ return []entity.Notify{}
+ }
+
+ var notifies []entity.Notify
+ DB().Where("user_id = ? AND is_read = ?", userID, false).Find(¬ifies)
+
+ return notifies
+}
+
+func FindNotifyParentComment(n *entity.Notify) entity.Comment {
+ comment := FetchCommentForNotify(n)
+ if comment.Rid == 0 {
+ return entity.Comment{}
+ }
+
+ return FindComment(comment.Rid)
+}
+
+//#endregion
+
+// #region Vote
+func GetVoteNum(targetID uint, voteType string) int {
+ var num int64
+ DB().Model(&entity.Vote{}).Where("target_id = ? AND type = ?", targetID, voteType).Count(&num)
+ return int(num)
+}
+
+func GetVoteNumUpDown(targetID uint, voteTo string) (int, int) {
+ var up int64
+ var down int64
+ DB().Model(&entity.Vote{}).Where("target_id = ? AND type = ?", targetID, voteTo+"_up").Count(&up)
+ DB().Model(&entity.Vote{}).Where("target_id = ? AND type = ?", targetID, voteTo+"_down").Count(&down)
+ return int(up), int(down)
+}
+
+//#endregion
+
+// #region 管理员账号检测
+var allAdmins *[]entity.User = nil
+
+func GetAllAdmins() []entity.User {
+ if allAdmins == nil {
+ var admins []entity.User
+ DB().Where(&entity.User{IsAdmin: true}).Find(&admins)
+ allAdmins = &admins
+ }
+
+ return *allAdmins
+}
+
+func GetAllAdminIDs() []uint {
+ admins := GetAllAdmins()
+ ids := []uint{}
+ for _, a := range admins {
+ ids = append(ids, a.ID)
+ }
+ return ids
+}
+
+func IsAdminUser(userID uint) bool {
+ admins := GetAllAdmins()
+ for _, admin := range admins {
+ if admin.ID == userID {
+ return true
+ }
+ }
+
+ return false
+}
+
+func IsAdminUserByNameEmail(name string, email string) bool {
+ admins := GetAllAdmins()
+ for _, admin := range admins {
+ // Name 和 Email 都匹配才是管理员
+ if strings.EqualFold(admin.Name, name) &&
+ strings.EqualFold(admin.Email, email) {
+ return true
+ }
+ }
+
+ return false
+}
+
+//#endregion
diff --git a/internal/query/query_find_create.go b/internal/query/query_find_create.go
new file mode 100644
index 000000000..6a238a7de
--- /dev/null
+++ b/internal/query/query_find_create.go
@@ -0,0 +1,37 @@
+package query
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/entity"
+)
+
+func FindCreateSite(siteName string) entity.Site {
+ site := FindSite(siteName)
+ if site.IsEmpty() {
+ site = NewSite(siteName, "")
+ }
+ return site
+}
+
+func FindCreatePage(pageKey string, pageTitle string, siteName string) entity.Page {
+ page := FindPage(pageKey, siteName)
+ if page.IsEmpty() {
+ page = NewPage(pageKey, pageTitle, siteName)
+ }
+ return page
+}
+
+func FindCreateUser(name string, email string, link string) entity.User {
+ user := FindUser(name, email)
+ if user.IsEmpty() {
+ user = NewUser(name, email, link) // save a new user
+ }
+ return user
+}
+
+func FindCreateNotify(userID uint, lookCommentID uint) entity.Notify {
+ notify := FindNotify(userID, lookCommentID)
+ if notify.IsEmpty() {
+ notify = NewNotify(userID, lookCommentID)
+ }
+ return notify
+}
diff --git a/internal/query/query_find_create_test.go b/internal/query/query_find_create_test.go
new file mode 100644
index 000000000..b66eb35df
--- /dev/null
+++ b/internal/query/query_find_create_test.go
@@ -0,0 +1,85 @@
+package query
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFindCreateSite(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Create New Site", func(t *testing.T) {
+ siteName := "TestCreateNewSite"
+
+ result := FindCreateSite(siteName)
+ assert.False(t, result.IsEmpty(), "直接获取创建后的站点数据有问题")
+ assert.Equal(t, siteName, result.Name)
+
+ findSite := FindSite(siteName)
+ assert.False(t, findSite.IsEmpty(), "找不到创建后的站点")
+ assert.Equal(t, CookSite(&result), CookSite(&findSite), "创建后的站点数据有问题")
+ })
+
+ t.Run("Find Existed Site", func(t *testing.T) {
+ result := FindCreateSite("Site A")
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, "http://localhost:8080/,https://qwqaq.com", result.Urls)
+ })
+}
+
+func TestFindCreatePage(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Create New Page", func(t *testing.T) {
+ var (
+ pageKey = "/NewPage.html"
+ pageTitle = "New Page Title"
+ siteName = "Site A"
+ )
+
+ result := FindCreatePage(pageKey, pageTitle, siteName)
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, pageKey, result.Key)
+ assert.Equal(t, pageTitle, result.Title)
+ assert.Equal(t, siteName, result.SiteName)
+
+ findPage := FindPage(pageKey, siteName)
+ assert.False(t, findPage.IsEmpty(), "找不到创建后的页面")
+ assert.Equal(t, CookPage(&result), CookPage(&findPage), "创建后的页面数据有问题")
+ })
+
+ t.Run("Find Existed Page", func(t *testing.T) {
+ result := FindCreatePage("/test/1000.html", "", "Site A")
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, FindPage("/test/1000.html", "Site A"), result)
+ })
+}
+
+func TestFindCreateUser(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Create New User", func(t *testing.T) {
+ var (
+ userName = "NewUser"
+ userEmail = "NewUser@gmail.com"
+ userLink = "https://qwqaq.com"
+ )
+
+ result := FindCreateUser(userName, userEmail, userLink)
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, userName, result.Name)
+ assert.Equal(t, userEmail, result.Email)
+ assert.Equal(t, userLink, result.Link)
+
+ findUser := FindUser(userName, userEmail)
+ assert.False(t, findUser.IsEmpty(), "找不到创建后的用户")
+ assert.Equal(t, CookUser(&result), CookUser(&findUser), "创建后的用户数据有问题")
+ })
+
+ t.Run("Find Existed User", func(t *testing.T) {
+ result := FindCreateUser("userA", "user_a@qwqaq.com", "")
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, FindUser("userA", "user_a@qwqaq.com"), result)
+ })
+}
diff --git a/internal/query/query_find_test.go b/internal/query/query_find_test.go
new file mode 100644
index 000000000..f60f8aa09
--- /dev/null
+++ b/internal/query/query_find_test.go
@@ -0,0 +1,209 @@
+package query
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFindComment(t *testing.T) {
+ reloadTestDatabase()
+
+ type args struct {
+ id uint
+ }
+ type wants struct {
+ id uint
+ rid uint
+ user_id uint
+ page_key string
+ site_name string
+ }
+ tests := []struct {
+ name string
+ args args
+ wants wants
+ }{
+ {name: "评论 ID=1000", args: args{id: 1000}, wants: wants{id: 1000, rid: 0, user_id: 1000, page_key: "/test/1000.html", site_name: "Site A"}},
+ {name: "评论 ID=1001", args: args{id: 1001}, wants: wants{id: 1001, rid: 1000, user_id: 1001, page_key: "/test/1000.html", site_name: "Site A"}},
+ {name: "评论 ID=1006", args: args{id: 1006}, wants: wants{id: 1006, rid: 0, user_id: 1001, page_key: "/site_b/1001.html", site_name: "Site B"}},
+ {name: "不存在的评论", args: args{id: 9999}, wants: wants{id: 0, rid: 0, user_id: 0, page_key: "", site_name: ""}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := FindComment(tt.args.id)
+ assert.Equal(t, tt.wants.id, got.ID)
+ assert.Equal(t, tt.wants.rid, got.Rid)
+ assert.Equal(t, tt.wants.user_id, got.UserID)
+ assert.Equal(t, tt.wants.page_key, got.PageKey)
+ assert.Equal(t, tt.wants.site_name, got.SiteName)
+ if tt.name != "不存在的评论" {
+ assert.NotEmpty(t, got.Content)
+ }
+ })
+ }
+}
+
+func TestFindCommentChildrenShallow(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Children Found", func(t *testing.T) {
+ result := FindCommentChildrenShallow(1001)
+ assert.Equal(t, 2, len(result))
+ })
+
+ t.Run("No Children Found", func(t *testing.T) {
+ result := FindCommentChildrenShallow(1005)
+ assert.Empty(t, result)
+ })
+}
+
+func TestFindCommentChildren(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Children Found", func(t *testing.T) {
+ result := FindCommentChildren(1000)
+ assert.Equal(t, 4, len(result))
+ })
+
+ t.Run("No Children Found", func(t *testing.T) {
+ result := FindCommentChildren(1005)
+ assert.Empty(t, result)
+ })
+}
+
+func TestFindUser(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("User Found", func(t *testing.T) {
+ result := FindUser("admin", "admin@qwqaq.com")
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, uint(1000), result.ID)
+ assert.Equal(t, "admin", result.Name)
+ assert.Equal(t, "admin@qwqaq.com", result.Email)
+ assert.Equal(t, "123456", result.Password)
+ assert.Equal(t, true, result.IsAdmin)
+ assert.Equal(t, "", result.SiteNames)
+ assert.Equal(t, "管理员", result.BadgeName)
+ })
+
+ t.Run("User not Found", func(t *testing.T) {
+ result := FindUser("NoUser", "NoUser@example.org")
+ assert.True(t, result.IsEmpty())
+ })
+}
+
+func TestFindUserByID(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("User Found", func(t *testing.T) {
+ result := FindUserByID(1000)
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, uint(1000), result.ID)
+ assert.Equal(t, "admin", result.Name)
+ assert.Equal(t, "admin@qwqaq.com", result.Email)
+ assert.Equal(t, "123456", result.Password)
+ assert.Equal(t, true, result.IsAdmin)
+ assert.Equal(t, "", result.SiteNames)
+ assert.Equal(t, "管理员", result.BadgeName)
+ })
+
+ t.Run("User not Found", func(t *testing.T) {
+ result := FindUserByID(9999)
+ assert.True(t, result.IsEmpty())
+ })
+}
+
+func TestFindPage(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Page Found", func(t *testing.T) {
+ result := FindPage("/site_b/1001.html", "Site B")
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, uint(1001), result.ID)
+ assert.Equal(t, "测试页面标题 1001", result.Title)
+ assert.Equal(t, true, result.AdminOnly)
+ })
+
+ t.Run("Page not Found", func(t *testing.T) {
+ result := FindPage("/NotExistPage", "NotExistSite")
+ assert.True(t, result.IsEmpty())
+ })
+}
+
+func TestFindPageByID(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Page Found", func(t *testing.T) {
+ result := FindPageByID(1001)
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, uint(1001), result.ID)
+ assert.Equal(t, "测试页面标题 1001", result.Title)
+ assert.Equal(t, true, result.AdminOnly)
+ })
+
+ t.Run("Page not Found", func(t *testing.T) {
+ result := FindPageByID(9999)
+ assert.True(t, result.IsEmpty())
+ })
+}
+
+func TestFindSite(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Site Found", func(t *testing.T) {
+ result := FindSite("Site A")
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, uint(1000), result.ID)
+ assert.Equal(t, "Site A", result.Name)
+ assert.Equal(t, "http://localhost:8080/,https://qwqaq.com", result.Urls)
+ })
+
+ t.Run("Site not Found", func(t *testing.T) {
+ result := FindSite("NotExistSite")
+ assert.True(t, result.IsEmpty())
+ })
+}
+
+func TestFindSiteByID(t *testing.T) {
+ reloadTestDatabase()
+
+ t.Run("Site Found", func(t *testing.T) {
+ result := FindSiteByID(1000)
+ assert.False(t, result.IsEmpty())
+ assert.Equal(t, uint(1000), result.ID)
+ assert.Equal(t, "Site A", result.Name)
+ assert.Equal(t, "http://localhost:8080/,https://qwqaq.com", result.Urls)
+ })
+
+ t.Run("Site not Found", func(t *testing.T) {
+ result := FindSiteByID(9999)
+ assert.True(t, result.IsEmpty())
+ })
+}
+
+func TestFindAllSites(t *testing.T) {
+ reloadTestDatabase()
+
+ allSites := FindAllSites()
+ assert.GreaterOrEqual(t, len(allSites), 1)
+}
+
+func TestGetAllAdmins(t *testing.T) {
+ reloadTestDatabase()
+
+ allAdmins := GetAllAdmins()
+ assert.GreaterOrEqual(t, len(allAdmins), 1)
+}
+
+func TestIsAdminUser(t *testing.T) {
+ reloadTestDatabase()
+
+ assert.Equal(t, true, IsAdminUser(1000))
+}
+
+func TestIsAdminUserByNameEmail(t *testing.T) {
+ reloadTestDatabase()
+
+ assert.Equal(t, true, IsAdminUserByNameEmail("admin", "admin@qwqaq.com"))
+}
diff --git a/internal/query/query_new.go b/internal/query/query_new.go
new file mode 100644
index 000000000..3d1954c91
--- /dev/null
+++ b/internal/query/query_new.go
@@ -0,0 +1,133 @@
+package query
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/sirupsen/logrus"
+)
+
+func NewSite(name string, urls string) entity.Site {
+ site := entity.Site{
+ Name: name,
+ Urls: urls,
+ }
+
+ err := CreateSite(&site)
+ if err != nil {
+ logrus.Error("Create Site error: ", err)
+ }
+
+ return site
+}
+
+func CreateSite(site *entity.Site) error {
+ err := DB().Create(&site).Error
+ if err != nil {
+ return err
+ }
+
+ // 制备缓存
+ cache.SiteCacheSave(site)
+
+ return nil
+}
+
+func NewUser(name string, email string, link string) entity.User {
+ user := entity.User{
+ Name: name,
+ Email: email,
+ Link: link,
+ }
+
+ err := CreateUser(&user)
+ if err != nil {
+ logrus.Error("Create User error: ", err)
+ }
+
+ return user
+}
+
+func CreateUser(user *entity.User) error {
+ err := DB().Create(&user).Error
+ if err != nil {
+ return err
+ }
+
+ // 制备缓存
+ cache.UserCacheSave(user)
+
+ return nil
+}
+
+func NewPage(key string, pageTitle string, siteName string) entity.Page {
+ page := entity.Page{
+ Key: key,
+ Title: pageTitle,
+ SiteName: siteName,
+ }
+
+ err := CreatePage(&page)
+ if err != nil {
+ logrus.Error("Create Page error: ", err)
+ }
+
+ return page
+}
+
+func CreatePage(page *entity.Page) error {
+ err := DB().Create(&page).Error
+ if err != nil {
+ return err
+ }
+
+ // 制备缓存
+ cache.PageCacheSave(page)
+
+ return nil
+}
+
+func CreateComment(comment *entity.Comment) error {
+ err := DB().Create(&comment).Error
+ if err != nil {
+ return err
+ }
+
+ // 制备缓存
+ cache.CommentCacheSave(comment)
+
+ return nil
+}
+
+func NewNotify(userID uint, commentID uint) entity.Notify {
+ notify := entity.Notify{
+ UserID: userID,
+ CommentID: commentID,
+ IsRead: false,
+ IsEmailed: false,
+ }
+ notify.GenerateKey()
+
+ err := DB().Create(¬ify).Error
+ if err != nil {
+ logrus.Error("Create Notify error: ", err)
+ }
+
+ return notify
+}
+
+func NewVote(targetID uint, voteType entity.VoteType, userID uint, ua string, ip string) (entity.Vote, error) {
+ vote := entity.Vote{
+ TargetID: targetID,
+ Type: voteType,
+ UserID: userID,
+ UA: ua,
+ IP: ip,
+ }
+
+ err := DB().Create(&vote).Error
+ if err != nil {
+ logrus.Error("Create Vote error: ", err)
+ }
+
+ return vote, err
+}
diff --git a/internal/query/query_update.go b/internal/query/query_update.go
new file mode 100644
index 000000000..9913b6f11
--- /dev/null
+++ b/internal/query/query_update.go
@@ -0,0 +1,63 @@
+package query
+
+import (
+ "errors"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/sirupsen/logrus"
+)
+
+// 更新评论
+func UpdateComment(comment *entity.Comment) error {
+ err := DB().Save(comment).Error
+ if err != nil {
+ logrus.Error("Update Comment error: ", err)
+ }
+ // 更新缓存
+ cache.CommentCacheSave(comment)
+ return err
+}
+
+func UpdateSite(site *entity.Site) error {
+ err := DB().Save(site).Error
+ if err != nil {
+ logrus.Error("Update Site error: ", err)
+ }
+ cache.SiteCacheSave(site)
+ return err
+}
+
+func UpdateUser(user *entity.User) error {
+ err := DB().Save(user).Error
+ if err != nil {
+ logrus.Error("Update User error: ", err)
+ }
+ cache.UserCacheSave(user)
+ return err
+}
+
+func UpdatePage(page *entity.Page) error {
+ err := DB().Save(page).Error
+ if err != nil {
+ logrus.Error("Update Page error: ", err)
+ }
+ cache.PageCacheSave(page)
+ return err
+}
+
+func UserNotifyMarkAllAsRead(userID uint) error {
+ if userID == 0 {
+ return errors.New("user not found")
+ }
+
+ nowTime := time.Now()
+
+ DB().Model(&entity.Notify{}).Where("user_id = ?", userID).Updates(&entity.Notify{
+ IsRead: true,
+ ReadAt: &nowTime,
+ })
+
+ return nil
+}
diff --git a/internal/query/service.go b/internal/query/service.go
new file mode 100644
index 000000000..e94510c45
--- /dev/null
+++ b/internal/query/service.go
@@ -0,0 +1,185 @@
+package query
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/PuerkitoBio/goquery"
+ "github.com/sirupsen/logrus"
+)
+
+// ===============
+// Comment
+// ===============
+
+// 获取评论回复链接
+func GetLinkToReplyByComment(c *entity.Comment, notifyKey ...string) string {
+ page := FetchPageForComment(c)
+ rawURL := GetPageAccessibleURL(&page)
+
+ // 请求 query
+ queryMap := map[string]string{
+ "atk_comment": fmt.Sprintf("%d", c.ID),
+ }
+
+ // atk_notify_key
+ if len(notifyKey) > 0 {
+ queryMap["atk_notify_key"] = notifyKey[0]
+ }
+
+ return utils.AddQueryToURL(rawURL, queryMap)
+}
+
+// ===============
+// Page
+// ===============
+
+// 获取可访问链接
+func GetPageAccessibleURL(p *entity.Page) string {
+ if p.AccessibleURL == "" {
+ acURL := p.Key
+
+ // 若 pageKey 为相对路径,生成相对于 site.FirstUrl 配置的 URL
+ if !utils.ValidateURL(p.Key) {
+ site := FetchSiteForPage(p)
+ u1, e1 := url.Parse(CookSite(&site).FirstUrl)
+ u2, e2 := url.Parse(p.Key)
+ if e1 == nil && e2 == nil {
+ acURL = u1.ResolveReference(u2).String()
+ }
+ }
+
+ p.AccessibleURL = acURL
+ }
+
+ return p.AccessibleURL
+}
+
+func FetchPageFromURL(p *entity.Page) error {
+ cookedPage := CookPage(p)
+ url := cookedPage.URL
+
+ if url == "" {
+ return errors.New("URL cannot be null")
+ }
+
+ // 获取 URL 页面 title
+ title, err := GetTitleByURL(url)
+
+ if err == nil && title != "" {
+ p.Title = title
+ }
+
+ if err := UpdatePage(p); err != nil {
+ logrus.Error("Failed to save in FetchPage")
+ return err
+ }
+
+ return nil
+}
+
+func GetTitleByURL(url string) (string, error) {
+ if !utils.ValidateURL(url) {
+ logrus.Error("Invalid URL: " + url)
+ return "", errors.New("invalid URL")
+ }
+
+ // Request the HTML page.
+ res, err := http.Get(url)
+ if err != nil {
+ logrus.Error(err)
+ return "", err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != 200 {
+ logrus.Error(fmt.Sprintf("status code error: %d '%s' '%s'", res.StatusCode, res.Status, url))
+ return "", errors.New("status code error")
+ }
+
+ // Load the HTML document
+ doc, err := goquery.NewDocumentFromReader(res.Body)
+ if err != nil {
+ logrus.Error(err)
+ return "", err
+ }
+
+ // 读取页面 title
+ title := doc.Find("title").Text()
+
+ // 如果页面有跳转
+ val, exists := doc.Find(`meta[http-equiv="refresh"]`).Attr("content")
+ if exists {
+ urlReg := regexp.MustCompile(`https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)`)
+ match := urlReg.FindStringSubmatch(val)
+ if len(match) > 0 {
+ redirectURL := match[0]
+ return GetTitleByURL(redirectURL)
+ }
+ }
+
+ return title, nil
+}
+
+// ===============
+// Notify
+// ===============
+
+func NotifySetInitial(n *entity.Notify) error {
+ n.IsRead = false
+ n.IsEmailed = false
+ return DB().Save(n).Error
+}
+
+func NotifySetRead(n *entity.Notify) error {
+ n.IsRead = true
+ nowTime := time.Now()
+ n.ReadAt = &nowTime
+ return DB().Save(n).Error
+}
+
+func NotifySetEmailed(n *entity.Notify) error {
+ n.IsEmailed = true
+ nowTime := time.Now()
+ n.EmailAt = &nowTime
+ return DB().Save(n).Error
+}
+
+func GetReadLinkByNotify(n *entity.Notify) string {
+ c := FetchCommentForNotify(n)
+
+ return GetLinkToReplyByComment(&c, n.Key)
+}
+
+// ===============
+// Vote
+// ===============
+
+func VoteSync() {
+ var comments []entity.Comment
+ DB().Find(&comments)
+
+ for _, c := range comments {
+ voteUp := GetVoteNum(c.ID, string(entity.VoteTypeCommentUp))
+ voteDown := GetVoteNum(c.ID, string(entity.VoteTypeCommentDown))
+ c.VoteUp = int(voteUp)
+ c.VoteDown = int(voteDown)
+ UpdateComment(&c)
+ }
+
+ var pages []entity.Page
+ DB().Find(&pages)
+
+ for _, p := range pages {
+ voteUp := GetVoteNum(p.ID, string(entity.VoteTypePageUp))
+ voteDown := GetVoteNum(p.ID, string(entity.VoteTypePageDown))
+ p.VoteUp = voteUp
+ p.VoteDown = voteDown
+ UpdatePage(&p)
+ }
+}
diff --git a/internal/query/service_test.go b/internal/query/service_test.go
new file mode 100644
index 000000000..0cdfebb3b
--- /dev/null
+++ b/internal/query/service_test.go
@@ -0,0 +1,41 @@
+package query
+
+import (
+ "testing"
+
+ "github.com/ArtalkJS/Artalk/internal/entity"
+)
+
+func Test_GetPageAccessibleURL(t *testing.T) {
+ tests := []struct {
+ name string
+ pageKey string
+ siteUrls string
+ want string
+ }{
+ {name: "相对路径", pageKey: "/abcd.html", siteUrls: "https://qwqaq.com", want: "https://qwqaq.com/abcd.html"},
+ {name: "绝对路径", pageKey: "https://xxx.com/abcd.html", siteUrls: "https://qwqaq.com", want: "https://xxx.com/abcd.html"},
+ {name: "未设置站点 URL", pageKey: "/abcd.html", siteUrls: "", want: "/abcd.html"},
+ {name: "使用第一个站点 URL", pageKey: "/abcd.html", siteUrls: "https://first_url.com,https://second_url.com", want: "https://first_url.com/abcd.html"},
+ {name: "复杂路径解析", pageKey: "/sub-folder/xxx?abc=test#test", siteUrls: "https://qwqaq.com", want: "https://qwqaq.com/sub-folder/xxx?abc=test#test"},
+ {name: "域名子目录", pageKey: "test/1.html", siteUrls: "https://qwqaq.com/sub-folder/", want: "https://qwqaq.com/sub-folder/test/1.html"},
+ {name: "相对路径,上一级目录", pageKey: "../test/1.html", siteUrls: "https://qwqaq.com/sub-folder/abc/", want: "https://qwqaq.com/sub-folder/test/1.html"},
+ {name: "相对路径,末尾带斜杠", pageKey: "/slash-test/", siteUrls: "https://qwqaq.com", want: "https://qwqaq.com/slash-test/"},
+ {name: "相对路径,末尾无斜杠", pageKey: "/slash-test", siteUrls: "https://qwqaq.com", want: "https://qwqaq.com/slash-test"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := entity.Site{}
+ s.ID = uint(1010)
+ s.Urls = tt.siteUrls
+ p := &entity.Page{
+ Key: tt.pageKey,
+ Site: s,
+ }
+
+ if got := GetPageAccessibleURL(p); got != tt.want {
+ t.Errorf("GetPageAccessibleURL() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/query/testdata/example_site_conf.yml b/internal/query/testdata/example_site_conf.yml
new file mode 100644
index 000000000..0a853f1a6
--- /dev/null
+++ b/internal/query/testdata/example_site_conf.yml
@@ -0,0 +1,162 @@
+# This script is used by internal/query/testdata/example_site_conf.yml
+# Do not modify this script unless you know what you are doing.
+host: "0.0.0.0"
+port: 23366
+app_key: "test"
+debug: false
+timezone: "Asia/Shanghai"
+site_default: "Default Site"
+login_timeout: 259200
+db:
+ type: "sqlite"
+ file: "./data/artalk.db"
+ table_prefix: ""
+ name: "artalk"
+ host: "localhost"
+ port: 3306
+ user: "root"
+ password: ""
+ charset: "utf8mb4"
+
+log:
+ enabled: true
+ filename: "./data/artalk.log"
+cache:
+ type: "builtin"
+ expires: 30
+ warm_up: false
+ server: ""
+ redis:
+ network: "tcp"
+ username: ""
+ password: ""
+ db: 0
+trusted_domains: ["http://localhost:1313"]
+ssl:
+ enabled: false
+ cert_path: ""
+ key_path: ""
+admin_users:
+ - name: "admin"
+ email: "admin@test.com"
+ password: "admin"
+ badge_name: "Admin"
+ badge_color: "#FF6C00"
+moderator:
+ pending_default: false
+ api_fail_block: false
+ akismet_key: ""
+ tencent:
+ enabled: false
+ secret_id: ""
+ secret_key: ""
+ region: "ap-guangzhou"
+ aliyun:
+ enabled: false
+ access_key_id: ""
+ access_key_secret: ""
+ region: "cn-shanghai"
+ keywords:
+ enabled: false
+ pending: false
+ files:
+ file_sep: "\n"
+ replac_to: "x"
+captcha:
+ enabled: true
+ always: false
+ action_limit: 3
+ action_reset: 60
+ geetest:
+ enabled: false
+ captcha_id: ""
+ captcha_key: ""
+email:
+ enabled: false
+ send_type: "smtp"
+ send_name: "{{reply_nick}}"
+ send_addr: "noreply@example.com"
+ mail_tpl: "default"
+ smtp:
+ host: "smtp.qq.com"
+ port: 587
+ username: "example@qq.com"
+ password: ""
+ ali_dm:
+ access_key_id: ""
+ access_key_secret: ""
+ account_name: "noreply@example.com"
+img_upload:
+ enabled: true
+ path: "./data/artalk-img/"
+ max_size: 5
+ public_path: null
+ upgit:
+ enabled: false
+ exec: "./upgit -c UPGIT_CONF_FILE_PATH -t /artalk-img"
+ del_local: true
+admin_notify:
+ notify_tpl: "default"
+ noise_mode: false
+ email:
+ enabled: true
+ mail_subject: '[{{site_name}}] Post "{{page_title}}" has new a comment'
+ telegram:
+ enabled: false
+ api_token: ""
+ receivers:
+ - 7777777
+ bark:
+ enabled: false
+ server: "http://day.app/xxxxxxx/"
+ lark:
+ enabled: false
+ webhook_url: ""
+ webhook:
+ enabled: false
+ url: ""
+ ding_talk:
+ enabled: false
+ token: ""
+ secret: ""
+ slack:
+ enabled: false
+ oauth_token: ""
+ receivers:
+ - "CHANNEL_ID"
+ line:
+ enabled: false
+ channel_secret: ""
+ channel_access_token: ""
+ receivers:
+ - "USER_ID_1"
+ - "GROUP_ID_1"
+frontend:
+ placeholder: "What do you think?"
+ noComment: "No comments yet."
+ sendBtn: "Send"
+ editorTravel: true
+ darkMode: false
+ emoticons: "https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json"
+ vote: true
+ voteDown: false
+ uaBadge: true
+ listSort: true
+ pvEl: "#ArtalkPV"
+ countEl: "#ArtalkCount"
+ preview: true
+ flatMode: "auto"
+ nestMax: 2
+ nestSort: DATE_ASC
+ gravatar:
+ mirror: "https://cravatar.cn/avatar/"
+ default: "mp"
+ pagination:
+ pageSize: 20
+ readMore: true
+ autoLoad: true
+ heightLimit:
+ content: 300
+ children: 400
+ reqTimeout: 15000
+ versionCheck: true
diff --git a/internal/query/testdata/model_test_conf.yml b/internal/query/testdata/model_test_conf.yml
new file mode 100644
index 000000000..5172ffed2
--- /dev/null
+++ b/internal/query/testdata/model_test_conf.yml
@@ -0,0 +1,31 @@
+# 加密密钥
+app_key: "test"
+
+# 调试模式
+debug: false
+
+# 时区
+timezone: "Asia/Shanghai"
+
+# 日志
+log:
+ enabled: false
+
+# 缓存
+cache:
+ type: "disabled" # 支持 redis, memcache, builtin (自带缓存)
+ expires: 30 # 缓存过期时间 (单位:分钟)
+ warm_up: false # 程序启动时预热缓存
+ server: "" # 连接缓存服务器 (例如:"localhost:6379")
+
+# 默认站点名
+site_default: "默认站点"
+
+# 管理员账户
+admin_users:
+ - name: "admin_in_conf"
+ email: "admin_in_conf@example.org"
+ password: "123456" # 密码支持 bcrypt 或 md5 加密,例如填写:"(md5)50c21190c6e4e5418c6a90d2b5031119"
+ badge_name: "管理员"
+ badge_color: "#FF6C00"
+
diff --git a/internal/utils/encrypt.go b/internal/utils/encrypt.go
new file mode 100644
index 000000000..6fbb3b090
--- /dev/null
+++ b/internal/utils/encrypt.go
@@ -0,0 +1,12 @@
+package utils
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+)
+
+func GetMD5Hash(text string) string {
+ hasher := md5.New()
+ hasher.Write([]byte(text))
+ return hex.EncodeToString(hasher.Sum(nil))
+}
diff --git a/internal/utils/fs.go b/internal/utils/fs.go
new file mode 100644
index 000000000..0891c335e
--- /dev/null
+++ b/internal/utils/fs.go
@@ -0,0 +1,8 @@
+package utils
+
+import "os"
+
+// EnsureDir ensures that a target directory exists (like `mkdir -p`),
+func EnsureDir(dir string) error {
+ return os.MkdirAll(dir, 0700)
+}
diff --git a/internal/utils/markdown.go b/internal/utils/markdown.go
new file mode 100644
index 000000000..0dd46ab43
--- /dev/null
+++ b/internal/utils/markdown.go
@@ -0,0 +1,38 @@
+package utils
+
+import (
+ "bytes"
+
+ "github.com/microcosm-cc/bluemonday"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer/html"
+)
+
+func Marked(markdownStr string) (string, error) {
+ bmPolicy := bluemonday.UGCPolicy()
+ bmPolicy.RequireNoReferrerOnLinks(true)
+ bmPolicy.AllowAttrs("width", "height", "align", "atk-emoticon").OnElements("img")
+ bmPolicy.AllowAttrs("style", "class", "align").OnElements("span", "p", "div", "a")
+
+ // https://github.com/yuin/goldmark#security
+ md := goldmark.New(
+ goldmark.WithExtensions(extension.GFM),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ ),
+ goldmark.WithRendererOptions(
+ html.WithHardWraps(),
+ html.WithXHTML(),
+ html.WithUnsafe(),
+ ),
+ )
+
+ var buf bytes.Buffer
+ if err := md.Convert([]byte(markdownStr), &buf); err != nil {
+ return "", err
+ }
+
+ return bmPolicy.SanitizeReader(&buf).String(), nil
+}
diff --git a/internal/utils/string.go b/internal/utils/string.go
new file mode 100644
index 000000000..c993a3e74
--- /dev/null
+++ b/internal/utils/string.go
@@ -0,0 +1,123 @@
+package utils
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/tidwall/gjson"
+)
+
+func AddQueryToURL(urlStr string, queryMap map[string]string) string {
+ u, _ := url.Parse(urlStr)
+
+ q, _ := url.ParseQuery(u.RawQuery)
+ for k, v := range queryMap {
+ q.Add(k, v)
+ }
+
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+// ContainsStr returns true if an str is present in a iteratee.
+func ContainsStr(s []string, v string) bool {
+ for _, vv := range s {
+ if vv == v {
+ return true
+ }
+ }
+ return false
+}
+
+// RemoveDuplicates removes the duplicates strings from a Slice
+func RemoveDuplicates(arr []string) []string {
+ allKeys := make(map[string]bool)
+ list := []string{}
+ for _, item := range arr {
+ if _, value := allKeys[item]; !value {
+ allKeys[item] = true
+ list = append(list, item)
+ }
+ }
+ return list
+}
+
+func SplitAndTrimSpace(s string, sep string) []string {
+ splitted := strings.Split(s, sep)
+ arr := []string{}
+ for _, v := range splitted {
+ arr = append(arr, strings.TrimSpace(v))
+ }
+ return RemoveBlankStrings(arr)
+}
+
+func RemoveBlankStrings(s []string) []string {
+ var r []string
+ for _, str := range s {
+ if strings.TrimSpace(str) != "" {
+ r = append(r, str)
+ }
+ }
+ return r
+}
+
+func TruncateString(str string, length int) string {
+ if length <= 0 {
+ return ""
+ }
+
+ // This code cannot support Chinese
+ // orgLen := len(str)
+ // if orgLen <= length {
+ // return str
+ // }
+ // return str[:length]
+
+ // Support Chinese
+ truncated := ""
+ count := 0
+ for _, char := range str {
+ truncated += string(char)
+ count++
+ if count >= length {
+ break
+ }
+ }
+ return truncated
+}
+
+//#region JSON Any To String (for Transfer)
+//******************************************
+
+// 任何类型转 String
+//
+// (bool) true => (string) "true"
+// (int) 0 => (string) "0"
+func ToString(val interface{}) string {
+ return fmt.Sprintf("%v", val)
+}
+
+// 将 JSON "数组中的"对象的 Values 全部转成 String 类型
+// @note Array style is not the same as JSON Array, it uses the ToString() function.
+//
+// [{"a":233}, {"b":true}, {"c":"233"}]
+// => [{"a":"233"}, {"b":"true"}, {"c":"233"}]
+//
+// @relevant ToString()
+func JsonObjInArrAnyStr(jsonStr string) string {
+ var dest []map[string]string
+ for _, item := range gjson.Parse(jsonStr).Array() {
+ dItem := map[string]string{}
+ item.ForEach(func(key, value gjson.Result) bool {
+ dItem[key.String()] = value.String()
+ return true
+ })
+ dest = append(dest, dItem)
+ }
+ j, _ := json.Marshal(dest)
+ return string(j)
+}
+
+//#endregion
diff --git a/internal/utils/struct.go b/internal/utils/struct.go
new file mode 100644
index 000000000..0e2226b95
--- /dev/null
+++ b/internal/utils/struct.go
@@ -0,0 +1,36 @@
+package utils
+
+import (
+ "encoding/json"
+
+ "github.com/jeremywohl/flatten"
+ "github.com/vmihailenco/msgpack"
+)
+
+func StructToMap(s interface{}) map[string]interface{} {
+ b, _ := json.Marshal(s)
+ var m map[string]interface{}
+ _ = json.Unmarshal(b, &m)
+ return m
+}
+
+func StructToFlatDotMap(s interface{}) map[string]interface{} {
+ m := StructToMap(s)
+ mainFlat, err := flatten.Flatten(m, "", flatten.DotStyle)
+ if err != nil {
+ return map[string]interface{}{}
+ }
+ return mainFlat
+}
+
+func CopyStruct(src *map[string]interface{}, dest *map[string]interface{}) error {
+ b, err := msgpack.Marshal(src)
+ if err != nil {
+ return err
+ }
+ err = msgpack.Unmarshal(b, dest)
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/utils/template.go b/internal/utils/template.go
new file mode 100644
index 000000000..759bc8739
--- /dev/null
+++ b/internal/utils/template.go
@@ -0,0 +1,25 @@
+package utils
+
+import (
+ "fmt"
+ "regexp"
+)
+
+// 解析 Mustaches 语法
+// 替换 {{ key }} 为 val
+func RenderMustaches(data string, dict map[string]interface{}, valueGetter ...func(k string, v interface{}) string) string {
+ r := regexp.MustCompile(`{{\s*(.*?)\s*}}`)
+
+ return r.ReplaceAllStringFunc(data, func(m string) string {
+ key := r.FindStringSubmatch(m)[1]
+ if val, isExist := dict[key]; isExist {
+ if len(valueGetter) > 0 {
+ return valueGetter[0](key, val)
+ } else {
+ return fmt.Sprintf("%v", val)
+ }
+ }
+
+ return m
+ })
+}
diff --git a/internal/utils/utils.go b/internal/utils/utils.go
new file mode 100644
index 000000000..d4b585bf7
--- /dev/null
+++ b/internal/utils/utils.go
@@ -0,0 +1 @@
+package utils
diff --git a/internal/utils/validator.go b/internal/utils/validator.go
new file mode 100644
index 000000000..752f3d872
--- /dev/null
+++ b/internal/utils/validator.go
@@ -0,0 +1,11 @@
+package utils
+
+import "github.com/asaskevich/govalidator"
+
+func ValidateEmail(email string) bool {
+ return govalidator.IsEmail(email)
+}
+
+func ValidateURL(url string) bool {
+ return govalidator.IsRequestURL(url)
+}
diff --git a/main.go b/main.go
new file mode 100644
index 000000000..bd378e3e5
--- /dev/null
+++ b/main.go
@@ -0,0 +1,18 @@
+package main
+
+import (
+ "embed"
+
+ "github.com/ArtalkJS/Artalk/cmd"
+ "github.com/ArtalkJS/Artalk/internal/pkged"
+)
+
+//go:embed public/*
+//go:embed i18n/*
+//go:embed artalk.example.yml
+var embedFS embed.FS
+
+func main() {
+ pkged.SetFS(embedFS)
+ cmd.Execute()
+}
diff --git a/packages/artalk-sidebar/scripts/fetch-conf-tpl.js b/packages/artalk-sidebar/scripts/fetch-conf-tpl.js
deleted file mode 100644
index 783a06357..000000000
--- a/packages/artalk-sidebar/scripts/fetch-conf-tpl.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import https from 'https'
-import fs from 'fs'
-import path from 'path'
-import { fileURLToPath } from 'url'
-
-const __filename = fileURLToPath(import.meta.url)
-const __dirname = path.dirname(__filename)
-
-const file = fs.createWriteStream(path.join(__dirname, '../src/assets/artalk-go.example.yml'))
-
-https.get(
- 'https://raw.githubusercontent.com/ArtalkJS/ArtalkGo/master/artalk-go.example.yml',
- (resp) => {
- resp.pipe(file)
-
- file.on('finish', () => {
- file.close()
- console.log("\nArtalkGo 'artalk-go.example.yml' file download completed.\n")
- })
- }
-).on('error', (e) => {
- console.error("Failed to download 'artalk-go.example.yml' file:\n\n", e, "\n");
-})
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 000000000..05f9d29ef
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Welcome to Artalk!
+
+
+ Welcome to ArtalkGo!
+ For online documentation and support please refer to artalk.js.org.
+ Thank you for using Artalk.
+
+
\ No newline at end of file
diff --git a/packages/artalk-sidebar/public/robots.txt b/public/robots.txt
similarity index 100%
rename from packages/artalk-sidebar/public/robots.txt
rename to public/robots.txt
diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh
new file mode 100755
index 000000000..e4c40ef65
--- /dev/null
+++ b/scripts/benchmark.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+bombardier -c 125 -d 10s -b "site_name=ArtalkDocs&page_key=https%3A%2F%2Fartalk.js.org%2Fguide%2Fintro.html&limit=20&offset=0" -m POST -H "Origin: http://127.0.0.1:5173" -H "Content-Type: application/x-www-form-urlencoded" http://127.0.0.1:23366/api/get
\ No newline at end of file
diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh
new file mode 100755
index 000000000..b4387e329
--- /dev/null
+++ b/scripts/build-frontend.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+set -e
+
+if ! command -v pnpm &> /dev/null
+then
+ apt-get update && apt-get install --no-install-recommends -y -q curl ca-certificates
+
+ # Install volta
+ bash -c "$(curl -fsSL https://get.volta.sh)" -- --skip-setup
+ export VOLTA_HOME="${HOME}/.volta"
+ export PATH="${VOLTA_HOME}/bin:${PATH}"
+
+ volta install node
+ volta install pnpm
+fi
+
+pnpm --dir ./ui install --frozen-lockfile
+pnpm --dir ./ui build:all
+
+## dist
+DIST_DIR="./public/dist"
+rm -rf ${DIST_DIR} && mkdir -p ${DIST_DIR}
+cp -r ./ui/packages/artalk/dist/{Artalk.css,Artalk.js} ${DIST_DIR}
+
+## sidebar
+SIDEBAR_DIR="./public/sidebar"
+rm -rf ${SIDEBAR_DIR} && mkdir -p ${SIDEBAR_DIR}
+cp -r ./ui/packages/artalk-sidebar/dist/* ${SIDEBAR_DIR}
diff --git a/scripts/docker-artalk-runner.sh b/scripts/docker-artalk-runner.sh
new file mode 100644
index 000000000..79367e376
--- /dev/null
+++ b/scripts/docker-artalk-runner.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ -e /data/artalk.yml ]; then
+ /artalk -w / -c /data/artalk.yml "$@"
+else
+ /artalk -w / -c /data/artalk-go.yml "$@"
+fi
diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh
new file mode 100755
index 000000000..f69ff3e8a
--- /dev/null
+++ b/scripts/docker-build.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+set -e
+
+IMAGE_NAME="artalk/artalk"
+VERSION=$(git describe --tags --abbrev=0)
+
+if [[ $* == *--push* ]]
+then
+ # tag and push image
+ for tag in {${VERSION},latest}; do
+ docker image tag "${IMAGE_NAME}" "${IMAGE_NAME}:${tag}"
+ docker push "${IMAGE_NAME}:${tag}"
+ done
+else
+ # build
+ docker image build -t "${IMAGE_NAME}" .
+fi
diff --git a/scripts/setup-example-site.sh b/scripts/setup-example-site.sh
new file mode 100755
index 000000000..ad91865d4
--- /dev/null
+++ b/scripts/setup-example-site.sh
@@ -0,0 +1,178 @@
+#!/bin/bash
+
+#
+# This script sets up a local example site for testing,
+# with Artalk integrated.
+# Build frontend and backend before running this script.
+# After running this script, run:
+# ./bin/artalk server -c ./local/local.yml
+# to start the comment server.
+# And open http://localhost:1313/ in your browser to view the example site.
+#
+
+# exit if any command fails
+set -e
+
+# check if hugo is installed
+if ! [ -x "$(command -v hugo)" ]; then
+ echo 'Error: hugo is not installed.' >&2
+ exit 1
+fi
+
+# check if ./public/dist/Artalk.css exists
+if ! [ -f "./public/dist/Artalk.css" ]; then
+ echo 'Error: ./public/dist/Artalk.css does not exist.' >&2
+ exit 1
+fi
+
+mkdir -p ./local
+
+# copy internal/query/testdata/example_site_conf.yml to ./local/local.yml if it does not exist
+if ! [ -f "./local/artalk.yml" ]; then
+ echo "Copying internal/query/testdata/example_site_conf.yml to ./local/artalk.yml"
+ cp ./internal/query/testdata/example_site_conf.yml ./local/local.yml
+fi
+
+# clean up local/example_site if it exists
+if [ -d "./local/example_site" ]; then
+ echo "Cleaning up local/example_site"
+ rm -rfd ./local/example_site
+fi
+
+echo "Setting up local/example_site"
+# create a new example site
+hugo new site ./local/example_site
+
+# copy the theme to the example site
+theme_repo="https://github.com/ph-ph/chalk"
+theme_dir="./local/example_site/themes/chalk"
+echo "Cloning theme from ${theme_repo} to ${theme_dir}"
+git clone ${theme_repo} ${theme_dir}
+
+
+# create comment partial
+echo "Creating comment partial in local/example_site"
+mkdir -p ./local/example_site/layouts/partials/comments
+cat << EOF > ./local/example_site/layouts/partials/comments/comments.html
+
+
+
+
+
+
+
+EOF
+
+# patch ${single_template}, insert code before last tag
+single_template=local/example_site/themes/chalk/layouts/_default/single.html
+echo "Patching ${single_template}"
+sed -i '/<\/article>/i {{ partial "comments/comments.html" . }}' ./${single_template}
+
+# update the example site config
+cat << EOF > ./local/example_site/config.toml
+baseURL = 'http://example.org/'
+languageCode = 'en-us'
+title = 'My New Hugo Site'
+theme = "chalk"
+[params.chalk]
+ # chalk theme parameters
+ about_enabled = true
+ scrollappear_enabled = true
+ theme_toggle = true
+ rss_enabled = true
+ blog_theme = 'light'
+ local_fonts = false
+[params.social]
+ twitter='example'
+ github='example'
+[params.artalk]
+ server='http://localhost:23366'
+ site='Default Site'
+EOF
+
+# copy ./public/dist/Artalk.css/js to /lib/artalk/Artalk.css/js
+echo "Copying ./public/dist/Artalk.css/js to /lib/artalk/Artalk.css/js"
+mkdir -p ./local/example_site/static/lib/artalk
+cp ./public/dist/Artalk.css ./local/example_site/static/lib/artalk/Artalk.css
+cp ./public/dist/Artalk.js ./local/example_site/static/lib/artalk/Artalk.js
+
+# create a post in the example site
+echo "Creating a post in local/example_site"
+mkdir -p ./local/example_site/content/posts
+cat << EOF > ./local/example_site/content/posts/my-first-post.md
+---
+title: "My First Post"
+---
+
+# Manibus positaeque agrestes
+
+## Inmurmurat posset saxa
+
+Lorem markdownum undas, sors matre fata nimiumque parte; sua carae medios. Per
+ego ad turritaque pars. Privignae tenuit auget in saxo nebulis. Pende enim
+Silvanusque idem favilla, tecta! Motu *modo*, Almo vertuntur sceptroque alto,
+mitia.
+
+## Fuit ardor centum materiam in inquit
+
+Rarescit digitis, turba debuit auctor inplerant monstra, pararet. Detestatur
+**saltem dubitet Aeneadae** pectora celsis doloris, est tutus et circumdat agit
+poposcit *precor* et.
+
+## Bos avidus flammas excusare corpore
+
+Nova Rutulos, Clytien, conspicuus poterit. Ne hac cladem fatendo postquam
+saevitiae Dianae torvos *fluctus*, iuvenis et. Sensit adstitit separat effectum
+saevarum fecit, me date imo, adicis nubigenas **ferumque**. Res illa signis!
+Videri vagi, hac mori iras exuit similis modo et tempora.
+
+ if (day_widget_saas) {
+ footer.rate_dhcp(4, association_data, winSpoofingMca(real,
+ esportsRepository, system));
+ }
+ if (unix_dma.serp(4 + trinitron, -3) ==
+ koffice_function.defragmentQuadMatrix(vista_kernel,
+ firmware_ntfs_tebibyte, -1 * bing)) {
+ utility *= web;
+ tunneling_halftone_primary(-2 + bare, -5, bmp + 5);
+ }
+ uddi += crop_error_capacity(bugCpc + boot, publishingSystemOutput(
+ dvdClientOptical));
+ if (2 == file_apple) {
+ cutDevelopmentData.heuristic_bridge_desktop(
+ softwareInfotainmentEdutainment - spam, tween,
+ donationwareSecondary);
+ } else {
+ serverSdramRuntime = -2;
+ }
+
+## Nostrique miratur repetita suprema vocat sustulit
+
+Dumosaque adflabat mixtaeque communicat est, Aeolus pinum. Me fecit.
+
+ domain_leak_namespace.visualMonitor(header, gps_archie(service, 4,
+ basic_networking_unfriend));
+ cisc_software_cycle.fiosHertzAndroid += plug - 5 + storage(
+ firewall_algorithm_troubleshooting);
+ eup_botnet -= 5;
+ computerSampleNull += uml * hard_clone_cd;
+
+Limine habebat frigore, venenis tenebris nota. Fors erubui victa cortice
+nullamque iniqui domos viroque, *inquit* ingentia, *crudelia*. Foedera qua!
+*Ipsa dum perque*, mea orbi; aversus loca rutilos dona dixerat Bacchus saltus.
+EOF
+
+
+# run the example site
+echo "Running local/example_site"
+pushd ./local/example_site
+hugo server
+popd
\ No newline at end of file
diff --git a/server/common/auth.go b/server/common/auth.go
new file mode 100644
index 000000000..0590ee609
--- /dev/null
+++ b/server/common/auth.go
@@ -0,0 +1,107 @@
+package common
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/gofiber/fiber/v2"
+ "github.com/golang-jwt/jwt"
+)
+
+// jwtCustomClaims are custom claims extending default ones.
+// See https://github.com/golang-jwt/jwt for more examples
+type jwtCustomClaims struct {
+ UserID uint `json:"user_id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ IsAdmin bool `json:"is_admin"`
+ jwt.StandardClaims
+}
+
+func LoginGetUserToken(user entity.User) string {
+ // Set custom claims
+ claims := &jwtCustomClaims{
+ UserID: user.ID,
+ Name: user.Name,
+ Email: user.Email,
+ IsAdmin: user.IsAdmin,
+ StandardClaims: jwt.StandardClaims{
+ ExpiresAt: time.Now().Add(time.Second * time.Duration(config.Instance.LoginTimeout)).Unix(), // 过期时间
+ },
+ }
+
+ // Create token with claims
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+
+ // Generate encoded token and send it as response.
+ t, err := token.SignedString([]byte(config.Instance.AppKey))
+ if err != nil {
+ return ""
+ }
+
+ return t
+}
+
+func GetJwtStrByReqCookie(c *fiber.Ctx) string {
+ if !config.Instance.Cookie.Enabled {
+ return ""
+ }
+ cookie := c.Cookies(config.COOKIE_KEY_ATK_AUTH)
+ return cookie
+}
+
+func GetJwtInstanceByReq(c *fiber.Ctx) *jwt.Token {
+ token := c.Query("token")
+ if token == "" {
+ token = c.FormValue("token")
+ }
+ if token == "" {
+ token = c.Get(fiber.HeaderAuthorization)
+ token = strings.TrimPrefix(token, "Bearer ")
+ }
+ if token == "" {
+ token = GetJwtStrByReqCookie(c)
+ }
+ if token == "" {
+ return nil
+ }
+
+ jwt, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
+ if t.Method.Alg() != "HS256" {
+ return nil, fmt.Errorf("unexpected jwt signing method=%v", t.Header["alg"])
+ }
+
+ return []byte(config.Instance.AppKey), nil // 密钥
+ })
+ if err != nil {
+ return nil
+ }
+
+ return jwt
+}
+
+func GetUserByJwt(jwt *jwt.Token) entity.User {
+ if jwt == nil {
+ return entity.User{}
+ }
+
+ claims := jwtCustomClaims{}
+ tmp, _ := json.Marshal(jwt.Claims)
+ _ = json.Unmarshal(tmp, &claims)
+
+ user := query.FindUserByID(claims.UserID)
+
+ return user
+}
+
+func GetUserByReq(c *fiber.Ctx) entity.User {
+ jwt := GetJwtInstanceByReq(c)
+ user := GetUserByJwt(jwt)
+
+ return user
+}
diff --git a/server/common/captcha.go b/server/common/captcha.go
new file mode 100644
index 000000000..6645085bc
--- /dev/null
+++ b/server/common/captcha.go
@@ -0,0 +1,177 @@
+package common
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image/color"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/eko/gocache/lib/v4/store"
+ "github.com/gofiber/fiber/v2"
+ imgCaptcha "github.com/steambap/captcha"
+)
+
+var (
+ CaptchaExpiration = 5 * time.Minute // 验证码 5 分钟内有效
+)
+
+// 请求是否需要验证码
+func IsReqNeedCaptchaCheck(c *fiber.Ctx) bool {
+ captchaConf := config.Instance.Captcha
+
+ // 管理员直接忽略
+ if CheckIsAdminReq(c) {
+ return false
+ }
+
+ // 总是需要验证码模式
+ if config.Instance.Captcha.Always {
+ return !GetAlwaysCaptchaMode_Pass(c.IP())
+ }
+
+ // 不重置计数器模式
+ if captchaConf.ActionReset == -1 {
+ if getActionCount(c) >= captchaConf.ActionLimit { // 只要操作次数超过
+ return true // 就过限
+ } else {
+ return false // 放行
+ }
+ }
+
+ // 开启重置计数器功能的情况:在时间范围内,操作次数超过
+ if IsActionInTimeFrame(c) && getActionCount(c) >= captchaConf.ActionLimit {
+ return true // 过限
+ } else {
+ return false // 放行
+ }
+}
+
+// 记录操作
+func RecordAction(c *fiber.Ctx) {
+ updateActionLastTime(c) // 更新最后操作时间
+ addActionCount(c) // 操作次数 +1
+}
+
+// 重置操作记录
+func ResetActionRecord(c *fiber.Ctx) {
+ ip := c.IP()
+
+ cache.CACHE.Delete(cache.Ctx, "action-time:"+ip)
+ cache.CACHE.Delete(cache.Ctx, "action-count:"+ip)
+}
+
+// 操作计数是否应该被重置
+func IsActionInTimeFrame(c *fiber.Ctx) bool {
+ return time.Since(getActionLastTime(c)).Seconds() <= float64(config.Instance.Captcha.ActionReset)
+}
+
+// 修改最后操作时间
+func updateActionLastTime(c *fiber.Ctx) {
+ curtTime := fmt.Sprintf("%v", time.Now().Unix())
+ cache.CACHE.Set(cache.Ctx, "action-time:"+c.IP(), curtTime)
+}
+
+// 获取最后操作时间
+func getActionLastTime(c *fiber.Ctx) time.Time {
+ var timestamp int64
+ var val string
+ if _, err := cache.CACHE.Get(cache.Ctx, "action-time:"+c.IP(), &val); err == nil {
+ timestamp, _ = strconv.ParseInt(string(val), 10, 64)
+ }
+ tm := time.Unix(timestamp, 0)
+ return tm
+}
+
+// 获取操作次数
+func getActionCount(c *fiber.Ctx) int {
+ count := 0
+ var val string
+ if _, err := cache.CACHE.Get(cache.Ctx, "action-count:"+c.IP(), &val); err == nil {
+ count, _ = strconv.Atoi(val)
+ }
+
+ return count
+}
+
+// 修改操作次数
+func setActionCount(c *fiber.Ctx, num int) {
+ cache.CACHE.Set(cache.Ctx, "action-count:"+c.IP(), fmt.Sprintf("%d", num))
+}
+
+// 操作次数 +1
+func addActionCount(c *fiber.Ctx) {
+ setActionCount(c, getActionCount(c)+1)
+}
+
+// 验证成功操作
+func OnCaptchaPass(c *fiber.Ctx) {
+ ip := c.IP()
+
+ setActionCount(c, config.Instance.Captcha.ActionLimit-1)
+ SetAlwaysCaptchaMode_Pass(ip, true) // 允许 always mode pass
+}
+
+// 验证失败操作
+func OnCaptchaFail(c *fiber.Ctx) {
+ ip := c.IP()
+
+ RecordAction(c) // 记录操作
+ SetAlwaysCaptchaMode_Pass(ip, false) // 取消 always mode pass
+}
+
+// #region 图片验证码
+// 获取对应 IP 图片验证码正确的值
+func GetImageCaptchaRealCode(ip string) string {
+ var realVal string
+ cache.CACHE.Get(cache.Ctx, "captcha:"+ip, &realVal)
+ return strings.ToLower(realVal)
+}
+
+// 获取新验证码 base64 格式图片
+func GetNewImageCaptchaBase64(ip string) string {
+ // generate a image
+ pngBuffer := bytes.NewBuffer([]byte{})
+ data, _ := imgCaptcha.New(160, 40, func(o *imgCaptcha.Options) {
+ o.FontScale = 1
+ o.CurveNumber = 2
+ o.FontDPI = 85.0
+ o.Noise = 0.7
+ o.BackgroundColor = color.White
+ })
+ data.WriteImage(pngBuffer)
+ base64 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBuffer.Bytes())
+
+ // save real code
+ cache.CACHE.Set(cache.Ctx, "captcha:"+ip, data.Text, store.WithExpiration(CaptchaExpiration))
+
+ return base64
+}
+
+// 销毁图片验证码
+func DisposeImageCaptcha(ip string) {
+ cache.CACHE.Delete(cache.Ctx, "captcha:"+ip)
+}
+
+//#endregion
+
+// AlwaysMode 是否能 Pass (for 总是需要验证码的选项)
+func GetAlwaysCaptchaMode_Pass(ip string) bool {
+ var val string
+ _, err := cache.CACHE.Get(cache.Ctx, "captcha-am-pass:"+ip, &val)
+ return err == nil && val == "1"
+}
+
+// 设置 AlwaysMode 允许 Pass (for 总是需要验证码的选项)
+func SetAlwaysCaptchaMode_Pass(ip string, pass bool) {
+ val := "0"
+ if pass {
+ val = "1"
+ }
+
+ cache.CACHE.Set(cache.Ctx, "captcha-am-pass:"+ip, val, store.WithExpiration(CaptchaExpiration))
+}
diff --git a/server/common/check.go b/server/common/check.go
new file mode 100644
index 000000000..a7c6235f2
--- /dev/null
+++ b/server/common/check.go
@@ -0,0 +1,53 @@
+package common
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/gofiber/fiber/v2"
+)
+
+func CheckIsAllowed(c *fiber.Ctx, name string, email string, page entity.Page, siteName string) (bool, error) {
+ isAdminUser := query.IsAdminUserByNameEmail(name, email)
+
+ // 如果用户是管理员,或者当前页只能管理员评论
+ if isAdminUser || page.AdminOnly {
+ if !CheckIsAdminReq(c) {
+ return false, RespError(c, i18n.T("Admin access required"), Map{"need_login": true})
+ }
+ }
+
+ return true, nil
+}
+
+func CheckIsAdminReq(c *fiber.Ctx) bool {
+ jwt := GetJwtInstanceByReq(c)
+ if jwt == nil {
+ return false
+ }
+
+ user := GetUserByJwt(jwt)
+ return user.IsAdmin
+}
+
+func GetIsSuperAdmin(c *fiber.Ctx) bool {
+ user := GetUserByReq(c)
+ return user.IsAdmin && user.SiteNames == ""
+}
+
+func IsAdminHasSiteAccess(c *fiber.Ctx, siteName string) bool {
+ user := GetUserByReq(c)
+ cookedUser := query.CookUser(&user)
+
+ if !user.IsAdmin {
+ return false
+ }
+
+ if !GetIsSuperAdmin(c) && !utils.ContainsStr(cookedUser.SiteNames, siteName) {
+ // 如果账户分配了站点,并且待操作的站点并非处于分配的站点列表
+ return false
+ }
+
+ return true
+}
diff --git a/server/common/conf.go b/server/common/conf.go
new file mode 100644
index 000000000..92553209f
--- /dev/null
+++ b/server/common/conf.go
@@ -0,0 +1,41 @@
+package common
+
+import (
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/gofiber/fiber/v2"
+)
+
+func GetApiVersionDataMap() Map {
+ return Map{
+ "app": "artalk",
+ "version": strings.TrimPrefix(config.Version, "v"),
+ "commit_hash": config.CommitHash,
+ "fe_min_version": strings.TrimPrefix(config.FeMinVersion, "v"),
+ }
+}
+
+func GetApiPublicConfDataMap(c *fiber.Ctx) Map {
+ isAdmin := CheckIsAdminReq(c)
+ imgUpload := config.Instance.ImgUpload.Enabled
+ if isAdmin {
+ imgUpload = true // 管理员始终允许上传图片
+ }
+
+ frontendConfSrc := config.Instance.Frontend
+ if frontendConfSrc == nil {
+ frontendConfSrc = make(map[string]interface{})
+ }
+
+ frontendConf := make(map[string]interface{})
+ utils.CopyStruct(&frontendConfSrc, &frontendConf)
+
+ frontendConf["imgUpload"] = &imgUpload
+
+ return Map{
+ "img_upload": imgUpload,
+ "frontend_conf": frontendConf,
+ }
+}
diff --git a/server/common/cors.go b/server/common/cors.go
new file mode 100644
index 000000000..9e253c4ab
--- /dev/null
+++ b/server/common/cors.go
@@ -0,0 +1,61 @@
+package common
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/middleware/cors"
+)
+
+var CorsConf = &cors.Config{
+ AllowOrigins: "",
+ AllowHeaders: "Origin, Content-Type, Accept, Authorization",
+ AllowCredentials: true, // allow cors with cookies
+}
+
+func ReloadCorsAllowOrigins() {
+ allowOriginsArr := GetCorsAllowOrigins()
+
+ allowOrigins := strings.Join(allowOriginsArr, ", ")
+ {
+ if len(allowOriginsArr) == 0 {
+ // 无配置的情况全部放行
+ // 如程序第一次运行的时候
+ allowOrigins = "*"
+ }
+ if utils.ContainsStr(config.Instance.TrustedDomains, "*") {
+ // 通配符关闭 origin 检测
+ allowOrigins = "*"
+ }
+ }
+ CorsConf.AllowOrigins = allowOrigins
+}
+
+func GetCorsAllowOrigins() []string {
+ allowURLs := []string{}
+ allowURLs = append(allowURLs, config.Instance.TrustedDomains...) // 导入配置中的可信域名
+ for _, site := range query.FindAllSitesCooked() { // 导入数据库中的站点 urls
+ allowURLs = append(allowURLs, site.Urls...)
+ }
+
+ allowOrigins := []string{}
+ for _, u := range allowURLs {
+ u = strings.TrimSpace(u)
+ if u == "" {
+ continue
+ }
+
+ urlP, err := url.Parse(u)
+ if err != nil || urlP.Scheme == "" || urlP.Host == "" {
+ continue
+ }
+
+ allowOrigins = append(allowOrigins, fmt.Sprintf("%s://%s", urlP.Scheme, urlP.Host))
+ }
+
+ return allowOrigins
+}
diff --git a/server/common/params.go b/server/common/params.go
new file mode 100644
index 000000000..87784b4dd
--- /dev/null
+++ b/server/common/params.go
@@ -0,0 +1,86 @@
+package common
+
+import (
+ "reflect"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type Map = map[string]interface{}
+
+func ParamsDecode(c *fiber.Ctx, destParams interface{}) (isContinue bool, resp error) {
+ reqMethod := c.Method()
+
+ var errParse error
+ switch reqMethod {
+ case "GET":
+ errParse = c.QueryParser(destParams)
+ case "POST":
+ errParse = c.BodyParser(destParams)
+ }
+ if errParse != nil {
+ return false, errParse
+ }
+
+ refVal := reflect.ValueOf(destParams)
+ for i := 0; i < refVal.Elem().Type().NumField(); i++ {
+ k := refVal.Elem().Type().Field(i)
+ // v := refVal.Elem().Field(i)
+ // fieldName := k.Name
+
+ validateTag := k.Tag.Get("validate")
+ requiredField := (validateTag == "required")
+
+ if !requiredField {
+ continue // 仅 required 检测时才继续执行之后的代码
+ }
+
+ paramName := k.Tag.Get("query")
+ if paramName == "" {
+ paramName = k.Tag.Get("form")
+ }
+ if paramName == "" {
+ continue
+ }
+
+ // get param value
+ paramVal := func() string {
+ switch reqMethod {
+ case "GET":
+ return c.Query(paramName)
+ case "POST":
+ return c.FormValue(paramName)
+ }
+ return ""
+ }()
+
+ // check required param
+ if requiredField && strings.TrimSpace(paramVal) == "" {
+ return false, RespError(c, "Param `"+paramName+"` is required")
+ }
+
+ // 类型转换交给 fiber 内置 BodyParser 来做,这里不再实现
+ // convert type
+ // kind := k.Type.Kind()
+ // if kind == reflect.String {
+ // v.SetString(paramVal)
+ // } else if kind == reflect.Bool {
+ // v.SetBool((paramVal == "1" || paramVal == "true"))
+ // } else if (kind == reflect.Int) || (kind == reflect.Uint) {
+ // u64, err := strconv.ParseInt(paramVal, 10, 32)
+ // if requiredField && (err != nil || u64 == 0) {
+ // return false, RespError(c, "Param `"+paramName+"` is required")
+ // }
+ // if kind == reflect.Uint {
+ // v.SetUint(uint64(u64))
+ // } else {
+ // v.SetInt(u64)
+ // }
+ // }
+ // // } else if kind == reflect.Array {
+ // // }
+ }
+
+ return true, nil
+}
diff --git a/server/common/resp.go b/server/common/resp.go
new file mode 100644
index 000000000..a0553cb0e
--- /dev/null
+++ b/server/common/resp.go
@@ -0,0 +1,96 @@
+package common
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+// JSONResult JSON 响应数据结构
+type JSONResult struct {
+ Success bool `json:"success"` // 是否成功
+ Msg string `json:"msg,omitempty"` // 消息
+ Data interface{} `json:"data,omitempty"` // 数据
+ Extra interface{} `json:"extra,omitempty"` // 数据
+}
+
+// RespJSON is normal json result
+func RespJSON(c *fiber.Ctx, msg string, data interface{}, success bool) error {
+ return c.Status(http.StatusOK).JSON(&JSONResult{
+ Success: success,
+ Msg: msg,
+ Data: data,
+ })
+}
+
+// RespData is just response data
+func RespData(c *fiber.Ctx, data interface{}) error {
+ return c.Status(http.StatusOK).JSON(&JSONResult{
+ Success: true,
+ Data: data,
+ })
+}
+
+// RespSuccess is just response success
+func RespSuccess(c *fiber.Ctx, msg ...string) error {
+ respData := &JSONResult{
+ Success: true,
+ }
+
+ // 可选参数 msg
+ if len(msg) > 0 {
+ respData.Msg = msg[0]
+ }
+
+ return c.Status(http.StatusOK).JSON(respData)
+}
+
+// RespError is just response error
+func RespError(c *fiber.Ctx, msg string, data ...Map) error {
+ // log
+ path := c.Path()
+ if path == "" {
+ path = "/"
+ }
+ LogWithHttpInfo(c).Errorf("[响应] %s %s ==> %s", c.Method(), path, strconv.Quote(msg))
+
+ respData := Map{}
+ if len(data) > 0 {
+ respData = data[0]
+ }
+
+ return c.Status(http.StatusOK).JSON(&JSONResult{
+ Success: false,
+ Msg: msg,
+ Data: respData,
+ })
+}
+
+func LogWithHttpInfo(c *fiber.Ctx) *logrus.Entry {
+ fields := logrus.Fields{}
+
+ req := c.Request()
+ res := c.Response()
+
+ path := string(req.URI().Path())
+ if path == "" {
+ path = "/"
+ }
+
+ id := c.Get(fiber.HeaderXRequestID)
+ if id == "" {
+ id = c.GetRespHeader(fiber.HeaderXRequestID)
+ }
+ fields["id"] = id
+ fields["ip"] = strings.Join(c.IPs(), ", ")
+ fields["host"] = string(req.Host())
+ fields["referer"] = string(req.Header.Referer())
+ fields["user_agent"] = string(req.Header.UserAgent())
+ fields["status"] = res.StatusCode()
+ //fields["headers"] = req.Header
+
+ return logrus.WithFields(fields)
+}
diff --git a/server/common/site.go b/server/common/site.go
new file mode 100644
index 000000000..1706702ca
--- /dev/null
+++ b/server/common/site.go
@@ -0,0 +1,18 @@
+package common
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/gofiber/fiber/v2"
+)
+
+func UseSite(c *fiber.Ctx, siteName *string, destID *uint, destSiteAll *bool) {
+ if destID != nil {
+ *destID = c.Locals(config.CTX_KEY_ATK_SITE_ID).(uint)
+ }
+ if siteName != nil {
+ *siteName = c.Locals(config.CTX_KEY_ATK_SITE_NAME).(string)
+ }
+ if destSiteAll != nil {
+ *destSiteAll = c.Locals(config.CTX_KEY_ATK_SITE_ALL).(bool)
+ }
+}
diff --git a/server/handler/admin_cache.go b/server/handler/admin_cache.go
new file mode 100644
index 000000000..7a258c078
--- /dev/null
+++ b/server/handler/admin_cache.go
@@ -0,0 +1,67 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminCacheWarm struct {
+}
+
+// 缓存预热
+//
+// POST /api/admin/cache-warm
+func AdminCacheWarm(router fiber.Router) {
+ router.Post("/cache-warm", func(c *fiber.Ctx) error {
+ var p ParamsAdminCacheWarm
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ go func() {
+ cache.CacheWarmUp()
+ }()
+
+ return common.RespData(c, common.Map{
+ "msg": i18n.T("Task executing in background, please wait..."),
+ })
+ })
+}
+
+type ParamsAdminCacheFlush struct {
+ FlushAll bool `form:"flush_all"`
+}
+
+// 缓存清理
+//
+// POST /api/admin/cache-flush
+func AdminCacheFlush(router fiber.Router) {
+ router.Post("/cache-flush", func(c *fiber.Ctx) error {
+ var p ParamsAdminCacheFlush
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ if p.FlushAll {
+ go func() {
+ cache.CacheFlushAll()
+ }()
+
+ return common.RespData(c, common.Map{
+ "msg": i18n.T("Task executing in background, please wait..."),
+ })
+ }
+
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Parameter")}))
+ })
+}
diff --git a/server/handler/admin_comment_del.go b/server/handler/admin_comment_del.go
new file mode 100644
index 000000000..db1965d08
--- /dev/null
+++ b/server/handler/admin_comment_del.go
@@ -0,0 +1,51 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsCommentDel struct {
+ ID uint `form:"id" validate:"required"`
+
+ SiteName string
+ SiteID uint
+ SiteAll bool
+}
+
+// POST /api/admin/comment-del
+func AdminCommentDel(router fiber.Router) {
+ router.Post("/comment-del", func(c *fiber.Ctx) error {
+ var p ParamsCommentDel
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // find comment
+ comment := query.FindComment(p.ID)
+ if comment.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Comment")}))
+ }
+
+ if !common.IsAdminHasSiteAccess(c, comment.SiteName) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ // 删除主评论
+ if err := query.DelComment(&comment); err != nil {
+ return common.RespError(c, i18n.T("{{name}} deletion failed", Map{"name": i18n.T("Comment")}))
+ }
+
+ // 删除子评论
+ if err := query.DelCommentChildren(comment.ID); err != nil {
+ return common.RespError(c, i18n.T("{{name}} deletion failed", Map{"name": i18n.T("Sub-comment")}))
+ }
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/admin_comment_edit.go b/server/handler/admin_comment_edit.go
new file mode 100644
index 000000000..030647f53
--- /dev/null
+++ b/server/handler/admin_comment_edit.go
@@ -0,0 +1,153 @@
+package handler
+
+import (
+ "strconv"
+
+ "github.com/ArtalkJS/Artalk/internal/email"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsCommentEdit struct {
+ // 查询值
+ ID uint `form:"id" validate:"required"`
+ SiteName string
+ SiteID uint
+ SiteAll bool
+
+ // 可修改
+ Content string `form:"content"`
+ PageKey string `form:"page_key"`
+ Nick string `form:"nick"`
+ Email string `form:"email"`
+ Link string `form:"link"`
+ Rid string `form:"rid"`
+ UA string `form:"ua"`
+ IP string `form:"ip"`
+ IsCollapsed bool `form:"is_collapsed"`
+ IsPending bool `form:"is_pending"`
+ IsPinned bool `form:"is_pinned"`
+}
+
+// POST /api/admin/comment-edit
+func AdminCommentEdit(router fiber.Router) {
+ router.Post("/comment-edit", func(c *fiber.Ctx) error {
+ var p ParamsCommentEdit
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // find comment
+ comment := query.FindComment(p.ID)
+ if comment.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Comment")}))
+ }
+
+ if !common.IsAdminHasSiteAccess(c, comment.SiteName) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ // check params
+ if p.Email != "" && !utils.ValidateEmail(p.Email) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Email")}))
+ }
+ if p.Link != "" && !utils.ValidateURL(p.Link) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Link")}))
+ }
+
+ // content
+ if p.Content != "" {
+ comment.Content = p.Content
+ }
+
+ // rid
+ if p.Rid != "" {
+ if rid, err := strconv.Atoi(p.Rid); err == nil {
+ comment.Rid = uint(rid)
+ }
+ }
+
+ // merge user
+ originalUser := query.FetchUserForComment(&comment)
+ if p.Nick == "" {
+ p.Nick = originalUser.Name
+ }
+ if p.Email == "" {
+ p.Email = originalUser.Email
+ }
+
+ // find or save new user
+ user := query.FindCreateUser(p.Nick, p.Email, p.Link)
+ if user.ID != comment.UserID {
+ comment.UserID = user.ID
+ }
+
+ // user link update
+ if p.Link != "" && p.Link != user.Link {
+ user.Link = p.Link
+ query.UpdateUser(&user)
+ }
+
+ // pageKey
+ if p.PageKey != "" && p.PageKey != comment.PageKey {
+ query.FindCreatePage(p.PageKey, "", p.SiteName)
+ comment.PageKey = p.PageKey
+ }
+
+ comment.UA = p.UA
+ comment.IP = p.IP
+ comment.IsCollapsed = p.IsCollapsed
+ comment.IsPinned = p.IsPinned
+
+ if p.IsPending != comment.IsPending {
+ // 待审状态发生改变
+ comment.IsPending = p.IsPending
+
+ // 待审状态被修改为 false,则重新发送邮件通知
+ if !comment.IsPending {
+ RenotifyWhenPendingModified(&comment)
+ }
+ }
+
+ if err := query.UpdateComment(&comment); err != nil {
+ return common.RespError(c, i18n.T("{{name}} save failed", Map{"name": i18n.T("Comment")}))
+ }
+
+ return common.RespData(c, common.Map{
+ "comment": query.CookComment(&comment),
+ })
+ })
+}
+
+func RenotifyWhenPendingModified(comment *entity.Comment) {
+ if comment.Rid == 0 {
+ return // Root 评论不发送通知,因为这个评论已经被管理员看到了
+ }
+
+ pComment := query.FindComment(comment.Rid)
+ if query.FetchUserForComment(&pComment).IsAdmin {
+ return // 回复对象是管理员,则不再发送通知,因为已经看到了
+ }
+
+ if comment.UserID == pComment.UserID {
+ return // 自己回复自己,不通知
+ }
+
+ notify := query.FindCreateNotify(pComment.UserID, comment.ID)
+ if notify.IsEmailed {
+ return // 邮件已经发送过,则不再重复发送
+ }
+
+ notify.SetComment(*comment)
+ query.NotifySetInitial(¬ify)
+
+ // 邮件通知
+ email.AsyncSend(¬ify)
+}
diff --git a/server/handler/admin_page_del.go b/server/handler/admin_page_del.go
new file mode 100644
index 000000000..041a3aee2
--- /dev/null
+++ b/server/handler/admin_page_del.go
@@ -0,0 +1,43 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminPageDel struct {
+ Key string `form:"key" validate:"required"`
+ SiteName string
+ SiteID uint
+}
+
+// POST /api/admin/page-del
+func AdminPageDel(router fiber.Router) {
+ router.Post("/page-del", func(c *fiber.Ctx) error {
+ var p ParamsAdminPageDel
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, nil)
+
+ page := query.FindPage(p.Key, p.SiteName)
+ if page.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Page")}))
+ }
+
+ if !common.IsAdminHasSiteAccess(c, page.SiteName) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ err := query.DelPage(&page)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} deletion failed", Map{"name": i18n.T("Page")}))
+ }
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/admin_page_edit.go b/server/handler/admin_page_edit.go
new file mode 100644
index 000000000..3dd35e624
--- /dev/null
+++ b/server/handler/admin_page_edit.go
@@ -0,0 +1,84 @@
+package handler
+
+import (
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminPageEdit struct {
+ // 查询值
+ ID uint `form:"id"`
+ SiteName string
+ SiteID uint
+
+ // 修改值
+ Key string `form:"key"`
+ Title string `form:"title"`
+ AdminOnly bool `form:"admin_only"`
+}
+
+// POST /api/admin/page-edit
+func AdminPageEdit(router fiber.Router) {
+ router.Post("/page-edit", func(c *fiber.Ctx) error {
+ var p ParamsAdminPageEdit
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if strings.TrimSpace(p.Key) == "" {
+ return common.RespError(c, i18n.T("{{name}} cannot be empty", Map{"name": "key"}))
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, nil)
+
+ // find page
+ var page = query.FindPageByID(p.ID)
+ if page.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Page")}))
+ }
+
+ if !common.IsAdminHasSiteAccess(c, page.SiteName) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ // 重命名合法性检测
+ modifyKey := p.Key != page.Key
+ if modifyKey && !query.FindPage(p.Key, p.SiteName).IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} already exists", Map{"name": i18n.T("Page")}))
+ }
+
+ // 预先删除缓存,防止修改主键原有 page_key 占用问题
+ cache.PageCacheDel(&page)
+
+ page.Title = p.Title
+ page.AdminOnly = p.AdminOnly
+ if modifyKey {
+ // 相关性数据修改
+ var comments []entity.Comment
+ db.DB().Where("page_key = ?", page.Key).Find(&comments)
+
+ for _, comment := range comments {
+ comment.PageKey = p.Key
+ query.UpdateComment(&comment)
+ }
+
+ page.Key = p.Key
+ }
+
+ if err := query.UpdatePage(&page); err != nil {
+ return common.RespError(c, i18n.T("{{name}} save failed", Map{"name": i18n.T("Page")}))
+ }
+
+ return common.RespData(c, common.Map{
+ "page": query.CookPage(&page),
+ })
+ })
+}
diff --git a/server/handler/admin_page_fetch.go b/server/handler/admin_page_fetch.go
new file mode 100644
index 000000000..91f209bb6
--- /dev/null
+++ b/server/handler/admin_page_fetch.go
@@ -0,0 +1,99 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+type ParamsAdminPageFetch struct {
+ ID uint `form:"id"`
+ SiteName string
+
+ GetStatus bool `form:"get_status"`
+}
+
+var allPageFetching = false
+var allPageFetchDone = 0
+var allPageFetchTotal = 0
+
+// POST /api/admin/page-fetch
+func AdminPageFetch(router fiber.Router) {
+ router.Post("/page-fetch", func(c *fiber.Ctx) error {
+ var p ParamsAdminPageFetch
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ common.UseSite(c, &p.SiteName, nil, nil)
+
+ // 状态获取
+ if p.GetStatus {
+ if allPageFetching {
+ return common.RespData(c, common.Map{
+ "msg": i18n.T("{{done}} of {{total}} done", Map{"done": allPageFetchDone, "total": allPageFetchTotal}),
+ "is_progress": true,
+ })
+ } else {
+ return common.RespData(c, common.Map{
+ "msg": "",
+ "is_progress": false,
+ })
+ }
+ }
+
+ // 更新全部站点
+ if p.SiteName != "" {
+ if allPageFetching {
+ return common.RespError(c, i18n.T("Task in progress, please wait a moment"))
+ }
+
+ // 异步执行
+ go func() {
+ allPageFetching = true
+ allPageFetchDone = 0
+ allPageFetchTotal = 0
+ var pages []entity.Page
+ db := db.DB().Model(&entity.Page{})
+ if p.SiteName != config.ATK_SITE_ALL {
+ db = db.Where(&entity.Page{SiteName: p.SiteName})
+ }
+ db.Find(&pages)
+
+ allPageFetchTotal = len(pages)
+ for _, p := range pages {
+ if err := query.FetchPageFromURL(&p); err != nil {
+ logrus.Error(c, "[api_admin_page_fetch] page fetch error: "+err.Error())
+ } else {
+ allPageFetchDone++
+ }
+ }
+ allPageFetching = false
+ }()
+
+ return common.RespSuccess(c)
+ }
+
+ page := query.FindPageByID(p.ID)
+ if page.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Page")}))
+ }
+
+ if !common.IsAdminHasSiteAccess(c, page.SiteName) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ if err := query.FetchPageFromURL(&page); err != nil {
+ return common.RespError(c, i18n.T("Page fetch failed")+": "+err.Error())
+ }
+
+ return common.RespData(c, common.Map{
+ "page": query.CookPage(&page),
+ })
+ })
+}
diff --git a/server/handler/admin_page_get.go b/server/handler/admin_page_get.go
new file mode 100644
index 000000000..a79b0d169
--- /dev/null
+++ b/server/handler/admin_page_get.go
@@ -0,0 +1,67 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminPageGet struct {
+ SiteName string
+ SiteID uint
+ SiteAll bool
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+}
+
+type ResponseAdminPageGet struct {
+ Total int64 `json:"total"`
+ Pages []entity.CookedPage `json:"pages"`
+}
+
+// POST /api/admin/page-get
+func AdminPageGet(router fiber.Router) {
+ router.Post("/page-get", func(c *fiber.Ctx) error {
+ var p ParamsAdminPageGet
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ if !common.IsAdminHasSiteAccess(c, p.SiteName) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ // 准备 query
+ q := db.DB().Model(&entity.Page{}).Order("created_at DESC")
+ if !p.SiteAll { // 不是查的所有站点
+ q = q.Where("site_name = ?", p.SiteName)
+ }
+
+ // 总共条数
+ var total int64
+ q.Count(&total)
+
+ // 数据分页
+ q = q.Scopes(Paginate(p.Offset, p.Limit))
+
+ // 查找
+ var pages []entity.Page
+ q.Find(&pages)
+
+ var cookedPages []entity.CookedPage
+ for _, p := range pages {
+ cookedPages = append(cookedPages, query.CookPage(&p))
+ }
+
+ return common.RespData(c, ResponseAdminPageGet{
+ Pages: cookedPages,
+ Total: total,
+ })
+ })
+}
diff --git a/server/handler/admin_send_mail.go b/server/handler/admin_send_mail.go
new file mode 100644
index 000000000..202ae8919
--- /dev/null
+++ b/server/handler/admin_send_mail.go
@@ -0,0 +1,32 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/email"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminSendMail struct {
+ Subject string `form:"subject" validate:"required"`
+ Body string `form:"body" validate:"required"`
+ ToAddr string `form:"to_addr" validate:"required"`
+}
+
+// POST /api/admin/send-mail
+func AdminSendMail(router fiber.Router) {
+ router.Post("/send-mail", func(c *fiber.Ctx) error {
+ var p ParamsAdminSendMail
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ email.AsyncSendTo(p.Subject, p.Body, p.ToAddr)
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/admin_setting.go b/server/handler/admin_setting.go
new file mode 100644
index 000000000..42d96004f
--- /dev/null
+++ b/server/handler/admin_setting.go
@@ -0,0 +1,70 @@
+package handler
+
+import (
+ "os"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/core"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+// POST /api/admin/setting-get
+func AdminSettingGet(router fiber.Router) {
+ router.Post("/setting-get", func(c *fiber.Ctx) error {
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ dat, err := os.ReadFile(config.GetCfgFileLoaded())
+ if err != nil {
+ return common.RespError(c, i18n.T("Config file read failed"))
+ }
+
+ return common.RespData(c, string(dat))
+ })
+}
+
+type ParamsAdminSettingSave struct {
+ Data string `form:"data" validate:"required"`
+}
+
+// POST /api/admin/setting-save
+func AdminSettingSave(router fiber.Router) {
+ router.Post("/setting-save", func(c *fiber.Ctx) error {
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ var p ParamsAdminSettingSave
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ configFile := config.GetCfgFileLoaded()
+ f, err := os.Create(configFile)
+ if err != nil {
+ return common.RespError(c, i18n.T("Config file read failed")+": "+err.Error())
+ }
+
+ defer f.Close()
+
+ _, err2 := f.WriteString(p.Data)
+ if err2 != nil {
+ return common.RespError(c, i18n.T("Save failed")+": "+err2.Error())
+ }
+
+ // 重启服务
+ workDir, err3 := os.Getwd()
+ if err3 != nil {
+ return common.RespError(c, i18n.T("Working directory retrieval failed")+": "+err3.Error())
+ }
+ core.LoadCore(configFile, workDir)
+ common.ReloadCorsAllowOrigins() // 刷新 CORS 可信域名
+ logrus.Info(i18n.T("Services restart complete"))
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/admin_site_add.go b/server/handler/admin_site_add.go
new file mode 100644
index 000000000..107e9feb6
--- /dev/null
+++ b/server/handler/admin_site_add.go
@@ -0,0 +1,62 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminSiteAdd struct {
+ Name string `form:"name" validate:"required"`
+ Urls string `form:"urls"`
+}
+
+// POST /api/admin/site-add
+func AdminSiteAdd(router fiber.Router) {
+ router.Post("/site-add", func(c *fiber.Ctx) error {
+ var p ParamsAdminSiteAdd
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ if p.Urls != "" {
+ urls := utils.SplitAndTrimSpace(p.Urls, ",")
+ for _, url := range urls {
+ if !utils.ValidateURL(url) {
+ return common.RespError(c, i18n.T("Contains invalid URL"))
+ }
+ }
+ }
+
+ if p.Name == config.ATK_SITE_ALL {
+ return common.RespError(c, "Prohibit the use of reserved keywords as names")
+ }
+
+ if !query.FindSite(p.Name).IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} already exists", Map{"name": i18n.T("Site")}))
+ }
+
+ site := entity.Site{}
+ site.Name = p.Name
+ site.Urls = p.Urls
+ err := query.CreateSite(&site)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} creation failed", Map{"name": i18n.T("Site")}))
+ }
+
+ // 刷新 CORS 可信域名
+ common.ReloadCorsAllowOrigins()
+
+ return common.RespData(c, common.Map{
+ "site": query.CookSite(&site),
+ })
+ })
+}
diff --git a/server/handler/admin_site_del.go b/server/handler/admin_site_del.go
new file mode 100644
index 000000000..ed41fd96a
--- /dev/null
+++ b/server/handler/admin_site_del.go
@@ -0,0 +1,41 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminSiteDel struct {
+ ID uint `form:"id" validate:"required"`
+}
+
+// POST /api/admin/site-del
+func AdminSiteDel(router fiber.Router) {
+ router.Post("/site-del", func(c *fiber.Ctx) error {
+ var p ParamsAdminSiteDel
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ site := query.FindSiteByID(p.ID)
+ if site.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Site")}))
+ }
+
+ err := query.DelSite(&site)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} deletion failed", Map{"name": i18n.T("Site")}))
+ }
+
+ // 刷新 CORS 可信域名
+ common.ReloadCorsAllowOrigins()
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/admin_site_edit.go b/server/handler/admin_site_edit.go
new file mode 100644
index 000000000..55c33adfb
--- /dev/null
+++ b/server/handler/admin_site_edit.go
@@ -0,0 +1,99 @@
+package handler
+
+import (
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminSiteEdit struct {
+ // 查询值
+ ID uint `form:"id" validate:"required"`
+
+ // 修改值
+ Name string `form:"name"`
+ Urls string `form:"urls"`
+}
+
+// POST /api/admin/site-edit
+func AdminSiteEdit(router fiber.Router) {
+ router.Post("/site-edit", func(c *fiber.Ctx) error {
+ var p ParamsAdminSiteEdit
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ site := query.FindSiteByID(p.ID)
+ if site.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Site")}))
+ }
+
+ // 站点操作权限检查
+ if !common.IsAdminHasSiteAccess(c, site.Name) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ if strings.TrimSpace(p.Name) == "" {
+ return common.RespError(c, i18n.T("{{name}} cannot be empty", Map{"name": "name"}))
+ }
+
+ // 重命名合法性检测
+ modifyName := p.Name != site.Name
+ if modifyName && !query.FindSite(p.Name).IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} already exists", Map{"name": i18n.T("Site")}))
+ }
+
+ // urls 合法性检测
+ if p.Urls != "" {
+ for _, url := range utils.SplitAndTrimSpace(p.Urls, ",") {
+ if !utils.ValidateURL(url) {
+ return common.RespError(c, i18n.T("Contains invalid URL"))
+ }
+ }
+ }
+
+ // 预先删除缓存,防止修改主键原有 site_name 占用问题
+ cache.SiteCacheDel(&site)
+
+ // 同步变更 site_name
+ if modifyName {
+ var comments []entity.Comment
+ var pages []entity.Page
+
+ db.DB().Where("site_name = ?", site.Name).Find(&comments)
+ db.DB().Where("site_name = ?", site.Name).Find(&pages)
+
+ for _, comment := range comments {
+ comment.SiteName = p.Name
+ query.UpdateComment(&comment)
+ }
+ for _, page := range pages {
+ page.SiteName = p.Name
+ query.UpdatePage(&page)
+ }
+ }
+
+ // 修改 site
+ site.Name = p.Name
+ site.Urls = p.Urls
+
+ err := query.UpdateSite(&site)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} save failed", Map{"name": i18n.T("Site")}))
+ }
+
+ // 刷新 CORS 可信域名
+ common.ReloadCorsAllowOrigins()
+
+ return common.RespData(c, common.Map{
+ "site": query.CookSite(&site),
+ })
+ })
+}
diff --git a/server/handler/admin_site_get.go b/server/handler/admin_site_get.go
new file mode 100644
index 000000000..dc0c7ba7f
--- /dev/null
+++ b/server/handler/admin_site_get.go
@@ -0,0 +1,41 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminSiteGet struct {
+}
+
+// POST /api/admin/site-get
+func AdminSiteGet(router fiber.Router) {
+ router.Post("/site-get", func(c *fiber.Ctx) error {
+ var p ParamsAdminSiteGet
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ allSites := query.FindAllSitesCooked()
+ sites := allSites
+
+ // 非超级管理员仅显示分配的站点
+ if !common.GetIsSuperAdmin(c) {
+ sites = []entity.CookedSite{}
+ user := common.GetUserByReq(c)
+ userCooked := query.CookUser(&user)
+ for _, s := range allSites {
+ if utils.ContainsStr(userCooked.SiteNames, s.Name) {
+ sites = append(sites, s)
+ }
+ }
+ }
+
+ return common.RespData(c, common.Map{
+ "sites": sites,
+ })
+ })
+}
diff --git a/server/handler/admin_transfer.go b/server/handler/admin_transfer.go
new file mode 100644
index 000000000..16c46e372
--- /dev/null
+++ b/server/handler/admin_transfer.go
@@ -0,0 +1,146 @@
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "os"
+
+ "github.com/ArtalkJS/Artalk/internal/artransfer"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+ "gorm.io/gorm"
+)
+
+func AdminTransfer(router fiber.Router) {
+ router.Post("/import", adminImport)
+ router.Post("/import-upload", adminImportUpload)
+ router.Post("/export", adminExport)
+}
+
+type ParamsAdminImport struct {
+ Payload string `form:"payload"`
+}
+
+func adminImport(c *fiber.Ctx) error {
+ var p ParamsAdminImport
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ var payloadMapRaw map[string]interface{}
+ err := json.Unmarshal([]byte(p.Payload), &payloadMapRaw)
+ if err != nil {
+ return common.RespError(c, "Payload parsing error", common.Map{
+ "error": err,
+ })
+ }
+
+ payloadMap := map[string]string{}
+ for k, v := range payloadMapRaw {
+ payloadMap[k] = utils.ToString(v) // convert all value to string
+ }
+
+ payloadArr := []string{}
+ for k, v := range payloadMap {
+ payloadArr = append(payloadArr, k+":"+v)
+ }
+
+ if !common.GetIsSuperAdmin(c) {
+ user := common.GetUserByReq(c)
+ if sitName, isExist := payloadMap["t_name"]; isExist {
+ if !utils.ContainsStr(query.CookUser(&user).SiteNames, sitName) {
+ return common.RespError(c, "Destination site name of prohibited import")
+ }
+ } else {
+ return common.RespError(c, "Please fill in the target site name")
+ }
+ }
+
+ // TODO bcz 懒,先整这个缓冲输出,以后改成高级点的
+ r := c.Response()
+
+ r.Header.Add(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ r.SetStatusCode(fiber.StatusOK)
+
+ buf := bytes.NewBufferString("")
+ r.SetBodyStream(buf, -1)
+ r.ImmediateHeaderFlush = true
+
+ buf.Write([]byte(
+ `
+ `))
+
+ artransfer.Assumeyes = true
+ artransfer.HttpOutput = func(continueRun bool, text string) {
+ buf.Write([]byte(text))
+ buf.Write([]byte(""))
+ }
+ artransfer.RunImportArtrans(payloadArr)
+
+ // 刷新 CORS 可信域名
+ common.ReloadCorsAllowOrigins()
+
+ return nil
+}
+
+func adminImportUpload(c *fiber.Ctx) error {
+ // 获取 Form
+ file, err := c.FormFile("file")
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File read failed")
+ }
+
+ // 打开文件
+ src, err := file.Open()
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File open failed")
+ }
+ defer src.Close()
+
+ // 读取文件
+ buf, err := io.ReadAll(src)
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File read failed")
+ }
+
+ tmpFile, err := os.CreateTemp("", "artalk-import-file-")
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "tmp file creation failed")
+ }
+
+ tmpFile.Write(buf)
+
+ return common.RespData(c, common.Map{
+ "filename": tmpFile.Name(),
+ })
+}
+
+func adminExport(c *fiber.Ctx) error {
+ jsonStr, err := artransfer.ExportArtransString(func(db *gorm.DB) *gorm.DB {
+ if !common.GetIsSuperAdmin(c) {
+ // 仅导出限定范围内的站点
+ u := common.GetUserByReq(c)
+ db = db.Where("site_name IN (?)", query.CookUser(&u).SiteNames)
+ }
+
+ return db
+ })
+ if err != nil {
+ common.RespError(c, i18n.T("Export error"), common.Map{
+ "err": err,
+ })
+ }
+
+ return common.RespData(c, common.Map{
+ "data": jsonStr,
+ })
+}
diff --git a/server/handler/admin_user_add.go b/server/handler/admin_user_add.go
new file mode 100644
index 000000000..ca4765644
--- /dev/null
+++ b/server/handler/admin_user_add.go
@@ -0,0 +1,75 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+type ParamsAdminUserAdd struct {
+ Name string `form:"name" validate:"required"`
+ Email string `form:"email" validate:"required"`
+ Password string `form:"password"`
+ Link string `form:"link"`
+ IsAdmin bool `form:"is_admin" validate:"required"`
+ SiteNames string `form:"site_names"`
+ ReceiveEmail bool `form:"receive_email" validate:"required"`
+ BadgeName string `form:"badge_name"`
+ BadgeColor string `form:"badge_color"`
+}
+
+// POST /api/admin/user-add
+func AdminUserAdd(router fiber.Router) {
+ router.Post("/user-add", func(c *fiber.Ctx) error {
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ var p ParamsAdminUserAdd
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !query.FindUser(p.Name, p.Email).IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} already exists", Map{"name": i18n.T("User")}))
+ }
+
+ if !utils.ValidateEmail(p.Email) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Email")}))
+ }
+ if p.Link != "" && !utils.ValidateURL(p.Link) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Link")}))
+ }
+
+ user := entity.User{}
+ user.Name = p.Name
+ user.Email = p.Email
+ user.Link = p.Link
+ user.IsAdmin = p.IsAdmin
+ user.SiteNames = p.SiteNames
+ user.ReceiveEmail = p.ReceiveEmail
+ user.BadgeName = p.BadgeName
+ user.BadgeColor = p.BadgeColor
+
+ if p.Password != "" {
+ err := user.SetPasswordEncrypt(p.Password)
+ if err != nil {
+ logrus.Errorln(err)
+ return common.RespError(c, i18n.T("Password update failed"))
+ }
+ }
+
+ err := query.CreateUser(&user)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} creation failed", Map{"name": i18n.T("User")}))
+ }
+
+ return common.RespData(c, common.Map{
+ "user": query.UserToCookedForAdmin(&user),
+ })
+ })
+}
diff --git a/server/handler/admin_user_del.go b/server/handler/admin_user_del.go
new file mode 100644
index 000000000..2a0f292b1
--- /dev/null
+++ b/server/handler/admin_user_del.go
@@ -0,0 +1,37 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminUserDel struct {
+ ID uint `form:"id" validate:"required"`
+}
+
+// POST /api/admin/user-del
+func AdminUserDel(router fiber.Router) {
+ router.Post("/user-del", func(c *fiber.Ctx) error {
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ var p ParamsAdminUserDel
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ user := query.FindUserByID(p.ID)
+ if user.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("User")}))
+ }
+
+ err := query.DelUser(&user)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} deletion failed", Map{"name": i18n.T("User")}))
+ }
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/admin_user_edit.go b/server/handler/admin_user_edit.go
new file mode 100644
index 000000000..4fdebca04
--- /dev/null
+++ b/server/handler/admin_user_edit.go
@@ -0,0 +1,85 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/cache"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminUserEdit struct {
+ // 查询值
+ ID uint `form:"id" validate:"required"`
+
+ // 修改值
+ Name string `form:"name" validate:"required"`
+ Email string `form:"email" validate:"required"`
+ Password string `form:"password"`
+ Link string `form:"link"`
+ IsAdmin bool `form:"is_admin" validate:"required"`
+ SiteNames string `form:"site_names"`
+ ReceiveEmail bool `form:"receive_email" validate:"required"`
+ BadgeName string `form:"badge_name"`
+ BadgeColor string `form:"badge_color"`
+}
+
+// POST /api/admin/user-edit
+func AdminUserEdit(router fiber.Router) {
+ router.Post("/user-edit", func(c *fiber.Ctx) error {
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ var p ParamsAdminUserEdit
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ user := query.FindUserByID(p.ID)
+ if user.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("User")}))
+ }
+
+ // 改名名合法性检测
+ modifyName := p.Name != user.Name
+ modifyEmail := p.Email != user.Email
+
+ if modifyName && modifyEmail && !query.FindUser(p.Name, p.Email).IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} already exists", Map{"name": i18n.T("User")}))
+ }
+
+ if !utils.ValidateEmail(p.Email) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Email")}))
+ }
+ if p.Link != "" && !utils.ValidateURL(p.Link) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Link")}))
+ }
+
+ // 删除原有缓存
+ cache.UserCacheDel(&user)
+
+ // 修改 user
+ user.Name = p.Name
+ user.Email = p.Email
+ if p.Password != "" {
+ user.SetPasswordEncrypt(p.Password)
+ }
+ user.Link = p.Link
+ user.IsAdmin = p.IsAdmin
+ user.SiteNames = p.SiteNames
+ user.ReceiveEmail = p.ReceiveEmail
+ user.BadgeName = p.BadgeName
+ user.BadgeColor = p.BadgeColor
+
+ err := query.UpdateUser(&user)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} save failed", Map{"name": i18n.T("User")}))
+ }
+
+ return common.RespData(c, common.Map{
+ "user": query.UserToCookedForAdmin(&user),
+ })
+ })
+}
diff --git a/server/handler/admin_user_get.go b/server/handler/admin_user_get.go
new file mode 100644
index 000000000..9f60093f8
--- /dev/null
+++ b/server/handler/admin_user_get.go
@@ -0,0 +1,70 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminUserGet struct {
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+ Type string `form:"type"`
+}
+
+type ResponseAdminUserGet struct {
+ Total int64 `json:"total"`
+ Users []entity.CookedUserForAdmin `json:"users"`
+}
+
+// POST /api/admin/user-get
+func AdminUserGet(router fiber.Router) {
+ router.Post("/user-get", func(c *fiber.Ctx) error {
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ var p ParamsAdminUserGet
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // 准备 query
+ q := db.DB().Model(&entity.User{}).Order("created_at DESC")
+
+ // 总共条数
+ var total int64
+ q.Count(&total)
+
+ // 类型筛选
+ if p.Type == "" {
+ p.Type = "all" // 默认类型
+ }
+
+ if p.Type == "admin" {
+ q = q.Where("is_admin = ?", true)
+ } else if p.Type == "in_conf" {
+ q = q.Where("is_in_conf = ?", true)
+ }
+
+ // 数据分页
+ q = q.Scopes(Paginate(p.Offset, p.Limit))
+
+ // 查找
+ var users []entity.User
+ q.Find(&users)
+
+ var cookedUsers []entity.CookedUserForAdmin
+ for _, u := range users {
+ cookedUsers = append(cookedUsers, query.UserToCookedForAdmin(&u))
+ }
+
+ return common.RespData(c, ResponseAdminUserGet{
+ Users: cookedUsers,
+ Total: total,
+ })
+ })
+}
diff --git a/server/handler/admin_vote_sync.go b/server/handler/admin_vote_sync.go
new file mode 100644
index 000000000..d347d9766
--- /dev/null
+++ b/server/handler/admin_vote_sync.go
@@ -0,0 +1,29 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsAdminVoteSync struct {
+}
+
+// POST /api/admin/vote-sync
+func AdminVoteSync(router fiber.Router) {
+ router.Post("/vote-sync", func(c *fiber.Ctx) error {
+ var p ParamsAdminVoteSync
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !common.GetIsSuperAdmin(c) {
+ return common.RespError(c, i18n.T("Access denied"))
+ }
+
+ query.VoteSync()
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/captcha.go b/server/handler/captcha.go
new file mode 100644
index 000000000..b61b95c72
--- /dev/null
+++ b/server/handler/captcha.go
@@ -0,0 +1,125 @@
+package handler
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "text/template"
+
+ "github.com/ArtalkJS/Artalk/internal/captcha"
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+func Captcha(router fiber.Router) {
+ ca := router.Group("/captcha/", func(c *fiber.Ctx) error {
+ if !config.Instance.Captcha.Enabled {
+ return common.RespError(c, "Captcha disabled")
+ }
+ return c.Next()
+ })
+ {
+ ca.Post("/refresh", captchaGet)
+ ca.Get("/get", captchaGet)
+ ca.Post("/get", captchaGet)
+ ca.Post("/check", captchaCheck)
+ ca.Post("/status", captchaStatus)
+ }
+}
+
+// 获取当前状态,是否需要验证
+func captchaStatus(c *fiber.Ctx) error {
+ if common.IsReqNeedCaptchaCheck(c) {
+ return common.RespData(c, common.Map{"is_pass": false})
+ } else {
+ return common.RespData(c, common.Map{"is_pass": true})
+ }
+}
+
+// 获取验证码
+func captchaGet(c *fiber.Ctx) error {
+ ip := c.IP()
+
+ // ===========
+ // Geetest
+ // ===========
+ if config.Instance.Captcha.Geetest.Enabled {
+ pageFile, _ := captcha.GetPage("geetest.html")
+ buf, _ := io.ReadAll(pageFile)
+
+ var page bytes.Buffer
+
+ t := template.New("")
+ t.Parse(string(buf))
+ t.Execute(&page, map[string]interface{}{"gt_id": config.Instance.Captcha.Geetest.CaptchaID})
+
+ c.Set("Content-Type", "text/html")
+ return c.SendString(page.String())
+ }
+
+ // ===========
+ // 图片验证码
+ // ===========
+ return common.RespData(c, common.Map{
+ "img_data": common.GetNewImageCaptchaBase64(ip),
+ })
+}
+
+type ParamsCaptchaCheck struct {
+ Value string `form:"value" validate:"required"`
+}
+
+// 验证
+func captchaCheck(c *fiber.Ctx) error {
+ ip := c.IP()
+
+ var p ParamsCaptchaCheck
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+ inputVal := p.Value
+
+ // ===========
+ // Geetest
+ // ===========
+ if config.Instance.Captcha.Geetest.Enabled {
+ isPass, reason, err := captcha.GeetestCheck(inputVal)
+ if err != nil {
+ logrus.Error("[Geetest] Failed to verify: ", err)
+ return common.RespError(c, "Geetest API error")
+ }
+
+ if isPass {
+ // 验证成功
+ common.OnCaptchaPass(c)
+ return common.RespSuccess(c)
+ } else {
+ // 验证失败
+ common.OnCaptchaFail(c)
+ return common.RespError(c, i18n.T("Verification failed"), common.Map{
+ "reason": reason,
+ })
+ }
+ }
+
+ // ===========
+ // 图片验证码
+ // ===========
+ isPass := strings.ToLower(inputVal) == common.GetImageCaptchaRealCode(ip)
+ if isPass {
+ // 验证码正确
+ common.DisposeImageCaptcha(ip) // 销毁图片验证码
+ common.OnCaptchaPass(c)
+ return common.RespSuccess(c)
+ } else {
+ // 验证码错误
+ common.DisposeImageCaptcha(ip)
+ common.OnCaptchaFail(c)
+ return common.RespError(c, i18n.T("Wrong captcha"), common.Map{
+ "img_data": common.GetNewImageCaptchaBase64(ip),
+ })
+ }
+}
diff --git a/server/handler/comment_add.go b/server/handler/comment_add.go
new file mode 100644
index 000000000..9822a19ef
--- /dev/null
+++ b/server/handler/comment_add.go
@@ -0,0 +1,161 @@
+package handler
+
+import (
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/anti_spam"
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/notify_launcher"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+type ParamsAdd struct {
+ Name string `form:"name"`
+ Email string `form:"email"`
+ Link string `form:"link"`
+ Content string `form:"content" validate:"required"`
+ Rid uint `form:"rid"`
+ UA string `form:"ua"`
+
+ PageKey string `form:"page_key" validate:"required"`
+ PageTitle string `form:"page_title"`
+
+ Token string `form:"token"`
+ SiteName string
+ SiteID uint
+}
+
+type ResponseAdd struct {
+ Comment entity.CookedComment `json:"comment"`
+}
+
+// GET /api/add
+func CommentAdd(router fiber.Router) {
+ router.Post("/add", func(c *fiber.Ctx) error {
+ var p ParamsAdd
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if strings.TrimSpace(p.Name) == "" {
+ return common.RespError(c, i18n.T("{{name}} cannot be empty", Map{"name": i18n.T("Nickname")}))
+ }
+ if strings.TrimSpace(p.Email) == "" {
+ return common.RespError(c, i18n.T("{{name}} cannot be empty", Map{"name": i18n.T("Email")}))
+ }
+
+ if !utils.ValidateEmail(p.Email) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Email")}))
+ }
+ if p.Link != "" && !utils.ValidateURL(p.Link) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Link")}))
+ }
+
+ ip := c.IP()
+ ua := string(c.Request().Header.UserAgent())
+
+ // 允许传入修正后的 UA
+ if p.UA != "" {
+ ua = p.UA
+ }
+
+ // record action for limiting action
+ common.RecordAction(c)
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, nil)
+
+ // find page
+ page := query.FindCreatePage(p.PageKey, p.PageTitle, p.SiteName)
+
+ // check if the user is allowed to comment
+ if isAllowed, resp := common.CheckIsAllowed(c, p.Name, p.Email, page, p.SiteName); !isAllowed {
+ return resp
+ }
+
+ // check reply comment
+ var parentComment entity.Comment
+ if p.Rid != 0 {
+ parentComment = query.FindComment(p.Rid)
+ if parentComment.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Parent comment")}))
+ }
+ if parentComment.PageKey != p.PageKey {
+ return common.RespError(c, "Inconsistent with the page_key of the parent comment")
+ }
+ if !parentComment.IsAllowReply() {
+ return common.RespError(c, i18n.T("Cannot reply to this comment"))
+ }
+ }
+
+ // find user
+ user := query.FindCreateUser(p.Name, p.Email, p.Link)
+ if user.ID == 0 || page.Key == "" {
+ logrus.Error("Cannot get user or page")
+ return common.RespError(c, i18n.T("Comment failed"))
+ }
+
+ // update user
+ user.Link = p.Link
+ user.LastIP = ip
+ user.LastUA = ua
+ user.Name = p.Name // for 若用户修改用户名大小写
+ user.Email = p.Email
+ query.UpdateUser(&user)
+
+ comment := entity.Comment{
+ Content: p.Content,
+ PageKey: page.Key,
+ SiteName: p.SiteName,
+
+ UserID: user.ID,
+ IP: ip,
+ UA: ua,
+
+ Rid: p.Rid,
+
+ IsPending: false,
+ IsCollapsed: false,
+ IsPinned: false,
+ }
+
+ // default comment type
+ if !common.CheckIsAdminReq(c) && config.Instance.Moderator.PendingDefault {
+ // 不是管理员评论 && 配置开启评论默认待审
+ comment.IsPending = true
+ }
+
+ // save to database
+ err := query.CreateComment(&comment)
+ if err != nil {
+ logrus.Error("Save Comment error: ", err)
+ return common.RespError(c, i18n.T("Comment failed"))
+ }
+
+ // 异步执行
+ go func() {
+ // Page Update
+ if query.CookPage(&page).URL != "" && page.Title == "" {
+ query.FetchPageFromURL(&page)
+ }
+
+ // 垃圾检测
+ if !common.CheckIsAdminReq(c) { // 忽略检查管理员
+ anti_spam.SyncSpamCheck(&comment, c) // 同步执行
+ }
+
+ // 通知发送
+ notify_launcher.SendNotify(&comment, &parentComment)
+ }()
+
+ return common.RespData(c, ResponseAdd{
+ Comment: query.CookComment(&comment),
+ })
+ })
+}
diff --git a/server/handler/comment_get.go b/server/handler/comment_get.go
new file mode 100644
index 000000000..d66d9d085
--- /dev/null
+++ b/server/handler/comment_get.go
@@ -0,0 +1,185 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+)
+
+type ParamsGet struct {
+ PageKey string `form:"page_key" validate:"required"`
+ SiteName string
+
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+
+ FlatMode bool `form:"flat_mode"`
+ SortBy string `form:"sort_by"` // date_asc, date_desc, vote
+ ViewOnlyAdmin bool `form:"view_only_admin"` // 只看 admin
+
+ Search string `form:"search"`
+
+ // Message Center
+ Type string `form:"type"` // ["", "all", "mentions", "mine", "pending", "admin_all", "admin_pending"]
+ Name string `form:"name"`
+ Email string `form:"email"`
+
+ SiteID uint
+ SiteAll bool
+
+ IsMsgCenter bool
+ User *entity.User
+ IsAdminReq bool
+}
+
+type ResponseGet struct {
+ Comments []entity.CookedComment `json:"comments"`
+ Total int64 `json:"total"`
+ TotalRoots int64 `json:"total_roots"`
+ Page entity.CookedPage `json:"page"`
+ Unread []entity.CookedNotify `json:"unread"`
+ UnreadCount int `json:"unread_count"`
+ ApiVersion common.Map `json:"api_version"`
+ Conf common.Map `json:"conf,omitempty"`
+}
+
+// POST /api/get
+func CommentGet(router fiber.Router) {
+ router.Post("/get", func(c *fiber.Ctx) error {
+ var p ParamsGet
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // handle params
+ UseCfgFrontend(&p)
+
+ // find page
+ var page entity.Page
+ if !p.SiteAll {
+ page = query.FindPage(p.PageKey, p.SiteName)
+ if page.IsEmpty() { // if page not found
+ page = entity.Page{
+ Key: p.PageKey,
+ SiteName: p.SiteName,
+ }
+ }
+ }
+
+ // find user
+ var user entity.User
+ if p.Name != "" && p.Email != "" {
+ user = query.FindUser(p.Name, p.Email)
+ p.User = &user // init params user field
+ }
+
+ // check if admin
+ if common.CheckIsAdminReq(c) {
+ p.IsAdminReq = true
+ }
+
+ // check if msg center
+ if p.Type != "" && p.Name != "" && p.Email != "" {
+ p.IsMsgCenter = true
+ p.FlatMode = true // 通知中心强制平铺模式
+ }
+
+ // prepare the first query
+ findScopes := []func(*gorm.DB) *gorm.DB{}
+ if !p.FlatMode {
+ // nested_mode prepare the root comments as first query result
+ findScopes = append(findScopes, RootComments())
+ }
+ if !p.IsMsgCenter {
+ // pinned comments ignore
+ findScopes = append(findScopes, func(d *gorm.DB) *gorm.DB {
+ return d.Where("is_pinned = ?", false) // 因为置顶是独立的查询,这里就不再查)
+ })
+ }
+
+ // search function
+ if p.Search != "" {
+ findScopes = append(findScopes, CommentSearchScope(p))
+ }
+
+ // get comments for the first query
+ var comments []entity.Comment
+ GetCommentQuery(c, p, p.SiteID, findScopes...).Scopes(Paginate(p.Offset, p.Limit)).Find(&comments)
+
+ // prepend the pinned comments
+ prependPinnedComments(c, p, &comments)
+
+ // cook
+ cookedComments := query.CookAllComments(comments)
+
+ switch {
+ case !p.FlatMode:
+ // ==========
+ // 层级嵌套模式
+ // ==========
+
+ // 获取 comment 子评论
+ for _, parent := range cookedComments { // TODO: Read more children, pagination for children comment
+ children := query.FindCommentChildren(parent.ID, SiteIsolationChecker(c, p), AllowedCommentChecker(c, p))
+ cookedComments = append(cookedComments, query.CookAllComments(children)...)
+ }
+
+ case p.FlatMode:
+ // ==========
+ // 平铺模式
+ // ==========
+
+ // find linked comments (被引用的评论,不单独显示)
+ for _, comment := range comments {
+ if comment.Rid == 0 || entity.ContainsCookedComment(cookedComments, comment.Rid) {
+ continue
+ }
+
+ rComment := query.FindComment(comment.Rid, SiteIsolationChecker(c, p)) // 查找被回复的评论
+ if rComment.IsEmpty() {
+ continue
+ }
+
+ rCooked := query.CookComment(&rComment)
+ rCooked.Visible = false // 设置为不可见
+ cookedComments = append(cookedComments, rCooked)
+ }
+ }
+
+ // count comments
+ total := CountComments(GetCommentQuery(c, p, p.SiteID))
+ totalRoots := CountComments(GetCommentQuery(c, p, p.SiteID, RootComments()))
+
+ // mark all as read in msg center
+ if p.IsMsgCenter {
+ query.UserNotifyMarkAllAsRead(p.User.ID)
+ }
+
+ // unread notifies
+ var unreadNotifies = []entity.CookedNotify{}
+ if p.User != nil {
+ unreadNotifies = query.CookAllNotifies(query.FindUnreadNotifies(p.User.ID))
+ }
+
+ resp := ResponseGet{
+ Comments: cookedComments,
+ Total: total,
+ TotalRoots: totalRoots,
+ Page: query.CookPage(&page),
+ Unread: unreadNotifies,
+ UnreadCount: len(unreadNotifies),
+ ApiVersion: common.GetApiVersionDataMap(),
+ }
+
+ if p.Offset == 0 {
+ resp.Conf = common.GetApiPublicConfDataMap(c)
+ }
+
+ return common.RespData(c, resp)
+ })
+}
diff --git a/server/handler/comment_get_query.go b/server/handler/comment_get_query.go
new file mode 100644
index 000000000..53a2835b8
--- /dev/null
+++ b/server/handler/comment_get_query.go
@@ -0,0 +1,244 @@
+package handler
+
+import (
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+)
+
+// 获取评论查询实例
+func GetCommentQuery(c *fiber.Ctx, p ParamsGet, siteID uint, scopes ...func(*gorm.DB) *gorm.DB) *gorm.DB {
+ q := db.DB().Model(&entity.Comment{})
+
+ q.Scopes(SiteIsolationScope(c, p), AllowedComment(c, p))
+ q.Order(GetSortRuleSQL(p.SortBy, "created_at DESC")) // 排序规则
+ q.Scopes(scopes...)
+
+ switch {
+ case !p.IsMsgCenter:
+ // ==========
+ // 非通知中心
+ // ==========
+
+ // 只看管理员功能
+ if p.ViewOnlyAdmin {
+ adminIDs := query.GetAllAdminIDs() // 获取管理员列表
+ q.Where("user_id IN ?", adminIDs) // 只允许管理员 user_id
+ }
+
+ q.Where("page_key = ?", p.PageKey)
+
+ case p.IsMsgCenter:
+ // ==========
+ // 通知中心
+ // ==========
+ user := p.User
+ if user == nil || user.IsEmpty() {
+ return q.Where("id = 0") // user not found
+ }
+
+ // admin_only 检测
+ if strings.HasPrefix(p.Type, "admin_") {
+ if !p.IsAdminReq || !common.IsAdminHasSiteAccess(c, p.SiteName) {
+ return q.Where("id = 0")
+ }
+ }
+
+ switch p.Type {
+ case "all":
+ q.Where("rid IN (?) OR user_id = ?", query.GetUserAllCommentIDs(user.ID), user.ID)
+ case "mentions":
+ q.Where("rid IN (?) AND user_id != ?", query.GetUserAllCommentIDs(user.ID), user.ID)
+ case "mine":
+ q.Where("user_id = ?", user.ID)
+ case "pending":
+ q.Where("user_id = ? AND is_pending = ?", user.ID, true)
+ case "admin_all":
+
+ case "admin_pending":
+ q.Where("is_pending = ?", true)
+ default:
+ return q.Where("id = 0")
+ }
+ }
+
+ return q
+}
+
+// 根评论 Root comments
+func RootComments() func(db *gorm.DB) *gorm.DB {
+ return func(db *gorm.DB) *gorm.DB {
+ return db.Where("rid = 0")
+ }
+}
+
+// 允许的评论
+func AllowedComment(c *fiber.Ctx, p ParamsGet) func(db *gorm.DB) *gorm.DB {
+ return func(db *gorm.DB) *gorm.DB {
+ if common.CheckIsAdminReq(c) {
+ return db // 管理员显示全部
+ }
+
+ // 通知中心允许显示个人的待审状态的评论
+ if p.IsMsgCenter && !p.User.IsEmpty() {
+ return db.Where("is_pending = ? OR (is_pending = ? AND user_id = ?)", false, true, p.User.ID)
+ }
+
+ return db.Where("is_pending = ?", false) // 不允许待审评论
+ }
+}
+
+func AllowedCommentChecker(c *fiber.Ctx, p ParamsGet) func(*entity.Comment) bool {
+ return func(comment *entity.Comment) bool {
+ if common.CheckIsAdminReq(c) {
+ return true // 管理员显示全部
+ }
+
+ // 通知中心允许显示个人的待审状态的评论
+ if p.IsMsgCenter && p.User.ID == comment.UserID {
+ return true
+ }
+
+ // 不允许待审评论
+ if comment.IsPending {
+ return false
+ }
+
+ return true
+ }
+}
+
+func SiteIsolationChecker(c *fiber.Ctx, p ParamsGet) func(*entity.Comment) bool {
+ return func(comment *entity.Comment) bool {
+ if common.CheckIsAdminReq(c) && p.SiteAll {
+ return true // 仅管理员支持取消站点隔离
+ }
+
+ if comment.SiteName != p.SiteName {
+ return false
+ }
+
+ return true
+ }
+}
+
+// 站点隔离
+func SiteIsolationScope(c *fiber.Ctx, p ParamsGet) func(db *gorm.DB) *gorm.DB {
+ return func(db *gorm.DB) *gorm.DB {
+ if common.CheckIsAdminReq(c) && p.SiteAll {
+ return db // 仅管理员支持取消站点隔离
+ }
+
+ return db.Where("site_name = ?", p.SiteName)
+ }
+}
+
+// 排序规则
+func GetSortRuleSQL(sortBy string, defaultSQL string) string {
+ switch sortBy {
+ case "date_desc":
+ return "created_at DESC"
+ case "date_asc":
+ return "created_at ASC"
+ case "vote":
+ return "vote_up DESC, created_at DESC"
+ }
+
+ return defaultSQL
+}
+
+// 评论计数
+func CountComments(db *gorm.DB) int64 {
+ var count int64
+ db.Count(&count)
+ return count
+}
+
+// 评论搜索
+func CommentSearchScope(p ParamsGet) func(d *gorm.DB) *gorm.DB {
+ var userIds []uint
+ db.DB().Model(&entity.User{}).Where(
+ "LOWER(name) = LOWER(?) OR LOWER(email) = LOWER(?)", p.Search, p.Search,
+ ).Pluck("id", &userIds)
+
+ return func(d *gorm.DB) *gorm.DB {
+ return d.Where("user_id IN (?) OR content LIKE ? OR page_key = ? OR ip = ? OR ua = ?",
+ userIds, "%"+p.Search+"%", p.Search, p.Search, p.Search)
+ }
+}
+
+// 分页
+func Paginate(offset int, limit int) func(db *gorm.DB) *gorm.DB {
+ if offset < 0 {
+ offset = 0
+ }
+
+ if limit > 100 {
+ limit = 100
+ } else if limit <= 0 {
+ limit = 15
+ }
+
+ return func(db *gorm.DB) *gorm.DB {
+ return db.Offset(offset).Limit(limit)
+ }
+}
+
+// 插入置顶的评论
+func prependPinnedComments(c *fiber.Ctx, p ParamsGet, comments *[]entity.Comment) {
+ if p.IsMsgCenter || p.Offset != 0 {
+ return // 通知中心关闭置顶 & 仅在分页的首页加入置顶评论
+ }
+
+ pinnedComments := []entity.Comment{}
+ GetCommentQuery(c, p, p.SiteID).Where("is_pinned = ?", true).Find(&pinnedComments)
+ if len(pinnedComments) == 0 {
+ return // 没有置顶评论
+ }
+
+ // 去掉已 pinned 且重复存在于原列表中的评论
+ filteredComments := []entity.Comment{}
+ for _, co := range *comments {
+ if !entity.ContainsComment(pinnedComments, co.ID) {
+ filteredComments = append(filteredComments, co)
+ }
+ }
+
+ // prepend
+ *comments = append(pinnedComments, filteredComments...)
+}
+
+// 处理来自配置文件的 fronted 配置项
+func UseCfgFrontend(p *ParamsGet) {
+ feConf := config.Instance.Frontend
+ if feConf == nil || reflect.ValueOf(feConf).Kind() != reflect.Map {
+ return
+ }
+
+ // pagination
+ (func() {
+ pagination, isExist := feConf["pagination"]
+ if !isExist {
+ return
+ }
+ if reflect.ValueOf(pagination).Kind() != reflect.Map {
+ return
+ }
+
+ // pagination.pageSize
+ cfgPageSizeStr := fmt.Sprintf("%v", pagination.(common.Map)["pageSize"])
+ confPageSize, err := strconv.Atoi(cfgPageSizeStr)
+ if err == nil && confPageSize > 0 {
+ p.Limit = confPageSize
+ }
+ })()
+}
diff --git a/server/handler/conf.go b/server/handler/conf.go
new file mode 100644
index 000000000..117e89cd3
--- /dev/null
+++ b/server/handler/conf.go
@@ -0,0 +1,13 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+// GET /conf
+func Conf(router fiber.Router) {
+ router.All("/conf", func(c *fiber.Ctx) error {
+ return common.RespData(c, common.GetApiPublicConfDataMap(c))
+ })
+}
diff --git a/server/handler/img_upload.go b/server/handler/img_upload.go
new file mode 100644
index 000000000..ae8bb7eb7
--- /dev/null
+++ b/server/handler/img_upload.go
@@ -0,0 +1,230 @@
+package handler
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "github.com/sirupsen/logrus"
+)
+
+type ParamsImgUpload struct {
+ Name string `form:"name" validate:"required"`
+ Email string `form:"email" validate:"required"`
+
+ PageKey string `form:"page_key" validate:"required"`
+ PageTitle string `form:"page_title"`
+
+ SiteName string
+
+ SiteID uint
+ SiteAll bool
+}
+
+// POST /api/img-upl
+func ImgUpload(router fiber.Router) {
+ router.Post("/img-upload", func(c *fiber.Ctx) error {
+ // 功能开关 (管理员始终开启)
+ if !config.Instance.ImgUpload.Enabled && !common.CheckIsAdminReq(c) {
+ return common.RespError(c, i18n.T("Image upload forbidden"), common.Map{
+ "img_upload_enabled": false,
+ })
+ }
+
+ // 传入参数解析
+ var p ParamsImgUpload
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ if !utils.ValidateEmail(p.Email) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Email")}))
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // 记录请求次数 (for 请求频率限制)
+ common.RecordAction(c)
+
+ // find page
+ // page := entity.FindPage(p.PageKey, p.PageTitle)
+ // ip := c.RealIP()
+ // ua := c.Request().UserAgent()
+
+ // 图片大小限制 (Based on content length)
+ if config.Instance.ImgUpload.MaxSize != 0 {
+ if int64(c.Request().Header.ContentLength()) > config.Instance.ImgUpload.MaxSize*1024*1024 {
+ return common.RespError(c, i18n.T("Image exceeds {{file_size}} limit", Map{
+ "file_size": fmt.Sprintf("%dMB", config.Instance.ImgUpload.MaxSize),
+ }))
+ }
+ }
+
+ // 获取 Form
+ file, err := c.FormFile("file")
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File read failed")
+ }
+
+ // 打开文件
+ src, err := file.Open()
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File open failed")
+ }
+ defer src.Close()
+
+ // 读取文件
+ buf, err := io.ReadAll(src)
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File read failed")
+ }
+
+ // 大小限制 (Based on content read)
+ if config.Instance.ImgUpload.MaxSize != 0 {
+ if int64(len(buf)) > config.Instance.ImgUpload.MaxSize*1024*1024 {
+ return common.RespError(c, i18n.T("Image exceeds {{file_size}} limit", Map{
+ "file_size": fmt.Sprintf("%dMB", config.Instance.ImgUpload.MaxSize),
+ }))
+ }
+ }
+
+ // 文件格式判断
+ // @link https://mimesniff.spec.whatwg.org/
+ // @link https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Basics_of_HTTP/MIME_types
+ fileMine := http.DetectContentType(buf)
+ allowMines := []string{
+ "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp",
+ // "image/svg+xml",
+ }
+ if !utils.ContainsStr(allowMines, fileMine) {
+ return common.RespError(c, i18n.T("Unsupported formats"))
+ }
+
+ // 图片文件名
+ mineToExts := map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ // "image/svg+xml": ".svg",
+ }
+
+ t := time.Now()
+ filename := t.Format("20060102-150405.000") + mineToExts[fileMine]
+
+ // 创建图片目标文件
+ if err := utils.EnsureDir(config.Instance.ImgUpload.Path); err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "Folder creation failed")
+ }
+
+ fileFullPath := strings.TrimSuffix(config.Instance.ImgUpload.Path, "/") + "/" + filename
+ dst, err := os.Create(fileFullPath)
+ if err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File creation failed")
+ }
+ defer dst.Close()
+
+ // 写入图片文件
+ if _, err = dst.Write(buf); err != nil {
+ logrus.Error(err)
+ return common.RespError(c, "File write failed")
+ }
+
+ // 生成外部可访问链接
+ baseURL := config.Instance.ImgUpload.PublicPath
+ if baseURL == "" {
+ baseURL = config.IMG_UPLOAD_PUBLIC_PATH
+ }
+ imgURL := path.Join(baseURL, filename)
+
+ // 使用 upgit
+ if config.Instance.ImgUpload.Upgit.Enabled {
+ upgitURL := execUpgitUpload(fileFullPath)
+ if upgitURL == "" || !utils.ValidateURL(upgitURL) {
+ // 上传失败,删除源图片文件
+ var err = os.Remove(fileFullPath)
+ if err != nil {
+ logrus.Error(err)
+ }
+
+ logrus.Error("[IMG_UPLOAD] [upgit] upgit output: ", upgitURL)
+ return common.RespError(c, i18n.T("Upload image via {{method}} failed", Map{"method": "upgit"}))
+ }
+
+ // 上传成功,删除本地文件
+ if config.Instance.ImgUpload.Upgit.DelLocal {
+ var err = os.Remove(fileFullPath)
+ if err != nil {
+ logrus.Error(err)
+ }
+ }
+
+ // 使用从 upgit 获取的图片 URL
+ imgURL = upgitURL
+ }
+
+ // 响应数据
+ return common.RespData(c, common.Map{
+ "img_file": filename,
+ "img_url": imgURL,
+ })
+ })
+}
+
+// 调用 upgit 上传图片获得 URL
+func execUpgitUpload(filename string) string {
+ LogTag := "[IMG_UPLOAD] [upgit] "
+
+ // 处理参数
+ cmdStrSplitted := strings.Split(config.Instance.ImgUpload.Upgit.Exec, " ")
+ execApp := cmdStrSplitted[0]
+ execArgs := []string{}
+ for i, arg := range cmdStrSplitted {
+ if i > 0 {
+ execArgs = append(execArgs, arg)
+ }
+ }
+ execArgs = append(execArgs, filename)
+
+ // 执行命令
+ cmd := exec.Command(execApp, execArgs...)
+ stdout, _ := cmd.StdoutPipe()
+
+ if err := cmd.Start(); err != nil {
+ logrus.Error(LogTag, "cmd.Start: ", err)
+ return ""
+ }
+
+ result, _ := io.ReadAll(stdout)
+ if err := cmd.Wait(); err != nil {
+ if exiterr, ok := err.(*exec.ExitError); ok {
+ if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
+ logrus.Error(LogTag, "Exit Status: ", status.ExitStatus())
+ }
+ } else {
+ logrus.Error(LogTag, "cmd.Wait: ", err)
+ }
+
+ return ""
+ }
+
+ return strings.TrimSpace(string(result))
+}
diff --git a/server/handler/mark_read.go b/server/handler/mark_read.go
new file mode 100644
index 000000000..5ac2587ce
--- /dev/null
+++ b/server/handler/mark_read.go
@@ -0,0 +1,66 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsMarkRead struct {
+ NotifyKey string `form:"notify_key"`
+
+ Name string `form:"name"`
+ Email string `form:"email"`
+ AllRead bool `form:"all_read"`
+
+ SiteName string
+ SiteID uint
+ SiteAll bool
+}
+
+// POST /api/mark-read
+func MarkRead(router fiber.Router) {
+ router.Post("/mark-read", func(c *fiber.Ctx) error {
+ var p ParamsMarkRead
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // all read
+ if p.AllRead {
+ if p.Name == "" || p.Email == "" {
+ return common.RespError(c, "username or email cannot be empty")
+ }
+
+ user := query.FindUser(p.Name, p.Email)
+ err := query.UserNotifyMarkAllAsRead(user.ID)
+ if err != nil {
+ return common.RespError(c, err.Error())
+ }
+
+ return common.RespSuccess(c)
+ }
+
+ // find notify
+ notify := query.FindNotifyByKey(p.NotifyKey)
+ if notify.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Notify")}))
+ }
+
+ if notify.IsRead {
+ return common.RespSuccess(c)
+ }
+
+ // update notify
+ err := query.NotifySetRead(¬ify)
+ if err != nil {
+ return common.RespError(c, i18n.T("{{name}} save failed", Map{"name": i18n.T("Notify")}))
+ }
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/pv.go b/server/handler/pv.go
new file mode 100644
index 000000000..612e0dc71
--- /dev/null
+++ b/server/handler/pv.go
@@ -0,0 +1,42 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsPV struct {
+ PageKey string `form:"page_key" validate:"required"`
+ PageTitle string `form:"page_title"`
+
+ SiteName string
+ SiteID uint
+ SiteAll bool
+}
+
+// POST /api/pv
+func PV(router fiber.Router) {
+ router.Post("/pv", func(c *fiber.Ctx) error {
+ var p ParamsPV
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // find page
+ page := query.FindCreatePage(p.PageKey, p.PageTitle, p.SiteName)
+
+ // ip := c.RealIP()
+ // ua := c.Request().UserAgent()
+
+ page.PV++
+ query.UpdatePage(&page)
+
+ return common.RespData(c, common.Map{
+ "pv": page.PV,
+ })
+ })
+}
diff --git a/server/handler/stat.go b/server/handler/stat.go
new file mode 100644
index 000000000..d042d790f
--- /dev/null
+++ b/server/handler/stat.go
@@ -0,0 +1,165 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+)
+
+type ParamsStat struct {
+ Type string `form:"type" validate:"required"`
+
+ SiteName string
+ PageKeys string `form:"page_keys"`
+
+ Limit int `form:"limit"`
+
+ SiteID uint
+ SiteAll bool
+}
+
+// POST /api/stat
+func Stat(router fiber.Router) {
+ router.Post("/stat", func(c *fiber.Ctx) error {
+ var p ParamsStat
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // Limit 限定
+ if p.Limit <= 0 {
+ p.Limit = 5
+ }
+ if p.Limit > 100 {
+ p.Limit = 100
+ }
+
+ // 公共查询规则
+ QueryPages := func(d *gorm.DB) *gorm.DB {
+ return d.Model(&entity.Page{}).Where("site_name = ?", p.SiteName)
+ }
+ QueryComments := func(d *gorm.DB) *gorm.DB {
+ return d.Model(&entity.Comment{}).Where("site_name = ? AND is_pending = ?", p.SiteName, false)
+ }
+ QueryOrderRand := func(d *gorm.DB) *gorm.DB {
+ if config.Instance.DB.Type == config.TypeSQLite {
+ return d.Order("RANDOM()") // SQLite case
+ } else {
+ return d.Order("RAND()")
+ }
+ }
+
+ switch p.Type {
+ case "latest_comments":
+ // 最新评论
+ var comments []entity.Comment
+ db.DB().Scopes(QueryComments).
+ Order("created_at DESC").
+ Limit(p.Limit).
+ Find(&comments)
+
+ return common.RespData(c, query.CookAllComments(comments))
+
+ case "latest_pages":
+ // 最新页面
+ var pages []entity.Page
+ db.DB().Scopes(QueryPages).
+ Order("created_at DESC").
+ Limit(p.Limit).
+ Find(&pages)
+
+ return common.RespData(c, query.CookAllPages(pages))
+
+ case "pv_most_pages":
+ // PV 数最多的页面
+ var pages []entity.Page
+ db.DB().Scopes(QueryPages).
+ Order("pv DESC").
+ Limit(p.Limit).
+ Find(&pages)
+
+ return common.RespData(c, query.CookAllPages(pages))
+
+ case "comment_most_pages":
+ // 评论数最多的页面
+ var pages []entity.Page
+ db.DB().Raw(
+ "SELECT * FROM pages p WHERE p.site_name = ? ORDER BY (SELECT COUNT(*) FROM comments c WHERE c.page_key = p.key AND c.is_pending = ?) DESC LIMIT ?",
+ p.SiteName, false, p.Limit,
+ ).Find(&pages)
+
+ return common.RespData(c, query.CookAllPages(pages))
+
+ case "page_pv":
+ // 查询页面的 PV 数
+ keys := utils.SplitAndTrimSpace(p.PageKeys, ",")
+ pvs := map[string]int{}
+ for _, k := range keys {
+ page := query.FindPage(k, p.SiteName)
+ if !page.IsEmpty() {
+ pvs[k] = page.PV
+ } else {
+ pvs[k] = 0
+ }
+ }
+
+ return common.RespData(c, pvs)
+
+ case "site_pv":
+ // 全站 PV 数
+ var pv int64
+ db.DB().Raw("SELECT SUM(pv) FROM pages WHERE site_name = ?", p.SiteName).Row().Scan(&pv)
+
+ return common.RespData(c, pv)
+
+ case "page_comment":
+ // 查询页面的评论数
+ keys := utils.SplitAndTrimSpace(p.PageKeys, ",")
+ counts := map[string]int64{}
+ for _, k := range keys {
+ var count int64
+ db.DB().Scopes(QueryComments).Where("page_key = ?", k).Count(&count)
+
+ counts[k] = count
+ }
+
+ return common.RespData(c, counts)
+
+ case "site_comment":
+ // 全站评论数
+ var count int64
+ db.DB().Scopes(QueryComments).Count(&count)
+
+ return common.RespData(c, count)
+
+ case "rand_comments":
+ // 随机评论
+ var comments []entity.Comment
+ db.DB().Scopes(QueryComments, QueryOrderRand).
+ Limit(p.Limit).
+ Find(&comments)
+
+ return common.RespData(c, query.CookAllComments(comments))
+
+ case "rand_pages":
+ // 随机页面
+ var pages []entity.Page
+ db.DB().Scopes(QueryPages, QueryOrderRand).
+ Limit(p.Limit).
+ Find(&pages)
+
+ return common.RespData(c, query.CookAllPages(pages))
+ }
+
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Type")}))
+ })
+}
diff --git a/server/handler/user_get.go b/server/handler/user_get.go
new file mode 100644
index 000000000..49d8dce15
--- /dev/null
+++ b/server/handler/user_get.go
@@ -0,0 +1,45 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsUserGet struct {
+ Name string `form:"name"`
+ Email string `form:"email"`
+}
+
+// POST /api/user-get
+func UserGet(router fiber.Router) {
+ router.Post("/user-get", func(c *fiber.Ctx) error {
+ var p ParamsUserGet
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // login status
+ isLogin := !common.GetUserByReq(c).IsEmpty()
+
+ user := query.FindUser(p.Name, p.Email)
+ if user.IsEmpty() {
+ return common.RespData(c, common.Map{
+ "user": nil,
+ "is_login": isLogin,
+ "unread": []interface{}{},
+ "unread_count": 0,
+ })
+ }
+
+ // unread notifies
+ unreadNotifies := query.CookAllNotifies(query.FindUnreadNotifies(user.ID))
+
+ return common.RespData(c, common.Map{
+ "user": query.CookUser(&user),
+ "is_login": isLogin,
+ "unread": unreadNotifies,
+ "unread_count": len(unreadNotifies),
+ })
+ })
+}
diff --git a/server/handler/user_login.go b/server/handler/user_login.go
new file mode 100644
index 000000000..7f370f97d
--- /dev/null
+++ b/server/handler/user_login.go
@@ -0,0 +1,161 @@
+package handler
+
+import (
+ "crypto/md5"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type ParamsLogin struct {
+ Name string `form:"name"`
+ Email string `form:"email" validate:"required"`
+ Password string `form:"password" validate:"required"`
+}
+
+// POST /api/login
+func UserLogin(router fiber.Router) {
+ router.Post("/login", func(c *fiber.Ctx) error {
+ var p ParamsLogin
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // 账户读取
+ var user entity.User
+ if p.Name == "" {
+ // 仅 Email 的查询
+ if !utils.ValidateEmail(p.Email) {
+ return common.RespError(c, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Email")}))
+ }
+ users := query.FindUsersByEmail(p.Email)
+ if len(users) == 1 {
+ // 仅有一个 email 匹配的用户
+ user = users[0]
+ } else if len(users) > 1 {
+ // 存在多个 email 匹配的用户
+ userNames := []string{}
+ for _, u := range users {
+ userNames = append(userNames, u.Name)
+ }
+ return common.RespError(c, "Need to select username", common.Map{
+ // 前端需做处理让用户选择用户名,
+ // 之后再发起带 name 参数的请求
+ "need_name_select": userNames,
+ })
+ }
+ } else {
+ // Name + Email 的精准查询
+ user = query.FindUser(p.Name, p.Email) // name = ? AND email = ?
+ }
+
+ // record action for limiting action
+ common.RecordAction(c)
+
+ if user.IsEmpty() {
+ return common.RespError(c, i18n.T("Login failed"))
+ }
+
+ // 密码验证
+ bcryptPrefix := "(bcrypt)"
+ md5Prefix := "(md5)"
+ passwordOK := false
+ switch {
+ case strings.HasPrefix(user.Password, bcryptPrefix):
+ err := bcrypt.CompareHashAndPassword(
+ []byte(strings.TrimPrefix(user.Password, bcryptPrefix)),
+ []byte(p.Password),
+ )
+
+ if err == nil {
+ passwordOK = true
+ }
+ case strings.HasPrefix(user.Password, md5Prefix):
+ if strings.EqualFold(strings.TrimPrefix(user.Password, md5Prefix),
+ fmt.Sprintf("%x", md5.Sum([]byte(p.Password)))) {
+ passwordOK = true
+ }
+ default:
+ if user.Password == p.Password {
+ passwordOK = true
+ }
+ }
+
+ if !passwordOK {
+ return common.RespError(c, i18n.T("Login failed"))
+ }
+
+ jwtToken := common.LoginGetUserToken(user)
+ setAuthCookie(c, jwtToken, time.Now().Add(time.Second*time.Duration(config.Instance.LoginTimeout)))
+
+ return common.RespData(c, common.Map{
+ "token": jwtToken,
+ "user": query.CookUser(&user),
+ })
+ })
+}
+
+func setAuthCookie(c *fiber.Ctx, jwtToken string, expires time.Time) {
+ if !config.Instance.Cookie.Enabled {
+ return
+ }
+
+ // save jwt token to cookie
+ cookie := new(fiber.Cookie)
+ cookie.Name = config.COOKIE_KEY_ATK_AUTH
+ cookie.Value = jwtToken
+ cookie.Expires = expires
+
+ // @see https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Cookies
+ // @see https://owasp.org/www-project-web-security-testing-guide/v41/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes
+ cookie.Path = "/"
+ cookie.HTTPOnly = true // prevent XSS
+ cookie.Secure = true // https only
+ cookie.SameSite = "" // for cors-request
+
+ // @note cookie secure is not working on localhost
+ // @see https://bugs.chromium.org/p/chromium/issues/detail?id=1177877#c7
+
+ c.Cookie(cookie)
+}
+
+func HashPassword(password string) (string, error) {
+ bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
+ return string(bytes), err
+}
+
+type ParamsLoginStatus struct {
+ Name string `form:"name"`
+ Email string `form:"email"`
+}
+
+// 获取当前登录状态
+//
+// POST /api/login-status
+func UserLoginStatus(router fiber.Router) {
+ router.Post("/login-status", func(c *fiber.Ctx) error {
+ var p ParamsLoginStatus
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ isAdmin := false
+ if p.Email != "" && p.Name != "" {
+ isAdmin = query.IsAdminUserByNameEmail(p.Name, p.Email)
+ }
+
+ return common.RespData(c, common.Map{
+ "is_admin": isAdmin,
+ "is_login": common.CheckIsAdminReq(c),
+ })
+ })
+}
diff --git a/server/handler/user_logout.go b/server/handler/user_logout.go
new file mode 100644
index 000000000..3fdb18da8
--- /dev/null
+++ b/server/handler/user_logout.go
@@ -0,0 +1,28 @@
+package handler
+
+import (
+ "time"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+// POST /api/logout
+func UserLogout(router fiber.Router) {
+ router.Post("/logout", func(c *fiber.Ctx) error {
+
+ if !config.Instance.Cookie.Enabled {
+ return common.RespError(c, "API cookie disabled")
+ }
+
+ if common.GetJwtStrByReqCookie(c) == "" {
+ return common.RespError(c, "Not logged in yet, no need to log out")
+ }
+
+ // same as login, remove cookie
+ setAuthCookie(c, "", time.Now().AddDate(0, 0, -1))
+
+ return common.RespSuccess(c)
+ })
+}
diff --git a/server/handler/utils.go b/server/handler/utils.go
new file mode 100644
index 000000000..bf47a5221
--- /dev/null
+++ b/server/handler/utils.go
@@ -0,0 +1,3 @@
+package handler
+
+type Map = map[string]interface{}
diff --git a/server/handler/version.go b/server/handler/version.go
new file mode 100644
index 000000000..cecfdcb43
--- /dev/null
+++ b/server/handler/version.go
@@ -0,0 +1,13 @@
+package handler
+
+import (
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+// GET /version
+func Version(router fiber.Router) {
+ router.All("/version", func(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusOK).JSON(common.GetApiVersionDataMap())
+ })
+}
diff --git a/server/handler/vote.go b/server/handler/vote.go
new file mode 100644
index 000000000..5be1d4a66
--- /dev/null
+++ b/server/handler/vote.go
@@ -0,0 +1,133 @@
+package handler
+
+import (
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/db"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ParamsVote struct {
+ TargetID uint `form:"target_id" validate:"required"`
+ FullType string `form:"type"`
+
+ Name string `form:"name"`
+ Email string `form:"email"`
+
+ SiteName string
+ SiteID uint
+ SiteAll bool
+}
+
+// POST /api/vote
+func Vote(router fiber.Router) {
+ router.Post("/vote", func(c *fiber.Ctx) error {
+ var p ParamsVote
+ if isOK, resp := common.ParamsDecode(c, &p); !isOK {
+ return resp
+ }
+
+ // use site
+ common.UseSite(c, &p.SiteName, &p.SiteID, &p.SiteAll)
+
+ // find user
+ var user entity.User
+ if p.Name != "" && p.Email != "" {
+ user = query.FindCreateUser(p.Name, p.Email, "")
+ }
+
+ ip := c.IP()
+
+ // check type
+ isVoteComment := strings.HasPrefix(p.FullType, "comment_")
+ isVotePage := strings.HasPrefix(p.FullType, "page_")
+ isUp := strings.HasSuffix(p.FullType, "_up")
+ isDown := strings.HasSuffix(p.FullType, "_down")
+ voteTo := strings.TrimSuffix(strings.TrimSuffix(p.FullType, "_up"), "_down")
+ voteType := strings.TrimPrefix(strings.TrimPrefix(p.FullType, "comment_"), "page_")
+
+ if !isUp && !isDown {
+ return common.RespError(c, "unknown type")
+ }
+
+ var comment entity.Comment
+ var page entity.Page
+
+ switch {
+ case isVoteComment:
+ comment = query.FindComment(p.TargetID)
+ if comment.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Comment")}))
+ }
+ case isVotePage:
+ page = query.FindPageByID(p.TargetID)
+ if page.IsEmpty() {
+ return common.RespError(c, i18n.T("{{name}} not found", Map{"name": i18n.T("Page")}))
+ }
+ default:
+ return common.RespError(c, "unknown type")
+ }
+
+ // sync target model field value
+ save := func(up int, down int) {
+ switch {
+ case isVoteComment:
+ comment.VoteUp = up
+ comment.VoteDown = down
+ query.UpdateComment(&comment)
+ case isVotePage:
+ page.VoteUp = up
+ page.VoteDown = down
+ query.UpdatePage(&page)
+ }
+ }
+
+ createNew := func(t string) error {
+ // create new vote record
+ _, err := query.NewVote(p.TargetID, entity.VoteType(t), user.ID, string(c.Request().Header.UserAgent()), ip)
+
+ return err
+ }
+
+ // un-vote
+ var avaliableVotes []entity.Vote
+ db.DB().Where("target_id = ? AND type LIKE ? AND ip = ?", p.TargetID, voteTo+"%", ip).Find(&avaliableVotes)
+ if len(avaliableVotes) > 0 {
+ for _, v := range avaliableVotes {
+ db.DB().Unscoped().Delete(&v)
+ }
+
+ avaVoteType := strings.TrimPrefix(strings.TrimPrefix(string(avaliableVotes[0].Type), "comment_"), "page_")
+ if voteType != avaVoteType {
+ createNew(p.FullType)
+ }
+
+ up, down := query.GetVoteNumUpDown(p.TargetID, voteTo)
+ save(up, down)
+
+ common.RecordAction(c)
+
+ return common.RespData(c, common.Map{
+ "up": up,
+ "down": down,
+ })
+ }
+
+ createNew(p.FullType)
+
+ // sync
+ up, down := query.GetVoteNumUpDown(p.TargetID, voteTo)
+ save(up, down)
+
+ common.RecordAction(c)
+
+ return common.RespData(c, common.Map{
+ "up": up,
+ "down": down,
+ })
+ })
+}
diff --git a/server/middleware/admin.go b/server/middleware/admin.go
new file mode 100644
index 000000000..798e7f76d
--- /dev/null
+++ b/server/middleware/admin.go
@@ -0,0 +1,17 @@
+package middleware
+
+import (
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+func AdminOnlyMiddleware() fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ if !common.CheckIsAdminReq(c) {
+ return common.RespError(c, i18n.T("Admin access required"), common.Map{"need_login": true})
+ }
+
+ return c.Next()
+ }
+}
diff --git a/server/middleware/cors.go b/server/middleware/cors.go
new file mode 100644
index 000000000..e4a8ff6e1
--- /dev/null
+++ b/server/middleware/cors.go
@@ -0,0 +1,12 @@
+package middleware
+
+import (
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/ArtalkJS/Artalk/server/middleware/cors"
+ "github.com/gofiber/fiber/v2"
+)
+
+func CorsMiddleware() func(*fiber.Ctx) error {
+ common.ReloadCorsAllowOrigins()
+ return cors.New(common.CorsConf)
+}
diff --git a/server/middleware/cors/cors.go b/server/middleware/cors/cors.go
new file mode 100644
index 000000000..a15dbd803
--- /dev/null
+++ b/server/middleware/cors/cors.go
@@ -0,0 +1,168 @@
+package cors
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+// Config defines the config for middleware.
+type Config struct {
+ // Next defines a function to skip this middleware when returned true.
+ //
+ // Optional. Default: nil
+ Next func(c *fiber.Ctx) bool
+
+ // AllowOrigin defines a list of origins that may access the resource.
+ //
+ // Optional. Default value "*"
+ AllowOrigins string
+
+ // AllowMethods defines a list methods allowed when accessing the resource.
+ // This is used in response to a preflight request.
+ //
+ // Optional. Default value "GET,POST,HEAD,PUT,DELETE,PATCH"
+ AllowMethods string
+
+ // AllowHeaders defines a list of request headers that can be used when
+ // making the actual request. This is in response to a preflight request.
+ //
+ // Optional. Default value "".
+ AllowHeaders string
+
+ // AllowCredentials indicates whether or not the response to the request
+ // can be exposed when the credentials flag is true. When used as part of
+ // a response to a preflight request, this indicates whether or not the
+ // actual request can be made using credentials.
+ //
+ // Optional. Default value false.
+ AllowCredentials bool
+
+ // ExposeHeaders defines a whitelist headers that clients are allowed to
+ // access.
+ //
+ // Optional. Default value "".
+ ExposeHeaders string
+
+ // MaxAge indicates how long (in seconds) the results of a preflight request
+ // can be cached.
+ //
+ // Optional. Default value 0.
+ MaxAge int
+}
+
+// ConfigDefault is the default config
+var ConfigDefault = Config{
+ Next: nil,
+ AllowOrigins: "*",
+ AllowMethods: strings.Join([]string{
+ fiber.MethodGet,
+ fiber.MethodPost,
+ fiber.MethodHead,
+ fiber.MethodPut,
+ fiber.MethodDelete,
+ fiber.MethodPatch,
+ }, ","),
+ AllowHeaders: "",
+ AllowCredentials: false,
+ ExposeHeaders: "",
+ MaxAge: 0,
+}
+
+// New creates a new middleware handler
+func New(config *Config) fiber.Handler {
+ cfg := config
+
+ // Set default values
+ if cfg.AllowMethods == "" {
+ cfg.AllowMethods = ConfigDefault.AllowMethods
+ }
+ if cfg.AllowOrigins == "" {
+ cfg.AllowOrigins = ConfigDefault.AllowOrigins
+ }
+
+ // Convert string to slice
+ allowOrigins := strings.Split(strings.ReplaceAll(cfg.AllowOrigins, " ", ""), ",")
+
+ // Strip white spaces
+ allowMethods := strings.ReplaceAll(cfg.AllowMethods, " ", "")
+ allowHeaders := strings.ReplaceAll(cfg.AllowHeaders, " ", "")
+ exposeHeaders := strings.ReplaceAll(cfg.ExposeHeaders, " ", "")
+
+ // Convert int to string
+ maxAge := strconv.Itoa(cfg.MaxAge)
+
+ // Return new handler
+ return func(c *fiber.Ctx) error {
+ // Don't execute middleware if Next returns true
+ if cfg.Next != nil && cfg.Next(c) {
+ return c.Next()
+ }
+
+ // Get origin header
+ origin := c.Get(fiber.HeaderOrigin)
+ allowOrigin := ""
+
+ // Check allowed origins
+ for _, o := range allowOrigins {
+ if o == "*" && cfg.AllowCredentials {
+ allowOrigin = origin
+ break
+ }
+ if o == "*" || o == origin {
+ allowOrigin = o
+ break
+ }
+ if matchSubdomain(origin, o) {
+ allowOrigin = origin
+ break
+ }
+ }
+
+ // Simple request
+ if c.Method() != http.MethodOptions {
+ c.Vary(fiber.HeaderOrigin)
+ c.Set(fiber.HeaderAccessControlAllowOrigin, allowOrigin)
+
+ if cfg.AllowCredentials {
+ c.Set(fiber.HeaderAccessControlAllowCredentials, "true")
+ }
+ if exposeHeaders != "" {
+ c.Set(fiber.HeaderAccessControlExposeHeaders, exposeHeaders)
+ }
+ return c.Next()
+ }
+
+ // Preflight request
+ c.Vary(fiber.HeaderOrigin)
+ c.Vary(fiber.HeaderAccessControlRequestMethod)
+ c.Vary(fiber.HeaderAccessControlRequestHeaders)
+ c.Set(fiber.HeaderAccessControlAllowOrigin, allowOrigin)
+ c.Set(fiber.HeaderAccessControlAllowMethods, allowMethods)
+
+ // Set Allow-Credentials if set to true
+ if cfg.AllowCredentials {
+ c.Set(fiber.HeaderAccessControlAllowCredentials, "true")
+ }
+
+ // Set Allow-Headers if not empty
+ if allowHeaders != "" {
+ c.Set(fiber.HeaderAccessControlAllowHeaders, allowHeaders)
+ } else {
+ h := c.Get(fiber.HeaderAccessControlRequestHeaders)
+ if h != "" {
+ c.Set(fiber.HeaderAccessControlAllowHeaders, h)
+ }
+ }
+
+ // Set MaxAge is set
+ if cfg.MaxAge > 0 {
+ c.Set(fiber.HeaderAccessControlMaxAge, maxAge)
+ }
+
+ // Send 204 No Content
+ return c.SendStatus(fiber.StatusNoContent)
+ }
+}
diff --git a/server/middleware/cors/utils.go b/server/middleware/cors/utils.go
new file mode 100644
index 000000000..fee658ef9
--- /dev/null
+++ b/server/middleware/cors/utils.go
@@ -0,0 +1,52 @@
+package cors
+
+import "strings"
+
+func matchScheme(domain, pattern string) bool {
+ didx := strings.Index(domain, ":")
+ pidx := strings.Index(pattern, ":")
+ return didx != -1 && pidx != -1 && domain[:didx] == pattern[:pidx]
+}
+
+// matchSubdomain compares authority with wildcard
+func matchSubdomain(domain, pattern string) bool {
+ if !matchScheme(domain, pattern) {
+ return false
+ }
+ didx := strings.Index(domain, "://")
+ pidx := strings.Index(pattern, "://")
+ if didx == -1 || pidx == -1 {
+ return false
+ }
+ domAuth := domain[didx+3:]
+ // to avoid long loop by invalid long domain
+ if len(domAuth) > 253 {
+ return false
+ }
+ patAuth := pattern[pidx+3:]
+
+ domComp := strings.Split(domAuth, ".")
+ patComp := strings.Split(patAuth, ".")
+ for i := len(domComp)/2 - 1; i >= 0; i-- {
+ opp := len(domComp) - 1 - i
+ domComp[i], domComp[opp] = domComp[opp], domComp[i]
+ }
+ for i := len(patComp)/2 - 1; i >= 0; i-- {
+ opp := len(patComp) - 1 - i
+ patComp[i], patComp[opp] = patComp[opp], patComp[i]
+ }
+
+ for i, v := range domComp {
+ if len(patComp) <= i {
+ return false
+ }
+ p := patComp[i]
+ if p == "*" {
+ return true
+ }
+ if p != v {
+ return false
+ }
+ }
+ return false
+}
diff --git a/server/middleware/limit.go b/server/middleware/limit.go
new file mode 100644
index 000000000..d16d3b35c
--- /dev/null
+++ b/server/middleware/limit.go
@@ -0,0 +1,84 @@
+package middleware
+
+import (
+ "path"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+type ActionLimitConf struct {
+ ProtectPaths []string
+}
+
+// 操作限制 中间件
+func ActionLimitMiddleware(conf ActionLimitConf) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ // 关闭验证码功能,直接 Skip
+ if !config.Instance.Captcha.Enabled {
+ return c.Next()
+ }
+
+ // 路径是否启用操作限制
+ pathInList := false
+ for _, p := range conf.ProtectPaths {
+ if path.Clean(c.Path()) == path.Clean(p) {
+ pathInList = true
+ break
+ }
+ }
+ if !pathInList {
+ // 不启用的 path 直接放行
+ return c.Next()
+ }
+
+ // 管理员直接放行
+ if common.CheckIsAdminReq(c) {
+ return c.Next()
+ }
+
+ userIP := c.IP()
+ isNeedCheck := common.IsReqNeedCaptchaCheck(c)
+
+ // 总是需要验证码模式
+ if config.Instance.Captcha.Always {
+ if common.GetAlwaysCaptchaMode_Pass(userIP) {
+ common.SetAlwaysCaptchaMode_Pass(userIP, false) // 总是需要验证码,放行一次后再次需要验证码
+ isNeedCheck = false
+ } else {
+ isNeedCheck = true
+ }
+ } else {
+ // 超时模式:重置计数
+ if config.Instance.Captcha.ActionReset != -1 {
+ if !common.IsActionInTimeFrame(c) { // 超时
+ common.ResetActionRecord(c) // 重置计数
+ isNeedCheck = false // 放行
+ }
+ }
+ }
+
+ // 是否需要验证
+ if isNeedCheck {
+ respData := common.Map{
+ "need_captcha": true,
+ }
+
+ if config.Instance.Captcha.Geetest.Enabled {
+ // iframe 验证模式
+ respData["iframe"] = true
+ // 前端新版不会再用到 img_data,给旧版响应 ArtalkFrontent out-of-date 图片
+ respData["img_data"] = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 40'%3E%3Cdefs%3E%3Cstyle%3E.a%7Bfill:%23328ce6%3B%7D.b%7Bfont-size:12px%3Bfill:%23fff%3Bfont-family:sans-serif%3B%7D%3C/style%3E%3C/defs%3E%3Crect class='a' width='160' height='40'/%3E%3Ctext class='b' transform='translate(18.37 16.67)'%3EArtalk Frontend%3Ctspan x='0' y='14.4'%3EOut-Of-Date.%3C/tspan%3E%3C/text%3E%3C/svg%3E"
+ } else {
+ respData["img_data"] = common.GetNewImageCaptchaBase64(c.IP())
+ }
+
+ return common.RespError(c, i18n.T("Captcha required"), respData)
+ }
+
+ // 放行
+ return c.Next()
+ }
+}
diff --git a/server/middleware/site_origin.go b/server/middleware/site_origin.go
new file mode 100644
index 000000000..644ea7f95
--- /dev/null
+++ b/server/middleware/site_origin.go
@@ -0,0 +1,165 @@
+package middleware
+
+import (
+ "net/url"
+ "path"
+ "strings"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/entity"
+ "github.com/ArtalkJS/Artalk/internal/i18n"
+ "github.com/ArtalkJS/Artalk/internal/query"
+ "github.com/ArtalkJS/Artalk/internal/utils"
+ "github.com/ArtalkJS/Artalk/server/common"
+ "github.com/gofiber/fiber/v2"
+)
+
+// 不启用 Origin 控制的 API paths
+var SiteOriginSkips = []string{
+ "/api/user-get",
+ "/api/login",
+}
+
+// 站点隔离 & Origin 控制
+func SiteOriginMiddleware() fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ // 忽略白名单
+ for _, p := range SiteOriginSkips {
+ if path.Clean(c.Path()) == path.Clean(p) {
+ return c.Next()
+ }
+ }
+
+ siteName := c.FormValue("site_name")
+ siteID := uint(0)
+ var site *entity.Site = nil
+
+ siteAll := false
+ isSuperAdmin := common.GetIsSuperAdmin(c)
+
+ // 请求站点名 == "__ATK_SITE_ALL" 时取消站点隔离
+ if siteName == config.ATK_SITE_ALL {
+ if !isSuperAdmin {
+ return common.RespError(c, "Only admin can query sites with disable isolation")
+ }
+
+ siteAll = true
+ } else {
+ // 请求站点名为空,使用默认 site
+ if siteName == "" {
+ siteName = strings.TrimSpace(config.Instance.SiteDefault)
+ if siteName != "" {
+ query.FindCreateSite(siteName) // 默认站点不存在则创建
+ }
+ }
+
+ findSite := query.FindSite(siteName)
+ if findSite.IsEmpty() {
+ return common.RespError(c,
+ i18n.T("Site `{{name}}` not found. Please create it in control center.", map[string]interface{}{"name": siteName}),
+ common.Map{
+ "err_no_site": true,
+ },
+ )
+ }
+ site = &findSite
+ siteID = findSite.ID
+ }
+
+ // 检测 Origin 合法性 (防止跨域的 CSRF 攻击)
+ if !isSuperAdmin { // 管理员忽略 Origin 检测
+ if isOK, resp := CheckOrigin(c, site); !isOK {
+ return resp
+ }
+ }
+
+ // 设置 Context Values
+ c.Locals(config.CTX_KEY_ATK_SITE_ID, siteID)
+ c.Locals(config.CTX_KEY_ATK_SITE_NAME, siteName)
+ c.Locals(config.CTX_KEY_ATK_SITE_ALL, siteAll)
+
+ return c.Next()
+ }
+}
+
+// 检测 Origin 合法性
+// 防止跨域的 CSRF 攻击
+// @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
+func CheckOrigin(c *fiber.Ctx, allowSite *entity.Site) (bool, error) {
+ // 可信来源 URL
+ allowURLs := []string{}
+
+ // 用户配置
+ allowURLs = append(allowURLs, config.Instance.TrustedDomains...) // 允许配置文件域名
+ if allowSite != nil {
+ allowURLs = append(allowURLs, query.CookSite(allowSite).Urls...) // 允许数据库站点 URLs 中的域名
+ }
+ if utils.ContainsStr(allowURLs, "*") {
+ return true, nil // 列表中出现通配符关闭控制
+ }
+
+ // 读取 Origin 数据
+ // @note Origin 标头在前端 fetch POST 操作中总是携带的,
+ // 即使配置 Referrer-Policy: no-referrer
+ // @see https://stackoverflow.com/questions/42239643/when-do-browsers-send-the-origin-header-when-do-browsers-set-the-origin-to-null
+ origin := c.Get(fiber.HeaderOrigin)
+ if origin == "" || origin == "null" {
+ // 从 Referer 获取 Origin
+ referer := string(c.Request().Header.Referer())
+ if referer == "" {
+ return false, common.RespError(c, i18n.T("Invalid request")+", "+i18n.T("Unable to get `{{name}}`", map[string]interface{}{"name": "origin"}))
+ }
+ origin = referer
+ }
+
+ // 允许同源请求
+ host := string(c.Request().Host())
+ realHostUnderProxy := c.Get(fiber.HeaderXForwardedHost)
+ if realHostUnderProxy != "" {
+ host = realHostUnderProxy
+ }
+ allowURLs = append(allowURLs, string(c.Request().URI().Scheme())+"://"+host)
+
+ // 判断 Origin 是否被允许
+ if GetIsAllowOrigin(origin, allowURLs) {
+ return true, nil
+ }
+
+ return false, common.RespError(c, i18n.T("Invalid request. Please check your `trusted_domains` config."))
+}
+
+// 判断 Origin 是否被允许
+// origin is 'schema://hostname:port',
+// allowURLs is a collection of url strings
+func GetIsAllowOrigin(origin string, allowURLs []string) bool {
+ // Origin 合法性检测
+ originP, err := url.Parse(origin)
+ if err != nil || originP.Scheme == "" || originP.Host == "" {
+ return false
+ }
+
+ // 提取 URLs 检测 Origin 是否匹配
+ for _, u := range allowURLs {
+ u = strings.TrimSpace(u)
+ if u == "" {
+ continue
+ }
+
+ urlP, err := url.Parse(u)
+ if err != nil || urlP.Scheme == "" || urlP.Host == "" {
+ continue
+ }
+
+ // 在可信来源列表中匹配 Referer 的 host 部分 (含端口) 则放行
+ // @see https://web.dev/referrer-best-practices/
+ // Referrer-Policy 不能设为 no-referer,
+ // Chrome v85+ 默认为:strict-origin-when-cross-origin。
+ // 前端页面 head 不配置 ,
+ // 浏览器默认都会至少携带 Origin 数据 (不带 path,但包含端口)
+ if urlP.Scheme == originP.Scheme && urlP.Host == originP.Host {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/server/middleware/site_origin_test.go b/server/middleware/site_origin_test.go
new file mode 100644
index 000000000..73f35a7dd
--- /dev/null
+++ b/server/middleware/site_origin_test.go
@@ -0,0 +1,37 @@
+package middleware
+
+import (
+ "testing"
+)
+
+func Test_GetIsAllowOrigin(t *testing.T) {
+ tests := []struct {
+ name string
+ origin string
+ allowURLs []string
+ want bool
+ }{
+ {name: "matched allowURLs with slash suffix", origin: "https://qwqaq.com", allowURLs: []string{"https://qwqaq.com/"}, want: true},
+ {name: "matched allowURLs with path", origin: "https://qwqaq.com", allowURLs: []string{"https://qwqaq.com/test-page/"}, want: true},
+ {name: "matched allowURLs with port and path", origin: "https://qwqaq.com:12345", allowURLs: []string{"https://qwqaq.com:12345/test-page/"}, want: true},
+ {name: "matched allowURLs with http schema", origin: "http://qwqaq.com", allowURLs: []string{"http://qwqaq.com"}, want: true},
+
+ {name: "not matched, port not same", origin: "https://qwqaq.com:1234", allowURLs: []string{"https://qwqaq.com"}, want: false},
+ {name: "not matched, protocol not same", origin: "http://qwqaq.com", allowURLs: []string{"https://qwqaq.com"}, want: false},
+ {name: "not matched, hostname not same", origin: "https://abc.qwqaq.com", allowURLs: []string{"https://qwqaq.com"}, want: false},
+
+ {name: "invalid origin 1", origin: "qwqaq.com", allowURLs: []string{"https://qwqaq.com"}, want: false},
+ {name: "invalid origin 2", origin: "", allowURLs: []string{"https://qwqaq.com"}, want: false},
+ {name: "invalid origin 3", origin: "null", allowURLs: []string{"https://qwqaq.com"}, want: false},
+
+ {name: "matched multi-allowUrls", origin: "https://abc.qwqaq.com", allowURLs: []string{"https://aaaa.com", "https://bbb.com", "https://abc.qwqaq.com/abcd"}, want: true},
+ {name: "not matched multi-allowUrls", origin: "https://def.qwqaq.com", allowURLs: []string{"https://aaaa.com", "https://bbb.com", "https://abc.qwqaq.com/abcd"}, want: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := GetIsAllowOrigin(tt.origin, tt.allowURLs); got != tt.want {
+ t.Errorf("GetIsAllowOrigin() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/server/server.go b/server/server.go
new file mode 100644
index 000000000..d691c8d43
--- /dev/null
+++ b/server/server.go
@@ -0,0 +1,133 @@
+package server
+
+import (
+ "net/http"
+
+ "github.com/ArtalkJS/Artalk/internal/config"
+ "github.com/ArtalkJS/Artalk/internal/pkged"
+ "github.com/ArtalkJS/Artalk/server/common"
+ h "github.com/ArtalkJS/Artalk/server/handler"
+ "github.com/ArtalkJS/Artalk/server/middleware"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/filesystem"
+ "github.com/gofiber/fiber/v2/middleware/pprof"
+ "github.com/sirupsen/logrus"
+)
+
+func Init(app *fiber.App) {
+ cors(app)
+ actionLimit(app)
+
+ if config.Instance.Debug {
+ app.Use(pprof.New())
+ }
+
+ api := app.Group("/api", middleware.SiteOriginMiddleware())
+ {
+ h.CommentAdd(api)
+ h.CommentGet(api)
+ h.Vote(api)
+ h.PV(api)
+ h.Stat(api)
+ h.MarkRead(api)
+ h.ImgUpload(api)
+
+ h.Conf(api)
+ h.Version(api)
+
+ // captcha
+ h.Captcha(api)
+
+ // user
+ h.UserGet(api)
+ h.UserLogin(api)
+ h.UserLoginStatus(api)
+ h.UserLogout(api)
+
+ // admin
+ admin(api)
+ }
+
+ index(app)
+
+ static(app)
+ uploadedStatic(app)
+}
+
+func admin(f fiber.Router) {
+ admin := f.Group("/admin", middleware.AdminOnlyMiddleware())
+ {
+ h.AdminCommentEdit(admin)
+ h.AdminCommentDel(admin)
+
+ h.AdminPageGet(admin)
+ h.AdminPageEdit(admin)
+ h.AdminPageDel(admin)
+ h.AdminPageFetch(admin)
+
+ h.AdminSiteGet(admin)
+ h.AdminSiteAdd(admin)
+ h.AdminSiteEdit(admin)
+ h.AdminSiteDel(admin)
+
+ h.AdminUserGet(admin)
+ h.AdminUserAdd(admin)
+ h.AdminUserEdit(admin)
+ h.AdminUserDel(admin)
+
+ h.AdminCacheWarm(admin)
+ h.AdminCacheFlush(admin)
+
+ h.AdminSendMail(admin)
+ h.AdminVoteSync(admin)
+
+ h.AdminSettingGet(admin)
+ h.AdminSettingSave(admin)
+
+ h.AdminTransfer(admin)
+ }
+}
+
+func cors(f fiber.Router) {
+ f.Use(middleware.CorsMiddleware())
+}
+
+func actionLimit(f fiber.Router) {
+ f.Use(middleware.ActionLimitMiddleware(middleware.ActionLimitConf{
+ // 启用操作限制路径白名单
+ ProtectPaths: []string{
+ "/api/add",
+ "/api/login",
+ "/api/vote",
+ "/api/img-upload",
+ },
+ }))
+}
+
+func static(f fiber.Router) {
+ f.Use("/", filesystem.New(filesystem.Config{
+ Root: http.FS(pkged.FS()),
+ PathPrefix: "public",
+ Browse: false,
+ }))
+}
+
+func index(f fiber.Router) {
+ f.All("/", func(c *fiber.Ctx) error {
+ if _, err := pkged.FS().Open("public/sidebar/index.html"); err == nil {
+ return c.Redirect("./sidebar/", fiber.StatusFound)
+ }
+
+ return c.Status(fiber.StatusOK).JSON(common.GetApiVersionDataMap())
+ })
+}
+
+func uploadedStatic(f fiber.Router) {
+ if config.Instance.ImgUpload.Path == "" {
+ config.Instance.ImgUpload.Path = "./data/artalk-img/"
+ logrus.Warn("[Image Upload] img_upload.path is not configured, using the default value: " + config.Instance.ImgUpload.Path)
+ }
+
+ // 图片上传静态资源可访问路径
+ f.Static(config.IMG_UPLOAD_PUBLIC_PATH, config.Instance.ImgUpload.Path)
+}
diff --git a/.eslintignore b/ui/.eslintignore
similarity index 100%
rename from .eslintignore
rename to ui/.eslintignore
diff --git a/.eslintrc.js b/ui/.eslintrc.js
similarity index 87%
rename from .eslintrc.js
rename to ui/.eslintrc.js
index 66d5eac20..c3133b302 100644
--- a/.eslintrc.js
+++ b/ui/.eslintrc.js
@@ -42,4 +42,6 @@ module.exports = {
'AbortController'
]
},
+ // @see https://stackoverflow.com/questions/63118405/how-to-fix-eslintrc-the-file-does-not-match-your-project-config
+ ignorePatterns: ['.eslintrc.js'],
}
diff --git a/.npmignore b/ui/.npmignore
similarity index 100%
rename from .npmignore
rename to ui/.npmignore
diff --git a/.prettierrc b/ui/.prettierrc
similarity index 100%
rename from .prettierrc
rename to ui/.prettierrc
diff --git a/package.json b/ui/package.json
similarity index 100%
rename from package.json
rename to ui/package.json
diff --git a/packages/artalk-sidebar/.eslintrc.js b/ui/packages/artalk-sidebar/.eslintrc.js
similarity index 100%
rename from packages/artalk-sidebar/.eslintrc.js
rename to ui/packages/artalk-sidebar/.eslintrc.js
diff --git a/packages/artalk-sidebar/.gitignore b/ui/packages/artalk-sidebar/.gitignore
similarity index 100%
rename from packages/artalk-sidebar/.gitignore
rename to ui/packages/artalk-sidebar/.gitignore
diff --git a/packages/artalk-sidebar/README.md b/ui/packages/artalk-sidebar/README.md
similarity index 100%
rename from packages/artalk-sidebar/README.md
rename to ui/packages/artalk-sidebar/README.md
diff --git a/packages/artalk-sidebar/auto-imports.d.ts b/ui/packages/artalk-sidebar/auto-imports.d.ts
similarity index 100%
rename from packages/artalk-sidebar/auto-imports.d.ts
rename to ui/packages/artalk-sidebar/auto-imports.d.ts
diff --git a/packages/artalk-sidebar/components.d.ts b/ui/packages/artalk-sidebar/components.d.ts
similarity index 100%
rename from packages/artalk-sidebar/components.d.ts
rename to ui/packages/artalk-sidebar/components.d.ts
diff --git a/packages/artalk-sidebar/index.html b/ui/packages/artalk-sidebar/index.html
similarity index 100%
rename from packages/artalk-sidebar/index.html
rename to ui/packages/artalk-sidebar/index.html
diff --git a/packages/artalk-sidebar/package.json b/ui/packages/artalk-sidebar/package.json
similarity index 100%
rename from packages/artalk-sidebar/package.json
rename to ui/packages/artalk-sidebar/package.json
diff --git a/ui/packages/artalk-sidebar/public/robots.txt b/ui/packages/artalk-sidebar/public/robots.txt
new file mode 100644
index 000000000..3f6899136
--- /dev/null
+++ b/ui/packages/artalk-sidebar/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
diff --git a/ui/packages/artalk-sidebar/scripts/fetch-conf-tpl.js b/ui/packages/artalk-sidebar/scripts/fetch-conf-tpl.js
new file mode 100644
index 000000000..029c0395d
--- /dev/null
+++ b/ui/packages/artalk-sidebar/scripts/fetch-conf-tpl.js
@@ -0,0 +1,28 @@
+import https from 'https'
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+fs.copyFile(path.join(__dirname, '../../../../artalk.example.yml'), path.join(__dirname, '../src/assets/artalk.example.yml'), (err) => {
+ if (!err) console.log("\nArtalk config file 'artalk.example.yml' loaded.\n")
+ else console.error("Failed to load config file 'artalk.example.yml':\n\n", err, "\n");
+})
+
+// const file = fs.createWriteStream()
+
+// https.get(
+// 'https://raw.githubusercontent.com/ArtalkJS/Artalk/master/artalk.example.yml',
+// (resp) => {
+// resp.pipe(file)
+
+// file.on('finish', () => {
+// file.close()
+// console.log("\nArtalk 'artalk.example.yml' file download completed.\n")
+// })
+// }
+// ).on('error', (e) => {
+// console.error("Failed to download 'artalk.example.yml' file:\n\n", e, "\n");
+// })
diff --git a/packages/artalk-sidebar/src/App.vue b/ui/packages/artalk-sidebar/src/App.vue
similarity index 100%
rename from packages/artalk-sidebar/src/App.vue
rename to ui/packages/artalk-sidebar/src/App.vue
diff --git a/packages/artalk-sidebar/src/assets/artalk-go.example.yml b/ui/packages/artalk-sidebar/src/assets/artalk.example.yml
similarity index 98%
rename from packages/artalk-sidebar/src/assets/artalk-go.example.yml
rename to ui/packages/artalk-sidebar/src/assets/artalk.example.yml
index 4486bcb1b..ddd0e5e89 100644
--- a/packages/artalk-sidebar/src/assets/artalk-go.example.yml
+++ b/ui/packages/artalk-sidebar/src/assets/artalk.example.yml
@@ -24,7 +24,7 @@ db:
# 数据库类型 ["sqlite", "mysql", "pgsql", "mssql"]
type: "sqlite"
# 数据库文件 (仅 SQLite 数据库需填写)
- file: "./data/artalk-go.db"
+ file: "./data/artalk.db"
# 数据库名称
name: "artalk"
# 数据库地址
@@ -45,7 +45,7 @@ log:
# 启用日志
enabled: true
# 日志文件路径
- filename: "./data/artalk-go.log"
+ filename: "./data/artalk.log"
# 缓存
cache:
@@ -283,6 +283,8 @@ frontend:
default: "mp"
# 评论分页
pagination:
+ # 每页评论数
+ pageSize: 20
# 加载更多模式 (关闭则使用分页条)
readMore: true
# 滚动加载
diff --git a/ui/packages/artalk-sidebar/src/assets/favicon.png b/ui/packages/artalk-sidebar/src/assets/favicon.png
new file mode 100644
index 000000000..0e15f6d0a
Binary files /dev/null and b/ui/packages/artalk-sidebar/src/assets/favicon.png differ
diff --git a/packages/artalk-sidebar/src/components/FileUploader.vue b/ui/packages/artalk-sidebar/src/components/FileUploader.vue
similarity index 100%
rename from packages/artalk-sidebar/src/components/FileUploader.vue
rename to ui/packages/artalk-sidebar/src/components/FileUploader.vue
diff --git a/packages/artalk-sidebar/src/components/Header.vue b/ui/packages/artalk-sidebar/src/components/Header.vue
similarity index 99%
rename from packages/artalk-sidebar/src/components/Header.vue
rename to ui/packages/artalk-sidebar/src/components/Header.vue
index faffe9bb7..0d54692d9 100644
--- a/packages/artalk-sidebar/src/components/Header.vue
+++ b/ui/packages/artalk-sidebar/src/components/Header.vue
@@ -1,4 +1,5 @@