From fde5f628ec3b624634b789733b864b3f94d31e0e Mon Sep 17 00:00:00 2001
From: freefire
Date: Sat, 29 Aug 2026 05:44:16 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20opaque=20session=E3=80=81=E5=AE=89?=
=?UTF-8?q?=E8=A3=85/=E5=8F=91=E5=B8=96=20SSR=20=E4=B8=8E=E6=9C=80?=
=?UTF-8?q?=E5=B0=8F=20Admin=20=E5=90=8E=E5=8F=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。
Co-authored-by: Cursor
---
.cursor/rules/rebuild-templates.mdc | 11 +-
README.md | 93 ++-
app.ini.example | 15 -
cmd/jiang13/program.go | 35 +-
config/config.go | 327 +++++++----
config/env.go | 15 +-
config/ini.go | 107 ----
docker-compose.yml | 12 +-
docs/introduction.md | 4 +-
docs/rebuild-spec/01-product.md | 2 +-
docs/rebuild-spec/02-features.md | 58 +-
docs/rebuild-spec/03-data-model.md | 25 +-
docs/rebuild-spec/04-api.md | 47 +-
docs/rebuild-spec/05-business-rules.md | 16 +-
docs/rebuild-spec/06-pages-ux.md | 72 ++-
docs/rebuild-spec/07-config-ops.md | 169 ++++--
.../rebuild-spec/08-gitea-ssr-architecture.md | 90 +--
docs/rebuild-spec/README.md | 13 +-
docs/site-posts/about-jiang13.md | 8 +-
go.mod | 12 +-
go.sum | 29 +-
models/db.go | 100 +++-
models/session.go | 14 +
modules/auth/auth.go | 111 ++--
modules/auth/cookie.go | 43 ++
modules/webctx/context.go | 181 ++++++
modules/webrender/render.go | 33 +-
public/assets/site.css | 184 ++++++
public/assets/site.js | 46 +-
routers/api/api.go | 33 +-
routers/api/handlers.go | 16 +-
routers/api/seo.go | 542 +-----------------
routers/api/seo_bot.go | 158 -----
routers/install/install.go | 124 ++++
routers/setup.go | 220 +------
routers/web/admin.go | 500 ++++++++++++++++
routers/web/auth.go | 208 +++++++
routers/web/chrome.go | 110 ++++
routers/web/compose.go | 224 ++++++++
routers/web/home.go | 226 +++-----
routers/web/pending.go | 35 ++
routers/web/post.go | 193 +++++++
services/auth.go | 136 ++---
services/backup.go | 19 +-
services/comment.go | 23 +
services/common.go | 16 +-
services/compose_body.go | 97 ++++
services/content_gate.go | 21 +
services/filter_words.go | 56 +-
services/gitea.go | 26 +-
services/install.go | 48 ++
services/oidc.go | 99 +++-
services/post.go | 17 +-
services/session.go | 129 +++++
services/settings.go | 47 +-
services/user.go | 14 +-
templates/admin/boards.tmpl | 47 ++
templates/admin/dashboard.tmpl | 18 +
templates/admin/moderation.tmpl | 60 ++
templates/admin/nav.tmpl | 9 +
templates/admin/settings.tmpl | 43 ++
templates/auth/login.tmpl | 20 +
templates/auth/register.tmpl | 39 ++
templates/base.tmpl | 51 --
templates/base/alert.tmpl | 4 +
templates/base/footer.tmpl | 8 +
templates/base/head.tmpl | 12 +
templates/base/navbar.tmpl | 27 +
templates/compose.tmpl | 39 ++
templates/embed.go | 2 +-
templates/{home.tmpl => home/feed.tmpl} | 10 +-
templates/home/home.tmpl | 12 +
templates/install.tmpl | 37 ++
templates/post-install.tmpl | 10 +
templates/post/body.tmpl | 69 +++
templates/post/view.tmpl | 12 +
templates/shared/board_aside.tmpl | 8 +
templates/status/404.tmpl | 21 +
web_src/css/site.css | 184 ++++++
web_src/js/site.js | 46 +-
80 files changed, 4148 insertions(+), 1849 deletions(-)
delete mode 100644 app.ini.example
delete mode 100644 config/ini.go
create mode 100644 models/session.go
create mode 100644 modules/auth/cookie.go
create mode 100644 modules/webctx/context.go
delete mode 100644 routers/api/seo_bot.go
create mode 100644 routers/install/install.go
create mode 100644 routers/web/admin.go
create mode 100644 routers/web/auth.go
create mode 100644 routers/web/chrome.go
create mode 100644 routers/web/compose.go
create mode 100644 routers/web/pending.go
create mode 100644 routers/web/post.go
create mode 100644 services/compose_body.go
create mode 100644 services/content_gate.go
create mode 100644 services/install.go
create mode 100644 services/session.go
create mode 100644 templates/admin/boards.tmpl
create mode 100644 templates/admin/dashboard.tmpl
create mode 100644 templates/admin/moderation.tmpl
create mode 100644 templates/admin/nav.tmpl
create mode 100644 templates/admin/settings.tmpl
create mode 100644 templates/auth/login.tmpl
create mode 100644 templates/auth/register.tmpl
delete mode 100644 templates/base.tmpl
create mode 100644 templates/base/alert.tmpl
create mode 100644 templates/base/footer.tmpl
create mode 100644 templates/base/head.tmpl
create mode 100644 templates/base/navbar.tmpl
create mode 100644 templates/compose.tmpl
rename templates/{home.tmpl => home/feed.tmpl} (87%)
create mode 100644 templates/home/home.tmpl
create mode 100644 templates/install.tmpl
create mode 100644 templates/post-install.tmpl
create mode 100644 templates/post/body.tmpl
create mode 100644 templates/post/view.tmpl
create mode 100644 templates/shared/board_aside.tmpl
create mode 100644 templates/status/404.tmpl
diff --git a/.cursor/rules/rebuild-templates.mdc b/.cursor/rules/rebuild-templates.mdc
index 0fd0d75..a15edff 100644
--- a/.cursor/rules/rebuild-templates.mdc
+++ b/.cursor/rules/rebuild-templates.mdc
@@ -6,8 +6,9 @@ alwaysApply: false
# 模板约定
-- 布局:`base.tmpl` + 各页定义 `content` 等 block(或 `{{template}}` 组合);保持片段小而可复用。
-- **默认转义**:`{{.Title}}` 等普通插值;用户生成 HTML 必须先经现有消毒(如 `SanitizePostHTML`),再用显式安全函数输出,禁止随意放开。
-- 静态资源走 `/ssr-assets/...`(`public/assets` 嵌入),不要写死外链 CDN(除非产品规格要求)。
-- 页面数据通过 handler 组装的 view model 传入;模板内不做复杂业务判断。
-- 中文 UI 文案可写在模板;与品牌相关的站点名等从 settings/view 传入。
+- 目录对齐 Gitea:`templates/base/`、`home/`、`post/`、`shared/`、`status/`、`auth/`,根级 `install.tmpl` / `post-install.tmpl`。
+- 页面入口用固定 `{{define "home"}}` / `{{define "post"}}` 等显式 `{{template "base/head"}}`…;**禁止** `{{template .Name}}`(标准库不支持动态名)。
+- **默认转义**;用户 HTML 须先消毒 + 门控后再 `{{safeHTML ...}}`。
+- 静态资源 `/ssr-assets/...`。
+- 浏览器写操作走 `routers/web` 表单 POST + CSRF(`webctx`),不依赖 JSON `/api`。
+- 中文文案可写在模板;站点名等从 PageChrome / settings 传入。
diff --git a/README.md b/README.md
index 4db81b9..6cc909b 100644
--- a/README.md
+++ b/README.md
@@ -110,12 +110,12 @@
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
-- 可选:邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储
+- 可选:邮件验证码、OIDC Provider、S3 兼容对象存储(Gitea 仓库同步后置)
### 部署体验
-- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
-- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
+- **单二进制** — `go:embed` 打包模板与 SSR 资源
+- **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQL(Env 引导)
- **跨平台** — Windows / Linux / macOS 一键编译
- **系统服务** — 内置 Linux systemd / Windows Service 注册
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
@@ -171,7 +171,7 @@ docker compose up -d --build
make compose-up
```
-浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
+浏览器打开 `http://localhost:3000/install` 完成安装向导(站点名 + 管理员)。
**拉取已构建镜像(Docker Hub):**
@@ -184,7 +184,7 @@ docker run -d --name jiang13 \
hangzhang714128/jiang13-forum:latest
```
-**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。
+**数据持久化:** 容器内 `/data` 对应 SQLite(默认)、上传、日志与 JWT/OIDC 密钥。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户)。
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
@@ -194,10 +194,12 @@ docker run -d --name jiang13 \
|------|------|
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
-| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
-| `JIANG13_CONFIG` | 配置文件路径 |
+| `JIANG13_DB_TYPE` | `sqlite`(默认)\| `postgres` \| `mysql` |
+| `JIANG13_DB_DSN` | 完整 DSN(非 sqlite 时推荐) |
| `JIANG13_WORK_PATH` | 工作目录 |
+JWT 自动写入 `/data/.jwt_secret`,无需 Env。
+
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
**发布镜像到 Docker Hub(手动):**
@@ -235,90 +237,72 @@ docker push hangzhang714128/jiang13-forum:latest
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
2. 端口映射 `3000:3000`
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
-4. 首次访问 `http://服务器IP:3000/register` 注册管理员
+4. 首次访问 `http://服务器IP:3000/install` 完成安装
### 3. 直接启动(二进制)
-把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
-
```bash
# Windows
-.\dist\jiang13.exe
+.\dist\jiang13.exe --data .\dist\data
# Linux / macOS
-./dist/jiang13
+./dist/jiang13 --data ./data
```
-也可先复制示例配置再改端口 / 数据目录:
-
-```bash
-cp app.ini.example /opt/jiang13/app.ini
-# 编辑 app.ini 后:
-./jiang13
-```
+默认 SQLite,库文件在 `{DATA}/jiang13.db`。无 `app.ini`。
### 4. 首次使用
-1. 浏览器打开 `http://localhost:3000/register` 注册账号
-2. **第一个注册的用户自动成为管理员**
-3. 登录后访问 `http://localhost:3000/admin` 进入后台
+1. 浏览器打开 `http://localhost:3000/install`
+2. 填写站点名与管理员账号
+3. 完成后登录,访问管理后台配置品牌等(热更新,无需重启)
-### 配置文件(`app.ini`)
+### 配置分层(无 INI)
-默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。
+| 层 | 内容 | 需重启 |
+|----|------|--------|
+| CLI / Env | 端口、数据目录、数据库类型与 DSN | 是 |
+| `data/.jwt_secret`、`.oidc_rsa.pem` | 密钥 | 换密钥需重启 |
+| DB `forum_settings` | 品牌、邮件、OIDC 开关、限流、存储… | 否 |
-```ini
-[server]
-HTTP_PORT = 3000
-
-[paths]
-DATA = data
-
-[security]
-JWT_SECRET =
-```
-
-完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。
-
-**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
+**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
### 启动参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
-| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
-| `--config` | `{work-path}/app.ini` | 配置文件路径 |
-| `--port` | (读配置 / `3000`) | HTTP 监听端口 |
-| `--data` | (读配置 / `data`) | 数据目录 |
-| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
+| `--work-path` | 可执行文件目录 | 工作目录 |
+| `--port` | `3000` | HTTP 监听端口 |
+| `--http-addr` | (空) | 监听地址 |
+| `--data` | `data` | 数据目录 |
+| `--db-type` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
+| `--db-dsn` | (sqlite 默认 `{data}/jiang13.db`) | 完整 DSN |
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
-**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`
+**环境变量:** `JIANG13_HTTP_PORT`、`JIANG13_HTTP_ADDR`、`JIANG13_DATA`、`JIANG13_WORK_PATH`、`JIANG13_DB_TYPE`、`JIANG13_DB_DSN`、以及 `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE`。
+
+PostgreSQL / MySQL 示例见 [`docs/rebuild-spec/07-config-ops.md`](docs/rebuild-spec/07-config-ops.md)。
### 5. 注册为系统服务(可选)
-将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
-
-**Ubuntu / Linux(systemd,需 root):**
-
```bash
sudo mkdir -p /opt/jiang13
sudo cp jiang13 /opt/jiang13/
-sudo /opt/jiang13/jiang13 --service install
+sudo /opt/jiang13/jiang13 --work-path /opt/jiang13 --data /opt/jiang13/data --service install
sudo /opt/jiang13/jiang13 --service start
sudo systemctl enable jiang13
```
-**Windows(Windows Service,需管理员 PowerShell):**
+**Windows(管理员 PowerShell):**
```powershell
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
Copy-Item .\jiang13.exe C:\jiang13\
-C:\jiang13\jiang13.exe --service install
+C:\jiang13\jiang13.exe --work-path C:\jiang13 --data C:\jiang13\data --service install
C:\jiang13\jiang13.exe --service start
```
-> 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。
+> 改端口或 `DB_*` 后执行 `--service restart`(必要时重装服务以更新参数)。日志:`data/jiang13.log`。
---
@@ -326,7 +310,7 @@ C:\jiang13\jiang13.exe --service start
| 层级 | 技术 |
|------|------|
-| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite · `html/template` |
+| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite / PostgreSQL / MySQL · `html/template` |
| **渐进资源** | `web_src/`(构建到 `public/assets/`,URL `/ssr-assets/`) |
| **构建** | `web_src` → `go:embed` templates + assets,单二进制发布 |
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
@@ -357,8 +341,7 @@ make run
```
jiang13-forum/ # 分支 rebuild/gitea-ssr
├── cmd/jiang13/ # 程序入口(含系统服务注册)
-├── config/ # app.ini 与命令行配置
-├── app.ini.example
+├── config/ # CLI / Env 引导配置(无 INI)
├── Dockerfile # web_src → Go → Alpine
├── docker-compose.yml
├── models/ # GORM 模型
diff --git a/app.ini.example b/app.ini.example
deleted file mode 100644
index f2e88ee..0000000
--- a/app.ini.example
+++ /dev/null
@@ -1,15 +0,0 @@
-; 姜十三论坛 Jiang13 Forum — 配置文件示例(风格类似 Gitea app.ini)
-; 复制为程序工作目录下的 app.ini 后修改。也可直接启动程序,首次会自动生成。
-; 修改后重启进程/服务生效。命令行 --port / --data 等优先级更高。
-; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。
-
-[server]
-HTTP_PORT = 3000
-
-[paths]
-; 相对路径相对于工作目录(默认可执行文件所在目录)
-DATA = data
-
-[security]
-; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
-JWT_SECRET =
diff --git a/cmd/jiang13/program.go b/cmd/jiang13/program.go
index d10a03c..3d634dd 100644
--- a/cmd/jiang13/program.go
+++ b/cmd/jiang13/program.go
@@ -75,7 +75,14 @@ func (p *program) setup() error {
log.Printf(" 版本: %s", version)
log.Println("========================================")
- if err := models.InitDB(cfg.DBPath()); err != nil {
+ if err := models.InitDB(models.DatabaseConfig{
+ Type: cfg.DB.Type,
+ DSN: cfg.DB.DSN,
+ SQLitePath: cfg.DB.SQLitePath,
+ MaxOpenConns: cfg.DB.MaxOpenConns,
+ MaxIdleConns: cfg.DB.MaxIdleConns,
+ ConnMaxLifetimeSec: cfg.DB.ConnMaxLifetimeSec,
+ }); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err)
}
@@ -84,31 +91,39 @@ func (p *program) setup() error {
return fmt.Errorf("路由初始化失败: %w", err)
}
- addr := fmt.Sprintf(":%d", cfg.Port)
+ addr := cfg.ListenAddr()
p.server = &http.Server{
Addr: addr,
Handler: engine,
}
- log.Printf("姜十三论坛已启动: http://localhost%s", addr)
- log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
+ log.Printf("姜十三论坛已启动: http://localhost:%d", cfg.Port)
+ log.Printf("后台管理地址: http://localhost:%d/admin/dashboard", cfg.Port)
log.Printf("工作目录: %s", cfg.WorkPath)
- log.Printf("配置文件: %s", cfg.ConfigFile)
log.Printf("数据目录: %s", cfg.DataDir)
+ log.Printf("数据库: %s", cfg.DB.Type)
return nil
}
func buildServiceConfig(cfg *config.Config) (*kardsvc.Config, error) {
- // 服务只绑定工作目录与配置文件;端口/数据目录改 app.ini 后重启即可,无需重装服务
+ // 服务绑定工作目录与数据目录;改端口 / DB_* 需重启进程(可用 Env 或重装服务参数)
+ args := []string{
+ "--work-path", cfg.WorkPath,
+ "--data", cfg.DataDir,
+ "--port", fmt.Sprintf("%d", cfg.Port),
+ "--db-type", cfg.DB.Type,
+ }
+ if cfg.DB.Type == config.DBTypeSQLite {
+ args = append(args, "--db-dsn", cfg.DB.SQLitePath)
+ } else if cfg.DB.DSN != "" {
+ args = append(args, "--db-dsn", cfg.DB.DSN)
+ }
return &kardsvc.Config{
Name: svcName,
DisplayName: svcDisplayName,
Description: svcDescription,
WorkingDirectory: cfg.WorkPath,
- Arguments: []string{
- "--work-path", cfg.WorkPath,
- "--config", cfg.ConfigFile,
- },
+ Arguments: args,
Option: kardsvc.KeyValue{
// systemd:异常退出后自动拉起
"Restart": "always",
diff --git a/config/config.go b/config/config.go
index e84e4fa..3d969ae 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,50 +1,78 @@
package config
import (
+ "crypto/rand"
+ "encoding/base64"
"flag"
"fmt"
+ "net/url"
"os"
"path/filepath"
+ "strconv"
"strings"
)
-// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
const (
+ defaultPort = 3000
+ defaultDataRel = "data"
+
+ DBTypeSQLite = "sqlite"
+ DBTypePostgres = "postgres"
+ DBTypeMySQL = "mysql"
+
+ // StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
StorageTypeLocal = "local"
StorageTypeS3 = "s3"
)
-// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
-type Config struct {
- // 工作目录(默认可执行文件所在目录)
- WorkPath string
- // 配置文件绝对路径
- ConfigFile string
- // 监听端口
- Port int
- // 数据目录:SQLite、上传、日志(绝对路径)
- DataDir string
- // JWT 签名密钥
- JWTSecret string
- // 日志文件路径
- LogFile string
- // 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
- ServiceAction string
- // 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
- DevMode bool
+// DatabaseConfig 数据库引导配置(需重启)
+type DatabaseConfig struct {
+ Type string // sqlite | postgres | mysql
+ DSN string // 非空则优先
+ Host string
+ User string
+ Password string
+ Name string
+ SSLMode string // postgres
+ // SQLite 文件路径(Type=sqlite 时由 DataDir 推导或 DSN)
+ SQLitePath string
+
+ MaxOpenConns int
+ MaxIdleConns int
+ ConnMaxLifetimeSec int
}
-// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
-//
-// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
+// Config 进程引导配置:仅 CLI / 环境变量(无 INI)
+type Config struct {
+ WorkPath string
+ HTTPAddr string // 空表示 0.0.0.0
+ Port int
+ DataDir string
+ JWTSecret string
+ LogFile string
+ ServiceAction string
+ DevMode bool
+ DB DatabaseConfig
+}
+
+// Parse 解析命令行与环境变量并准备数据目录
+// 优先级:命令行显式参数 > 环境变量 > 内置默认
func Parse() (*Config, error) {
- configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
- portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)")
- dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
- jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
+ portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000)")
+ addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口)")
+ dataFlag := flag.String("data", "", "数据存储目录")
+ dbTypeFlag := flag.String("db-type", "", "数据库类型:sqlite|postgres|mysql")
+ dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN(优先于拆分参数)")
+ dbHostFlag := flag.String("db-host", "", "数据库主机")
+ dbUserFlag := flag.String("db-user", "", "数据库用户")
+ dbPassFlag := flag.String("db-pass", "", "数据库密码")
+ dbNameFlag := flag.String("db-name", "", "数据库名")
+ dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
- devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
+ devFlag := flag.Bool("dev", false, "开发模式")
+ _ = flag.String("config", "", "已废弃:不再使用 ini 配置文件")
+ _ = flag.String("jwt-secret", "", "已废弃:JWT 仅使用 data/.jwt_secret")
flag.Parse()
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
@@ -52,35 +80,13 @@ func Parse() (*Config, error) {
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
}
- workPathInput := strings.TrimSpace(*workFlag)
- if workPathInput == "" {
- workPathInput = envOrDefault(envWorkPath)
- }
+ workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
workPath, err := resolveWorkPath(workPathInput)
if err != nil {
return nil, err
}
- configInput := strings.TrimSpace(*configFlag)
- if configInput == "" {
- configInput = envOrDefault(envConfig)
- }
- configFile, err := resolveConfigPath(workPath, configInput)
- if err != nil {
- return nil, err
- }
-
- fileCfg := defaultFileSettings()
- configExists := false
- if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
- configExists = true
- fileCfg, err = loadAppINI(configFile)
- if err != nil {
- return nil, err
- }
- }
-
- port := fileCfg.Port
+ port := defaultPort
if p := envIntOrZero(envHTTPPort); p > 0 {
port = p
}
@@ -88,65 +94,35 @@ func Parse() (*Config, error) {
port = *portFlag
}
- dataInput := fileCfg.DataRel
- if v := envOrDefault(envData); v != "" {
- dataInput = v
- }
- if strings.TrimSpace(*dataFlag) != "" {
- dataInput = *dataFlag
- }
+ httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
+
+ dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
absData, err := absPath(workPath, dataInput)
if err != nil {
return nil, fmt.Errorf("解析数据目录失败: %w", err)
}
- jwtSecret := fileCfg.JWTSecret
- if v := envOrDefault(envJWTSecret); v != "" {
- jwtSecret = v
- }
- if strings.TrimSpace(*jwtFlag) != "" {
- jwtSecret = strings.TrimSpace(*jwtFlag)
+ dbCfg, err := buildDatabaseConfig(absData, dbFlags{
+ Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
+ User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
+ })
+ if err != nil {
+ return nil, err
}
cfg := &Config{
WorkPath: workPath,
- ConfigFile: configFile,
+ HTTPAddr: httpAddr,
Port: port,
DataDir: absData,
- JWTSecret: jwtSecret,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
DevMode: *devFlag,
+ DB: dbCfg,
}
needDirs := action == "" || action == "install"
if needDirs {
- // 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
- if !configExists {
- dataRel := resolveDataRelForINI(workPath, absData)
- if err := writeAppINI(configFile, fileSettings{
- Port: port,
- DataRel: dataRel,
- }); err != nil {
- return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
- }
- fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
- } else if action == "install" {
- // 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
- dataRel := resolveDataRelForINI(workPath, absData)
- iniJWT := fileCfg.JWTSecret
- if strings.TrimSpace(*jwtFlag) != "" {
- iniJWT = jwtSecret
- }
- if err := writeAppINI(configFile, fileSettings{
- Port: port,
- DataRel: dataRel,
- JWTSecret: iniJWT,
- }); err != nil {
- return nil, fmt.Errorf("更新配置文件失败: %w", err)
- }
- }
-
if err := ensureDataDirs(absData); err != nil {
return nil, err
}
@@ -158,6 +134,96 @@ func Parse() (*Config, error) {
return cfg, nil
}
+type dbFlags struct {
+ Type, DSN, Host, User, Pass, Name, SSL string
+}
+
+func buildDatabaseConfig(dataDir string, f dbFlags) (DatabaseConfig, error) {
+ typ := strings.ToLower(firstNonEmpty(f.Type, envOrDefault(envDBType), DBTypeSQLite))
+ switch typ {
+ case "sqlite", "sqlite3":
+ typ = DBTypeSQLite
+ case "postgres", "postgresql", "pg":
+ typ = DBTypePostgres
+ case "mysql", "mariadb":
+ typ = DBTypeMySQL
+ default:
+ return DatabaseConfig{}, fmt.Errorf("不支持的数据库类型 %q,可选:sqlite|postgres|mysql", typ)
+ }
+
+ out := DatabaseConfig{
+ Type: typ,
+ DSN: firstNonEmpty(f.DSN, envOrDefault(envDBDSN)),
+ Host: firstNonEmpty(f.Host, envOrDefault(envDBHost)),
+ User: firstNonEmpty(f.User, envOrDefault(envDBUser)),
+ Password: firstNonEmpty(f.Pass, envOrDefault(envDBPass)),
+ Name: firstNonEmpty(f.Name, envOrDefault(envDBName)),
+ SSLMode: firstNonEmpty(f.SSL, envOrDefault(envDBSSLMode), "disable"),
+ MaxOpenConns: envIntDefault(envDBMaxOpen, 0),
+ MaxIdleConns: envIntDefault(envDBMaxIdle, 0),
+ ConnMaxLifetimeSec: envIntDefault(envDBConnLife, 0),
+ }
+
+ if typ == DBTypeSQLite {
+ if out.DSN != "" {
+ out.SQLitePath = out.DSN
+ } else {
+ out.SQLitePath = filepath.Join(dataDir, "jiang13.db")
+ out.DSN = out.SQLitePath
+ }
+ if out.MaxOpenConns == 0 {
+ out.MaxOpenConns = 1
+ }
+ if out.MaxIdleConns == 0 {
+ out.MaxIdleConns = 1
+ }
+ return out, nil
+ }
+
+ if out.DSN == "" {
+ dsn, err := buildDSN(out)
+ if err != nil {
+ return DatabaseConfig{}, err
+ }
+ out.DSN = dsn
+ }
+ if out.MaxOpenConns == 0 {
+ out.MaxOpenConns = 25
+ }
+ if out.MaxIdleConns == 0 {
+ out.MaxIdleConns = 5
+ }
+ if out.ConnMaxLifetimeSec == 0 {
+ out.ConnMaxLifetimeSec = 300
+ }
+ return out, nil
+}
+
+func buildDSN(c DatabaseConfig) (string, error) {
+ if c.Host == "" || c.User == "" || c.Name == "" {
+ return "", fmt.Errorf("%s 需要 JIANG13_DB_DSN,或 JIANG13_DB_HOST/USER/NAME(及可选 PASS)", c.Type)
+ }
+ switch c.Type {
+ case DBTypePostgres:
+ u := url.URL{
+ Scheme: "postgres",
+ User: url.UserPassword(c.User, c.Password),
+ Host: c.Host,
+ Path: "/" + c.Name,
+ }
+ q := url.Values{}
+ q.Set("sslmode", c.SSLMode)
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+ case DBTypeMySQL:
+ // 特殊字符密码请直接用 JIANG13_DB_DSN;此处为拆分参数简易拼接
+ return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true&loc=Local&charset=utf8mb4",
+ c.User, c.Password, c.Host, c.Name), nil
+ default:
+ return "", fmt.Errorf("无法为 %s 拼接 DSN", c.Type)
+ }
+}
+
func resolveWorkPath(flagVal string) (string, error) {
if strings.TrimSpace(flagVal) != "" {
abs, err := filepath.Abs(flagVal)
@@ -169,13 +235,6 @@ func resolveWorkPath(flagVal string) (string, error) {
return defaultWorkPath()
}
-func resolveConfigPath(workPath, flagVal string) (string, error) {
- if strings.TrimSpace(flagVal) != "" {
- return absPath(workPath, flagVal)
- }
- return filepath.Join(workPath, defaultConfName), nil
-}
-
func ensureDataDirs(dataDir string) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("创建数据目录失败: %w", err)
@@ -194,21 +253,25 @@ func ensureDataDirs(dataDir string) error {
func (c *Config) resolveJWT() error {
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
- if c.JWTSecret != "" {
- _ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
+ if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
+ c.JWTSecret = string(bytesTrimSpace(data))
return nil
}
- if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
- c.JWTSecret = string(data)
- return nil
+ sec, err := generateRandomSecret(32)
+ if err != nil {
+ return err
}
- c.JWTSecret = generateRandomSecret(32)
+ c.JWTSecret = sec
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
}
return nil
}
+func bytesTrimSpace(b []byte) []byte {
+ return []byte(strings.TrimSpace(string(b)))
+}
+
func validServiceAction(action string) bool {
switch action {
case "install", "uninstall", "start", "stop", "restart", "status":
@@ -218,36 +281,60 @@ func validServiceAction(action string) bool {
}
}
-// DBPath 返回 SQLite 数据库文件路径
-func (c *Config) DBPath() string {
- return filepath.Join(c.DataDir, "jiang13.db")
+// ListenAddr 返回 host:port
+func (c *Config) ListenAddr() string {
+ if c.HTTPAddr == "" {
+ return fmt.Sprintf(":%d", c.Port)
+ }
+ return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
+}
+
+// SQLitePath 兼容旧调用:仅 sqlite 有意义
+func (c *Config) DBPath() string {
+ if c.DB.Type == DBTypeSQLite {
+ return c.DB.SQLitePath
+ }
+ return c.DB.DSN
}
-// AvatarUploadDir 返回头像上传目录
func (c *Config) AvatarUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars")
}
-
-// PostImageUploadDir 返回帖子正文图片上传目录
func (c *Config) PostImageUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "posts")
}
-
-// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
func (c *Config) SiteUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "site")
}
-
-// FilterWordsPath 返回敏感词配置文件路径
func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt")
}
-func generateRandomSecret(n int) string {
- const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+func generateRandomSecret(n int) (string, error) {
b := make([]byte, n)
- for i := range b {
- b[i] = chars[i%len(chars)]
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("生成密钥失败: %w", err)
}
- return string(b)
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func firstNonEmpty(vals ...string) string {
+ for _, v := range vals {
+ if strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+func envIntDefault(key string, def int) int {
+ v := envOrDefault(key)
+ if v == "" {
+ return def
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil || n < 0 {
+ return def
+ }
+ return n
}
diff --git a/config/env.go b/config/env.go
index db1ac4e..b73d984 100644
--- a/config/env.go
+++ b/config/env.go
@@ -6,13 +6,22 @@ import (
"strings"
)
-// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认)
+// 引导环境变量(无 INI)
const (
envWorkPath = "JIANG13_WORK_PATH"
- envConfig = "JIANG13_CONFIG"
envHTTPPort = "JIANG13_HTTP_PORT"
+ envHTTPAddr = "JIANG13_HTTP_ADDR"
envData = "JIANG13_DATA"
- envJWTSecret = "JIANG13_JWT_SECRET"
+ envDBType = "JIANG13_DB_TYPE"
+ envDBDSN = "JIANG13_DB_DSN"
+ envDBHost = "JIANG13_DB_HOST"
+ envDBUser = "JIANG13_DB_USER"
+ envDBPass = "JIANG13_DB_PASS"
+ envDBName = "JIANG13_DB_NAME"
+ envDBSSLMode = "JIANG13_DB_SSLMODE"
+ envDBMaxOpen = "JIANG13_DB_MAX_OPEN"
+ envDBMaxIdle = "JIANG13_DB_MAX_IDLE"
+ envDBConnLife = "JIANG13_DB_CONN_MAX_LIFETIME_SEC"
)
func envOrDefault(key string) string {
diff --git a/config/ini.go b/config/ini.go
deleted file mode 100644
index 4a33659..0000000
--- a/config/ini.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package config
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-
- "gopkg.in/ini.v1"
-)
-
-const (
- defaultPort = 3000
- defaultDataRel = "data"
- defaultConfName = "app.ini"
-)
-
-// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
-type fileSettings struct {
- Port int
- DataRel string
- JWTSecret string
-}
-
-func defaultFileSettings() fileSettings {
- return fileSettings{
- Port: defaultPort,
- DataRel: defaultDataRel,
- }
-}
-
-func loadAppINI(path string) (fileSettings, error) {
- out := defaultFileSettings()
- cfg, err := ini.LoadSources(ini.LoadOptions{
- IgnoreInlineComment: true,
- }, path)
- if err != nil {
- return out, fmt.Errorf("读取配置文件失败: %w", err)
- }
-
- if sec, err := cfg.GetSection("server"); err == nil {
- if k := sec.Key("HTTP_PORT"); k.String() != "" {
- p, err := k.Int()
- if err != nil {
- return out, fmt.Errorf("server.HTTP_PORT 无效: %w", err)
- }
- if p <= 0 || p > 65535 {
- return out, fmt.Errorf("server.HTTP_PORT 超出范围: %d", p)
- }
- out.Port = p
- }
- }
-
- if sec, err := cfg.GetSection("paths"); err == nil {
- if v := strings.TrimSpace(sec.Key("DATA").String()); v != "" {
- out.DataRel = v
- }
- }
-
- if sec, err := cfg.GetSection("security"); err == nil {
- out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
- }
-
- return out, nil
-}
-
-// writeAppINI 写入/覆盖 app.ini(安装服务或首次生成时使用)
-func writeAppINI(path string, s fileSettings) error {
- if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
- return err
- }
-
- var b strings.Builder
- b.WriteString("; 姜十三论坛 Jiang13 Forum — 配置文件(风格类似 Gitea app.ini)\n")
- b.WriteString("; 修改后重启进程/服务生效。命令行参数优先级高于本文件。\n")
- b.WriteString("; OIDC / 邮件 / Gitea 同步 / 对象存储等请在管理后台「系统设置」配置。\n")
- b.WriteString(";\n")
- b.WriteString("; 默认位置:程序工作目录下的 app.ini\n")
- b.WriteString("; 可用 --config / --work-path 覆盖。\n")
- b.WriteString("\n")
- b.WriteString("[server]\n")
- b.WriteString("HTTP_PORT = ")
- b.WriteString(strconv.Itoa(s.Port))
- b.WriteString("\n\n")
- b.WriteString("[paths]\n")
- b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
- b.WriteString("DATA = ")
- b.WriteString(s.DataRel)
- b.WriteString("\n\n")
- b.WriteString("[security]\n")
- b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)\n")
- b.WriteString("JWT_SECRET = ")
- b.WriteString(s.JWTSecret)
- b.WriteString("\n")
-
- return os.WriteFile(path, []byte(b.String()), 0644)
-}
-
-// resolveDataRelForINI 把绝对数据目录尽量写成相对工作目录的路径,便于 app.ini 可读
-func resolveDataRelForINI(workPath, absData string) string {
- rel, err := filepath.Rel(workPath, absData)
- if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
- return absData
- }
- return filepath.ToSlash(rel)
-}
diff --git a/docker-compose.yml b/docker-compose.yml
index 51ab7b4..db371b5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,12 @@
# 姜十三论坛 — Docker Compose 单服务部署
# 启动:docker compose up -d --build
# 或:make compose-up / build.bat -Target compose-up
+#
+# 默认 SQLite(数据在 /data)。换库示例:
+# JIANG13_DB_TYPE=postgres
+# JIANG13_DB_DSN=postgres://forum:secret@host:5432/jiang13?sslmode=disable
+# JIANG13_DB_TYPE=mysql
+# JIANG13_DB_DSN=forum:secret@tcp(host:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4
services:
jiang13:
@@ -14,12 +20,10 @@ services:
- "3000:3000"
volumes:
- jiang13-data:/data
- # 可选:挂载自定义 app.ini(只读)
- # - ./app.ini:/app/app.ini:ro
environment:
TZ: Asia/Shanghai
- # 可选:固定 JWT 密钥(留空则自动生成并持久化到 /data/.jwt_secret)
- # JIANG13_JWT_SECRET: your-secret-here
+ # JIANG13_DB_TYPE: sqlite
+ # JIANG13_HTTP_PORT: "3000"
restart: unless-stopped
stop_grace_period: 15s
healthcheck:
diff --git a/docs/introduction.md b/docs/introduction.md
index 1f60e41..7fa8d1f 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -87,7 +87,7 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
```text
编译 → 得到一个 jiang13(或 jiang13.exe)
-放到目录里运行 → 自动生成 app.ini
+放到目录里运行 → Env / CLI 引导(默认 SQLite)
打开浏览器注册 → 第一个账号就是管理员
```
@@ -95,7 +95,7 @@ Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
- **零外部数据库**:SQLite 落在数据目录,备份就是拷贝文件;
-- **app.ini 配置**:风格类似 Gitea,端口与数据目录一眼能改;业务项在管理后台配置;
+- **Env / CLI 引导**:端口、数据目录、数据库类型;业务项在管理后台热更新;
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
- **跨平台**:Windows / Linux / macOS 均可编译与运行。
diff --git a/docs/rebuild-spec/01-product.md b/docs/rebuild-spec/01-product.md
index fa59f9d..8fa839b 100644
--- a/docs/rebuild-spec/01-product.md
+++ b/docs/rebuild-spec/01-product.md
@@ -29,7 +29,7 @@
| 维度 | 当前实现 | 重构时 |
|------|----------|--------|
| **产品** | 论坛功能全集(见模块地图) | **必须对齐**功能与规则 |
-| **运维** | 单二进制 + 内嵌 SPA + SQLite + `app.ini` | **可选保留**;可换容器 / PG / 分离部署 |
+| **运维** | 单二进制 + Env 引导 + SQLite/PG/MySQL + `forum_settings` 热更 | **本分支已落地**;无 `app.ini` |
规格文档把「用户能做什么」写死;把「怎么打包发布」放在 [07-config-ops.md](07-config-ops.md) 供参考。
diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md
index d985f4f..5f21ff2 100644
--- a/docs/rebuild-spec/02-features.md
+++ b/docs/rebuild-spec/02-features.md
@@ -15,53 +15,53 @@
- [ ] 浅色 / 暗色主题;跟随系统偏好并本地记忆
- [ ] 响应式:平板/手机收起侧栏
- [ ] 长列表虚拟滚动或等价流畅方案
-- [ ] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
-- [ ] 板块筛选:全部 + 单板块
+- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
+- [x] 板块筛选:全部 + 单板块
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
- [ ] 搜索:关键词、标签、作者、仅标题(`title_only`)
- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)
- [ ] 登录用户右栏/侧边:签到与抽奖入口
- [ ] 下拉刷新(移动端)
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)
-- [ ] 404 页
+- [x] 404 页
---
## B. 认证与个人中心
-- [ ] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)
+- [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register`
- [ ] 图形验证码接口(注册流程)
-- [ ] 登录 / 登出(会话 Cookie)
+- [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销
- [ ] 忘记密码:邮箱验证码 + 重置
-- [ ] 注册配置接口:是否首用户、邮件是否就绪、是否开放注册
-- [ ] 首个用户自动成为管理员
+- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
+- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
- [ ] 个人中心:改昵称、签名、密码、上传头像(可裁剪)
- [ ] 个人活动统计:帖数、评数、收藏数、获赞
- [ ] 公开用户主页 `/user/:id`(无邮箱)
-- [ ] 禁言用户无法使用需登录写接口
+- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
---
## C. 板块
-- [ ] 列出板块(含帖数等展示字段)
-- [ ] 管理员:创建 / 改 / 删板块
-- [ ] 板块名称、描述、图标、色板索引、排序
-- [ ] 默认板块保障(空站可引导创建)
+- [x] 列出板块(含帖数等展示字段)— 前台侧栏 + Admin
+- [x] 管理员:创建 / 改 / 删板块 — SSR `/admin/boards`
+- [x] 板块名称、描述、图标、色板索引、排序
+- [x] 默认板块保障(空站可引导创建)
---
## D. 帖子(通用)
-- [ ] 发帖:选板块、标题、标签、正文
-- [ ] 正文图片上传
-- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)
+- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal)
+- [x] 正文图片上传 — `/compose/upload` + Markdown 插入
+- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— 本分支改用 Markdown textarea 渐进增强
- [ ] Markdown 编辑模式(与富文本互转/双模)
-- [ ] 编辑帖子(时限、锁帖约束)
+- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
- [ ] 删除帖子 → 软删进回收站
- [ ] 修订历史列表与单条详情(可做 diff)
-- [ ] 点赞切换;收藏切换;收藏列表页
-- [ ] 浏览量(可 `skip_view=1` 跳过计数)
+- [x] 点赞切换;收藏切换;(收藏列表页未迁)
+- [x] 浏览量 — 详情页计数
- [ ] 举报帖子
- [ ] 内容审核状态展示(作者可见待审/被拒)
@@ -80,7 +80,7 @@
- [ ] 精华 / 取消
- [ ] 禁止编辑(edit lock)
- [ ] 禁止评论 / 结贴(comments lock)
-- [ ] 审核通过 / 拒绝(拒绝可通知作者)
+- [x] 审核通过 / 拒绝(拒绝可通知作者)— SSR `/admin/moderation`
- [ ] 回收站:恢复 / 彻底删除
---
@@ -108,7 +108,7 @@
- [ ] @ 提及 → 通知
- [ ] 回复提醒(站内信 + 可选邮件)
- [ ] 审核中 / 被拒评论可见性规则
-- [ ] 管理员:通过 / 拒绝 / 回收站 / 查看评论修订
+- [x] 管理员:通过 / 拒绝待审评论 — SSR `/admin/moderation`(回收站/修订未迁)
---
@@ -155,11 +155,13 @@
---
-## K. Gitea 码桶
+## K. Gitea 码桶(**后置**)
-- [ ] 后台开关、Base URL、Token、同步间隔
-- [ ] 手动同步 + 后台定时同步
-- [ ] 前台 `/projects` 列表与搜索
+> 本迭代**不做**产品化同步:不启后台定时任务、不挂管理入口。表结构与 settings 键可保留兼容。
+
+- [ ] (后置)后台开关、Base URL、Token、同步间隔
+- [ ] (后置)手动同步 + 后台定时同步
+- [ ] (后置)前台 `/projects` 列表与搜索
---
@@ -183,11 +185,11 @@
## N. 管理后台其它
-- [ ] 仪表盘:计数 + 待审帖/评/举报/友链 + 最近帖
-- [ ] 敏感词文件读写
-- [ ] 论坛限流与字数等 Limits
+- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 — SSR `/admin/dashboard`(举报/友链待迁)
+- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
+- [x] 基础限流(post/comment/register/login/window)— SSR;完整 Limits 字数等未迁
- [ ] SMTP 配置与测试信
-- [ ] 站点品牌:名称、标语、简介、keywords、Logo、Favicon、OG 图、ICP
+- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR(Logo/Favicon/OG 上传未迁)
- [ ] SQLite 一键备份与下载
---
diff --git a/docs/rebuild-spec/03-data-model.md b/docs/rebuild-spec/03-data-model.md
index e7c7123..7642dac 100644
--- a/docs/rebuild-spec/03-data-model.md
+++ b/docs/rebuild-spec/03-data-model.md
@@ -38,7 +38,7 @@ erDiagram
User ||--o{ Media : uploads
```
-另有:`ForumSetting`(键值)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。
+另有:`ForumSetting`(键值)、`Session`(浏览器 opaque 会话)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。
---
@@ -46,6 +46,18 @@ erDiagram
说明:`json:"-"` 表示默认 API 序列化隐藏;软删列 `deleted_at` 表示 GORM soft delete。
+### 2.0 sessions
+
+| 列 | 类型 | 说明 |
+|----|------|------|
+| id | string(64) PK | 密码学随机 opaque id(Cookie `jiang13_session` 的值) |
+| user_id | uint index | 用户 |
+| expires_at | time index | 过期;默认 TTL 7 天,滑动续期 |
+| created_at / last_seen_at | time | |
+| ip / user_agent | string | 可选审计 |
+
+登出删单行;禁言 / 改密删该用户全部 session。每次请求以 DB 中 `users.role` / 禁言为准。
+
### 2.1 users
| 字段 | 类型 | 约束 | 说明 |
@@ -391,8 +403,15 @@ Metric:`tenure_days` | `likes_received` | `creator_income`
| oidc_group_claim | groups |
| oidc_admin_group | gitea-admin |
| oidc_user_group | gitea-users |
+| oidc_rsa_private_pem | (空)启用 OIDC 时懒生成并写入;未启用不落盘 `.oidc_rsa.pem` |
-### 6.5 Gitea 同步
+### 6.4b 敏感词
+
+| Key | 默认 |
+|-----|------|
+| filter_words | 默认词表文本;启动时从旧 `filter_words.txt` 导入(若键为空) |
+
+### 6.5 Gitea 同步(**后置**,键保留兼容)
| Key | 默认 |
|-----|------|
@@ -401,6 +420,8 @@ Metric:`tenure_days` | `likes_received` | `creator_income`
| gitea_token | |
| gitea_sync_interval_min | 60 |
+本迭代不启同步任务;见 [02-features.md](02-features.md) §K。
+
### 6.6 存储
| Key | 默认 |
diff --git a/docs/rebuild-spec/04-api.md b/docs/rebuild-spec/04-api.md
index cad9958..98dac1a 100644
--- a/docs/rebuild-spec/04-api.md
+++ b/docs/rebuild-spec/04-api.md
@@ -1,25 +1,40 @@
# 04 · HTTP API 合约
-> **读者**:实现后端 / BFF / 前端数据层的 AI
-> **前置**:[03-data-model.md](03-data-model.md)
-> **源码**:[`router/router.go`](../../routers/setup.go)、[`frontend/src/api/client.ts`]((仅 main)frontend/src/api/client.ts)、[`frontend/src/api/types.ts`]((仅 main)frontend/src/api/types.ts)、[`middleware/auth.go`](../../modules/auth/auth.go)
+> **读者**:机器客户端 / 集成方;浏览器 UI **不**使用本文件作为主路径
+> **前置**:[03-data-model.md](03-data-model.md)、[08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
+> **源码**:[`routers/setup.go`](../../routers/setup.go)、[`modules/auth/auth.go`](../../modules/auth/auth.go)
-不要求 OpenAPI YAML;以下表格 + JSON 形状即为合约。新站可加 `/v1` 前缀,但**字段名建议保持**以便对照迁移。
+本分支(`rebuild/gitea-ssr`)浏览器走 **`routers/web` 模板 + 表单**。
+下列 JSON 合约保留作历史对照与未来机器 API;**当前进程仅注册** health / OIDC / robots / sitemap / media 等机器相关路由,论坛 CRUD 的 `/api/*` 已从路由表移除(handler 源码可删可留,不以 SPA 兼容为目的)。
+
+`main` 分支 SPA 仍完整实现下表;对照请 checkout `main`。
---
-## 1. 通用约定
+## 1. 本分支已注册的机器入口
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/health` | 探活 |
+| GET | `/robots.txt` | 抓取规则 |
+| GET | `/sitemap.xml` | 站点地图 |
+| GET/POST | `/oauth/*`、`/.well-known/openid-configuration` | OIDC Provider |
+| GET | `/media/thumb/*`、`/uploads/*` | 媒体 |
+
+---
+
+## 2. 历史 JSON 合约(main / 对照,本分支默认不挂载)
+
+以下章节描述原 SPA 使用的 `/api` 形状,便于迁移业务语义;**实现 UI 时请用 web 表单,勿恢复双轨。**
+
+### 通用约定(历史)
| 项 | 约定 |
|----|------|
-| Base | 同源;前端 `credentials: 'same-origin'` |
+| Base | 同源;`credentials: 'same-origin'` |
| 成功 | HTTP 2xx + JSON body |
-| 失败 | 非 2xx + `{ "error": "人类可读中文或英文消息" }` |
-| 鉴权 | Cookie `jiang13_token`(HttpOnly);部分也接受 Authorization Bearer(以实现为准) |
-| 内容类型 | JSON 默认;部分写接口用 `multipart/form-data`(FormData) |
-| OptionalAuth | 有 cookie 则解析用户,无则游客继续 |
-| RequireAuth | 必须登录且未禁言 |
-| RequireAdmin | 必须 `role=admin` |
+| 失败 | 非 2xx + `{ "error": "..." }` |
+| 鉴权 | Cookie `jiang13_session`(opaque);机器 OIDC 用 Bearer |
### 分页形态差异
@@ -31,11 +46,11 @@
---
-## 2. 基础设施 / SEO / 静态
+## 3. 基础设施 / SEO / 静态(节选,仍有效)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
-| GET | `/health` | 无 | `{ "status": "ok" }`(DB ping 失败则非 ok,以实现为准) |
+| GET | `/health` | 无 | `{ "status": "ok" }` |
| GET | `/robots.txt` | 无 | 文本 |
| GET | `/sitemap.xml` | 无 | XML |
| GET | `/media/thumb/*filepath` | 无 | 缩略图 / WebP 等 |
@@ -282,8 +297,8 @@
| PUT | `/settings/mail` | MailConfig |
| POST | `/settings/mail/test` | `{ to }` |
| PUT | `/settings/oidc` | OIDCConfig |
-| PUT | `/settings/gitea` | GiteaSyncConfig |
-| POST | `/settings/gitea/sync` | 手动同步 |
+| PUT | `/settings/gitea` | **后置**(501) |
+| POST | `/settings/gitea/sync` | **后置**(501) |
| PUT | `/settings/storage` | StorageConfig |
| PUT | `/settings/branding` | SiteBranding |
| POST | `/settings/branding/upload` | Form kind=`logo`\|`favicon`\|`og_image`, file |
diff --git a/docs/rebuild-spec/05-business-rules.md b/docs/rebuild-spec/05-business-rules.md
index 8a4ff45..775b525 100644
--- a/docs/rebuild-spec/05-business-rules.md
+++ b/docs/rebuild-spec/05-business-rules.md
@@ -8,14 +8,16 @@
## 1. 注册与引导
-源:[`handler/handlers.go`](../../routers/api/handlers.go) `APIRegisterConfig`、[`service/auth.go`](../../services/auth.go)
+源:[`routers/web/auth.go`](../../routers/web/auth.go)、[`services/auth.go`](../../services/auth.go)
| 规则 | 细节 |
|------|------|
-| 首用户 = 管理员 | `UserCount() == 0` 时注册的用户 `role=admin` |
-| 开放注册 | `register_open = (userCount == 0) \|\| mailReady` |
-| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时首用户仍可无码注册 |
-| 后续用户 | 邮件未配置则注册关闭,直到管理员配好 SMTP |
+| 管理员 | **仅** `/install` 向导创建(不再「首注册变管理员」) |
+| 开放注册 | 安装完成后开放;不依赖 SMTP |
+| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时可无码注册 |
+| 会话 | Cookie `jiang13_session` = opaque id;表 `sessions`;HttpOnly + SameSite=Lax;HTTPS 时 Secure;TTL 7 天滑动续期 |
+| 吊销 | 登出删当前 session;禁言 / 重置密码删该用户全部 session |
+| HMAC 密钥 | `{DATA}/.jwt_secret` 仅 CSRF 等 HMAC(**不是**浏览器登录 JWT) |
密码:bcrypt;最小长度来自 `password_min_len`(默认 6)。
@@ -206,8 +208,8 @@ stateDiagram-v2
## 11. 敏感词与限流
-- 敏感词文件:`data/filter_words.txt`;发帖/评/私信等路径过滤
-- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings
+- 敏感词:`forum_settings.filter_words`(Admin SSR 可改并热更);旧文件可导入
+- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings(Admin 可改基础四项+窗口)
---
diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md
index 87d0a07..7155843 100644
--- a/docs/rebuild-spec/06-pages-ux.md
+++ b/docs/rebuild-spec/06-pages-ux.md
@@ -10,50 +10,46 @@
## 1. 路由表
-### 1.1 认证(无 MainLayout 壳或独立简洁壳)
+> **本分支(`rebuild/gitea-ssr`)**:浏览器 UI 走 `routers/web` 模板 + 表单,**不依赖**论坛 JSON `/api`。下表「SSR」列表示是否已迁。
-| 路径 | 页面 | 说明 |
-|------|------|------|
-| `/login` | LoginPage | |
-| `/register` | RegisterPage | 读 register/config;可能关闭 |
-| `/forgot-password` | ForgotPasswordPage | 依赖邮件 |
+### 1.1 认证
-### 1.2 前台(MainLayout)
+| 路径 | 说明 | SSR |
+|------|------|-----|
+| `/login` | 登录 / 登出 | 已迁 |
+| `/register` | 注册(邮件就绪时要验证码) | 已迁 |
+| `/forgot-password` | 忘记密码 | 未迁 |
-| 路径 | 页面 |
-|------|------|
-| `/` | HomePage(全部 Feed) |
-| `/board/:id` | HomePage(板块 Feed;id 可带伪静态后缀) |
-| `/post/:id` | PostDetailPage |
-| `/compose` | ComposePage 发帖 |
-| `/post/:id/edit` | ComposePage 编辑 |
-| `/profile` | ProfilePage(需登录) |
-| `/user/:id` | UserProfilePage |
-| `/favorites` | FavoritesPage |
-| `/projects` | ProjectsPage(Gitea 码桶) |
-| `/links` | LinksPage |
-| `/messages` | MessagesPage |
-| `/page/:slug` | SitePageView |
-| `*` | NotFoundPage |
+### 1.2 前台
-重定向:`/boards` → `/admin/boards`。
+| 路径 | 说明 | SSR |
+|------|------|-----|
+| `/` | Feed | 已迁 |
+| `/board/:id` | 板块 Feed | 已迁 |
+| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
+| `/compose` | 发帖(normal;Markdown textarea + 图片上传) | 已迁 |
+| `/post/:id/edit` | 编辑帖 | 已迁 |
+| `/profile` | 个人中心 | pending |
+| `/user/:id` | 公开用户页 | 未注册 |
+| `/favorites` | 收藏 | pending |
+| `/projects` | Gitea 码桶 | 后置 |
+| `/links` | 友链 | pending |
+| `/messages` | 私信 | pending |
+| `/page/:slug` | 站点单页 | 未注册 |
+| `*` | 404 / pending | 已迁 |
-### 1.3 后台(AdminLayout,需管理员)
+### 1.3 后台(Admin SSR,表单 + CSRF,不挂管理 JSON `/api`)
-| 路径 | 页面 |
-|------|------|
-| `/admin` → `/admin/dashboard` | 仪表盘 |
-| `/admin/boards` | 板块管理 |
-| `/admin/pages` | 单页列表 |
-| `/admin/pages/new`、`/admin/pages/:id/edit` | 单页编辑 |
-| `/admin/links` | 友链与申请 |
-| `/admin/posts` | 帖子审核/运营 |
-| `/admin/comments` | 评论 |
-| `/admin/reports` | 举报 |
-| `/admin/users` | 用户 |
-| `/admin/badges` | 徽章定义 |
-| `/admin/media` | 媒体 |
-| `/admin/settings` | 系统设置(多 Tab) |
+| 路径 | 说明 | SSR |
+|------|------|-----|
+| `/admin` | 重定向 dashboard | 已迁 |
+| `/admin/dashboard` | 概览计数 | 已迁 |
+| `/admin/boards` | 板块 CRUD | 已迁 |
+| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
+| `/admin/settings` | 品牌 + 基础限流 + 敏感词 | 已迁 |
+| `/admin/login` | 重定向前台登录 | 已迁 |
+
+未迁(原 SPA):reports / users / badges / media / pages / links / SMTP / 完整 Limits 等。
---
diff --git a/docs/rebuild-spec/07-config-ops.md b/docs/rebuild-spec/07-config-ops.md
index 896e424..f077baf 100644
--- a/docs/rebuild-spec/07-config-ops.md
+++ b/docs/rebuild-spec/07-config-ops.md
@@ -2,26 +2,106 @@
> **读者**:部署与运维、以及实现配置层的 AI
> **前置**:[README.md](README.md)
-> **源码**:[`app.ini.example`](../../app.ini.example)、[`config/`](../../config/)、[`README.md`](../../README.md)、[`handler/seo.go`](../../routers/api/seo.go)、[`handler/seo_bot.go`](../../routers/api/seo_bot.go)、[`embed_static/`]((仅 main 分支)embed_static/)
+> **源码**:[`config/`](../../config/)、[`README.md`](../../README.md)、[`routers/api/seo.go`](../../routers/api/seo.go)、[`routers/install/`](../../routers/install/)
-运维形态可改;下列描述**现网**行为,便于迁移数据与对齐环境变量语义。
+运维形态可改;下列描述**本分支**行为。
---
-## 1. 进程配置优先级
+## 0. 首次安装(Gitea 式)
-**命令行显式参数 > 环境变量 > `app.ini` > 内置默认**
+| 项 | 说明 |
+|----|------|
+| 锁文件 | `data/install.lock` |
+| 向导 | `GET/POST /install`(`templates/install.tmpl`) |
+| 未锁定 | 除 `/install`、`/ssr-assets/*`、`/health` 外重定向到向导 |
+| 管理员 | 仅安装向导创建;不再「首个注册用户变管理员」 |
+| 向导内容 | 站点名 + 管理员账号(数据库已在进程启动时连上) |
+| 旧数据 | 启动时若已有用户且无锁,自动补写锁 |
-| CLI | 环境变量 | INI | 默认 | 说明 |
-|-----|----------|-----|------|------|
-| `--port` | `JIANG13_HTTP_PORT` | `[server] HTTP_PORT` | 3000 | 监听端口 |
-| `--data` | `JIANG13_DATA` | `[paths] DATA` | `data` | 数据目录 |
-| `--jwt-secret` | `JIANG13_JWT_SECRET` | `[security] JWT_SECRET` | 自动生成 | JWT 密钥 |
-| `--config` | `JIANG13_CONFIG` | | `{work}/app.ini` | 配置文件路径 |
-| `--work-path` | `JIANG13_WORK_PATH` | | 可执行文件目录 | 工作目录 |
-| `--service` | | | | install/uninstall/start/stop/restart/status |
+**无 `app.ini`。** 引导仅 CLI / Env。
-`app.ini` 示例见 [`app.ini.example`](../../app.ini.example)。业务配置(邮件、OIDC、Gitea、存储、品牌等)在 **DB `forum_settings`**,管理后台热更新,不必写进 ini。
+---
+
+## 1. 配置分层与重启边界
+
+| 层 | 存什么 | 变更方式 | 需重启 |
+|----|--------|----------|--------|
+| **Bootstrap** | `DATA`、`HTTP_PORT`/`ADDR`、`DB_TYPE` + DSN/连接参数、工作目录 | CLI / Env | **是** |
+| **密钥文件** | App HMAC(`data/.jwt_secret`,文件名历史遗留);OIDC RSA **仅启用时**写入 settings(可选遗留文件迁移) | 自动生成 | HMAC 换钥需重启 |
+| **站点运行时** | 品牌、邮件、OIDC 开关、限流、敏感词、存储、伪静态… | DB `forum_settings` | **否**(热更) |
+
+**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
+
+### 进程引导
+
+| CLI | 环境变量 | 默认 | 说明 |
+|-----|----------|------|------|
+| `--port` | `JIANG13_HTTP_PORT` | 3000 | 监听端口 |
+| `--http-addr` | `JIANG13_HTTP_ADDR` | (空=全接口) | 监听地址 |
+| `--data` | `JIANG13_DATA` | `data` | 数据目录 |
+| `--work-path` | `JIANG13_WORK_PATH` | 可执行文件目录 | 工作目录 |
+| `--db-type` | `JIANG13_DB_TYPE` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
+| `--db-dsn` | `JIANG13_DB_DSN` | (sqlite 默认 `{DATA}/jiang13.db`) | 完整 DSN,优先 |
+| `--db-host` 等 | `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE` | | DSN 为空时拼接(pg/mysql) |
+| `--service` | | | install/uninstall/start/stop/restart/status |
+
+`{DATA}/.jwt_secret`:**App HMAC 密钥**(CSRF 双提交等),启动时自动生成。**不是**浏览器登录 JWT。`--config` / `--jwt-secret` / `JIANG13_JWT_SECRET` 已废弃。
+
+浏览器登录:DB `sessions` + Cookie `jiang13_session`。OIDC 对外 token 仍为 JWT,私钥在 `forum_settings.oidc_rsa_private_pem`(启用时懒加载;未启用不生成 `.oidc_rsa.pem`)。
+
+业务配置(邮件、OIDC、存储、品牌、敏感词等)在 **DB `forum_settings`**,管理后台热更新。
+
+### 数据库 Env 示例
+
+**SQLite(默认):**
+
+```bash
+JIANG13_DATA=/data
+# 可不设 DB_*;库文件 = $JIANG13_DATA/jiang13.db
+```
+
+**PostgreSQL:**
+
+```bash
+JIANG13_DB_TYPE=postgres
+JIANG13_DB_DSN="postgres://forum:secret@db:5432/jiang13?sslmode=disable"
+# 或拆分:
+# JIANG13_DB_HOST=db:5432
+# JIANG13_DB_USER=forum
+# JIANG13_DB_PASS=secret
+# JIANG13_DB_NAME=jiang13
+# JIANG13_DB_SSLMODE=disable
+```
+
+**MySQL / MariaDB:**
+
+```bash
+JIANG13_DB_TYPE=mysql
+JIANG13_DB_DSN="forum:secret@tcp(db:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4"
+```
+
+连库失败时进程**退出并打印 Env 提示**,不会静默回落 sqlite。
+
+### Docker Compose 多库示意
+
+```yaml
+services:
+ jiang13:
+ image: hangzhang714128/jiang13-forum:latest
+ environment:
+ JIANG13_DB_TYPE: postgres
+ JIANG13_DB_DSN: postgres://forum:secret@postgres:5432/jiang13?sslmode=disable
+ volumes:
+ - jiang13-data:/data
+ depends_on: [postgres]
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: forum
+ POSTGRES_PASSWORD: secret
+ POSTGRES_DB: jiang13
+```
---
@@ -29,19 +109,19 @@
```text
data/
-├── jiang13.db # SQLite 主库
+├── install.lock # 安装完成锁(与 DB 引擎无关)
+├── jiang13.db # 仅 SQLite 时的主库文件(含 sessions / forum_settings)
├── jiang13.log # 运行日志
-├── filter_words.txt # 敏感词
-├── .jwt_secret # 自动生成的 JWT 密钥(勿提交仓库)
+├── filter_words.txt # 遗留:启动时可导入 settings;新源以 DB 为准
+├── .jwt_secret # App HMAC(CSRF 等;勿提交;非登录 JWT)
+├── .oidc_rsa.pem # 遗留:仅启用 OIDC 且从文件迁移时可能存在;新站优先 DB
├── uploads/
│ ├── avatars/
│ ├── posts/
-│ └── site/ # 品牌资源等
-└── jiang13_backup_*.db # 后台导出备份
+│ └── site/
+└── jiang13_backup_*.db # SQLite 一键备份(其它引擎请用库方工具)
```
-开发时后端常与 `dist/data` 共用,避免 dev 与产物数据分裂(见根 README)。
-
---
## 3. 部署方式(现网)
@@ -55,8 +135,6 @@ data/
构建约定见 [`.cursor/rules/build-scripts.mdc`](../../.cursor/rules/build-scripts.mdc):Windows 用 `build.bat`,勿直接 `make` / `.\build.ps1`。
-容器常用环境变量与上表 `JIANG13_*` 一致。旧镜像权限问题:数据目录属主 uid 1000。
-
---
## 4. 存储后端
@@ -66,7 +144,7 @@ data/
| `local` | 文件落在 `data/uploads`;URL 通常 `/uploads/...` |
| `s3` | S3 兼容;endpoint、bucket、密钥、public_base_url、prefix、force_path_style |
-`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。上传始终可保留原图策略以实现为准。
+`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。
媒体索引表 `media` 供后台列表;启动时可后台 SyncMediaIndex。
@@ -82,20 +160,15 @@ data/
| meta description | 站点简介优先,否则标语;帖文则摘要 |
| meta keywords | 站点 keywords |
| canonical | 绝对 URL |
-| og:type / site_name / locale / title / description / url / image | |
-| twitter:card / title / description / image | |
-| JSON-LD | 结构化数据(站点或 Article) |
-| robots | 个别页可 noindex(以实现为准) |
-
-### 现网额外机制(可废弃)
+| og:* / twitter:* | |
+| JSON-LD | 结构化数据 |
+| robots | 个别页可 noindex |
| 机制 | 说明 |
|------|------|
-| SPA 壳注入 | `embed_static`(仅 `main` 分支) 注入 title / branding JSON,**无帖文 DOM** |
-| 爬虫 HTML | User-Agent 命中时 [`seo_bot.go`](../../routers/api/seo_bot.go) 返回简易 HTML |
| robots.txt / sitemap.xml | 动态生成 |
-重构验收:用普通浏览器「查看网页源代码」应能看到帖文正文,而不仅是空 div + script。
+重构验收:普通浏览器「查看网页源代码」应能看到帖文正文。
---
@@ -103,43 +176,33 @@ data/
设置:`permalink_enabled`、`permalink_ext`(默认 `html`)。
-规范路径示例:
-
-- `/post/123.html`
-- `/user/1.html`
-- `/board/2.html`
-- `/page/about.html`
-
-路由应同时接受无后缀与有后缀形式。解析逻辑见 [`service/permalink.go`](../../services/permalink.go)。
-
---
## 7. 安全相关运维注意
| 项 | 说明 |
|----|------|
-| JWT 密钥 | 生产必须固定且保密;勿提交 `.jwt_secret` |
-| Cookie | `jiang13_token` HttpOnly;生产应 Secure + 合适 SameSite |
-| 上传 | 类型/大小限制(头像 MB、帖图策略) |
-| 敏感词 | 后台可改;影响发帖评论私信等 |
-| OAuth 密钥 | 仅存 bcrypt 哈希;创建时明文只回显一次 |
-| 备份 | 含用户哈希与私信,下载需管理员权限、传输加密 |
+| HMAC / OIDC | 勿提交 `.jwt_secret`;OIDC PEM 优先在 DB;遗留 `.oidc_rsa.pem` 亦勿提交 |
+| Cookie | `jiang13_session` HttpOnly + SameSite=Lax;生产 HTTPS 下 Secure |
+| 上传 | 类型/大小限制 |
+| 敏感词 | `forum_settings.filter_words`,后台可改热更 |
+| 备份 | SQLite 文件备份含哈希与私信;PG/MySQL 用官方工具 |
---
## 8. 健康检查
-`GET /health` → JSON `status`。Docker / 负载均衡探活依赖此接口;实现应在 DB 不可用时返回非 200。
+`GET /health` → JSON `status`。DB 不可用时非 200。
---
## 9. 从旧站迁数据建议
-1. 导出 / 复制 `jiang13.db`(或 dump 到新库并映射表)
-2. 复制 `uploads/` 与 `filter_words.txt`
-3. 迁移 `forum_settings` 键值(或后台重新配置)
-4. 会话:旧 JWT 密钥兼容一阶段,或强制全员重登
-5. OIDC 客户端:`oauth_clients` 表 + 重新下发密钥(若无法迁移哈希)
+1. SQLite:复制 `jiang13.db`;或 dump 到 PG/MySQL 并映射表
+2. 复制 `uploads/`、`.jwt_secret`(HMAC 兼容);敏感词若仍在文件可启动导入
+3. 迁移 `forum_settings` 或后台重配(含 `filter_words`)
+4. OIDC:`oauth_clients` + settings 中 PEM(或遗留 `.oidc_rsa.pem` 一次迁移)
+5. 旧站 JWT Cookie 无效;用户需重新登录(opaque session)
表语义以 [03-data-model.md](03-data-model.md) 为准。
diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md
index 27436d7..4c923cc 100644
--- a/docs/rebuild-spec/08-gitea-ssr-architecture.md
+++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md
@@ -10,59 +10,77 @@
| 层 | 选择 |
|----|------|
-| 公开页渲染 | Go `html/template` **真 SSR**(完整 HTML,含帖文/列表 DOM) |
-| 渐进增强 | `web_src/` 少量 CSS/JS,构建后嵌入 |
+| 公开页渲染 | Go `html/template` **真 SSR** |
+| 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG |
+| JSON `/api` | **仅机器客户端**(OIDC 等);不服务已迁页面 UI |
+| 渐进增强 | `web_src/` → `public/assets/` → `/ssr-assets/` |
| 发布 | 单二进制 + `go:embed` |
-| 业务语义 | 仍以本目录 `01`–`07` 为准 |
-| 不做 | React/Next 公开页 SPA;用户与爬虫双轨 HTML |
+| 业务语义 | `01`–`07`;冲突时改代码并回写规格 |
+| 不做 | React SPA、爬虫/用户双轨 HTML、为旧 SPA 保留死代码 |
---
-## 开发分支与对照
+## 分支
| 分支 | 用途 |
|------|------|
-| `main` | 现网 **React SPA** 对照,勿在此做破坏性 SSR 替换 |
-| `rebuild/gitea-ssr` | **唯一** Gitea 式重构开发分支 |
-
-对照运行:
-
-```bash
-git checkout main # 旧 SPA
-# 或
-git worktree add ../jiang13-spa main
-```
+| `main` | React SPA 对照(git checkout / worktree) |
+| `rebuild/gitea-ssr` | 唯一重建分支 |
---
-## 目录职责(本分支已落地)
+## 目录
```text
-cmd/jiang13/ # 入口
-config/ # 配置
-models/ # GORM 模型(原 model/)
-services/ # 业务逻辑(原 service/)
routers/
- setup.go # 路由总装(原 router/)
- web/ # HTML SSR
- api/ # JSON API(原 handler/)
+ setup.go
+ install/ # INSTALL_LOCK 未置位时的安装向导
+ web/ # HTML + 表单
+ api/ # 精简机器接口(health / OIDC / robots / sitemap / media)
modules/
- auth/ # JWT / 限流等(原 middleware/)
- webrender/ # 模板渲染
- seo/ # PageMeta 等
-templates/ # Go 模板(embed)
-web_src/ # CSS/JS 源码
-public/assets/ # 构建产物(URL 前缀 `/ssr-assets/`)
-docs/rebuild-spec/ # 产品规格
-.cursor/rules/ # AI 开发规则
+ webctx/ # Doer / CSRF / Flash / HTML / Redirect
+ auth/
+ webrender/
+ seo/
+templates/
+ install.tmpl
+ post-install.tmpl
+ base/ home/ post/ shared/ status/ auth/ admin/
+services/
+web_src/ → public/assets/
```
-**已删除(勿恢复):** `frontend/`、`embed_static/`。SPA 对照仅看 `main`。
+**已删除:** `frontend/`、`embed_static/`、`ServePublicSPA`、爬虫双轨 HTML、首注册变管理员 bootstrap、`app.ini`、浏览器 JWT Cookie 登录。
+
+**配置:** 引导 = CLI/Env(含 `DB_*`);运行时 = `forum_settings` 热更;`.jwt_secret` = App HMAC;OIDC PEM 启用时进 settings。详见 [07-config-ops.md](07-config-ops.md)。
+
+**会话:** Cookie `jiang13_session` → 表 `sessions`;可吊销。
+
+**数据库:** GORM 方言 `sqlite`(默认)| `postgres` | `mysql`;连库失败不回落。
+
+**后置:** Gitea 仓库同步(不启后台任务)。
---
-## 渲染原则
+## 安装
-1. 用户访问已迁移路径时,「查看源代码」须可见内容 DOM,而非空壳。
-2. JSON `/api` 留给交互增强与后台;**不得**作为公开页首屏唯一数据来源。
-3. 模板默认 HTML 转义;可信 HTML(已消毒正文)用明确的安全管道,禁止随意 `| safe`。
+- 锁文件:`data/install.lock`
+- 未安装:除 `/install`、`/ssr-assets/*`、`/health` 外一律重定向到安装向导
+- 管理员仅由安装向导创建;已有用户数据启动时会自动补写锁
+- 向导不选库:库由启动 Env 决定
+
+---
+
+## 渲染与交互原则
+
+1. 已迁路径「查看源代码」须含内容 DOM。
+2. UI 读写不依赖 `/api` 灌首屏或写操作(`/compose/upload` 为同站表单辅助 JSON,带 CSRF)。
+3. 模板默认转义;`safeHTML` 仅用于消毒 + 门控后正文/评论 HTML。
+4. 未迁路径用 `status/pending.tmpl` 或 404,不维护 SPA 占位语义。
+5. 会话 Cookie:`jiang13_session`;`SameSite=Lax`;HTTPS 下 `Secure`。
+
+### 已迁路径(摘要)
+
+公开写:`/install`、`/login`、`/logout`、`/register`、`/compose`、`/post/:id/edit`、帖详情评论/赞/藏。
+
+Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。
\ No newline at end of file
diff --git a/docs/rebuild-spec/README.md b/docs/rebuild-spec/README.md
index 068bf89..c6a13e0 100644
--- a/docs/rebuild-spec/README.md
+++ b/docs/rebuild-spec/README.md
@@ -37,7 +37,7 @@
| 后端 | Go · Gin · GORM · SQLite |
| 前端 | React 18 SPA · TipTap · Tailwind · TanStack Virtual |
| 发布 | Vite 构建 → `go:embed` 打进单二进制 |
-| 认证 | bcrypt + JWT Cookie(`jiang13_token`) |
+| 认证 | bcrypt + DB opaque session Cookie(`jiang13_session`) |
演示站:https://bbs.iioio.com/
@@ -64,7 +64,7 @@ flowchart LR
| 非真 SSR | 生产入口(`main` 的 `embed_static`)只注入 title / branding / Open Graph,**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR |
| 爬虫双轨 | [`routers/api/seo_bot.go`](../../routers/api/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 |
| 无正式 migration | Schema 靠 GORM `AutoMigrate`([`models/db.go`](../../models/db.go)) | 升级靠「加字段」,难做破坏性迁移与审计 |
-| Cookie JWT | 无 session 表,密钥在 `data/.jwt_secret` | 可保留语义,实现可换成更好的会话方案 |
+| Cookie JWT(旧) | 浏览器登录曾用 JWT Cookie | **本分支已改为** DB `sessions` + opaque Cookie `jiang13_session`;`.jwt_secret` 仅 CSRF/HMAC |
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO meta 与正文同源。技术选型自定(Next.js / Nuxt / Remix / 其它均可)。
@@ -77,13 +77,13 @@ flowchart LR
- [02-features.md](02-features.md) 中列出的功能能力
- [03-data-model.md](03-data-model.md) 中的实体关系与枚举含义(表名可改,语义对齐)
- [05-business-rules.md](05-business-rules.md) 中的数值与状态机(积分、审核、门控、悬赏分成等)
-- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;首个注册用户为管理员
+- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;**管理员仅由 `/install` 创建**(不再首注册变管理员)
### 建议兼容(降低迁移成本)
- [04-api.md](04-api.md) 的 JSON 字段命名与路径形状(可做版本前缀,但旧字段名便于对照)
-- Cookie 名 `jiang13_token` 或提供清晰的会话迁移方案
-- 数据目录语义:`jiang13.db`、`uploads/`、`filter_words.txt`
+- Cookie 名 `jiang13_session`(opaque session id;重建分支不做 `jiang13_token` 双读)
+- 数据目录语义:`jiang13.db`、`uploads/`;敏感词在 `forum_settings.filter_words`(旧 `filter_words.txt` 可导入)
### 可以彻底改
@@ -102,7 +102,8 @@ flowchart LR
| SSR | 服务端渲染 | 首屏 HTML 含正文,非纯客户端壳 |
| SPA | 单页应用 | 当前前台实现形态 |
| OIDC | 开放身份连接 | 本站可作 Provider,供 Gitea 等 SSO |
-| JWT | JSON Web Token | 当前登录凭证,存 Cookie |
+| JWT | JSON Web Token | OIDC 对外 `id_token`/`access_token` 仍用;**浏览器登录不用 JWT** |
+| Opaque session | 不透明会话 | Cookie 只存随机 id,服务端 `sessions` 表可吊销 |
| Feed | 信息流 | 首页 / 板块帖列表 |
| 门控 | Content gate | 登录可见 / 回复可见 / 积分可见区块 |
| 伪静态 | Permalink | 如 `/post/123.html` 的可选后缀 |
diff --git a/docs/site-posts/about-jiang13.md b/docs/site-posts/about-jiang13.md
index 170cab6..e4f1dbf 100644
--- a/docs/site-posts/about-jiang13.md
+++ b/docs/site-posts/about-jiang13.md
@@ -84,7 +84,7 @@
| 改什么 | 去哪里 |
| --- | --- |
-| 端口、数据目录、JWT | 服务器上的 `app.ini`(改后重启) |
+| 端口、数据目录、DB、JWT | CLI / Env + `data/.jwt_secret`(改引导项需重启) |
| 品牌、OIDC、邮件、对象存储、限流、敏感词等 | 后台「系统设置」(保存即生效) |
逛完板块,发第一帖或回一楼——聊起来就对了。
@@ -151,7 +151,7 @@
1. 编译得到一个二进制
2. 放到目录里运行
-3. 自动生成 `app.ini`
+3. 自动生成 `data/.jwt_secret`
4. 浏览器注册,第一个账号即管理员
### 它补哪块空缺
@@ -165,14 +165,14 @@
| --- | --- |
| 单二进制 | 前端已内嵌,不必单独部署前端目录 |
| 内置 SQLite | 零外部数据库,数据在本地目录 |
-| 精简 `app.ini` | 主要管端口、数据目录、JWT |
+| Env / CLI 引导 | 端口、数据目录、数据库 |
| 后台热配置 | OIDC、邮件、存储、品牌等保存即生效 |
| 系统服务 | 内置 Linux systemd / Windows Service |
| 跨平台 | Windows / Linux / macOS 均可编译运行 |
备份也直观:数据库、上传、配置结构清晰,拷贝即可留存。
-进程级项改 `app.ini` 后重启;业务项在管理后台改。既保留「改文件控进程」的可控性,又避免把所有开关塞进配置文件。
+进程级项改 Env/CLI 后重启;业务项在管理后台改。引导面保持精简,热更新走数据库。
---
diff --git a/go.mod b/go.mod
index c43a285..f5dc015 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,8 @@ require (
github.com/minio/minio-go/v7 v7.0.98
golang.org/x/crypto v0.46.0
golang.org/x/image v0.44.0
- gopkg.in/ini.v1 v1.67.3
+ gorm.io/driver/mysql v1.5.7
+ gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
@@ -30,15 +31,21 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/pgx/v5 v5.5.5 // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
@@ -48,13 +55,16 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/rs/xid v1.6.0 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.48.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
diff --git a/go.sum b/go.sum
index aa43ea5..d7523e3 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,7 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+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=
@@ -35,6 +36,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
@@ -48,6 +51,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+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/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/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
+github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -65,6 +76,10 @@ github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQe
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -91,6 +106,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
+github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -123,6 +140,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
@@ -133,13 +152,17 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1N
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
-gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
+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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
+gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
+gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
diff --git a/models/db.go b/models/db.go
index e091cce..e832701 100644
--- a/models/db.go
+++ b/models/db.go
@@ -5,33 +5,60 @@ import (
"log"
"os"
"path/filepath"
+ "strings"
+ "time"
- "github.com/glebarez/sqlite" // 纯 Go,支持 CGO_ENABLED=0 交叉编译
+ "github.com/glebarez/sqlite"
+ "gorm.io/driver/mysql"
+ "gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
-// InitDB 初始化 SQLite 并自动迁移
-func InitDB(dbPath string) error {
- dir := filepath.Dir(dbPath)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return fmt.Errorf("创建数据库目录失败: %w", err)
+// DatabaseConfig 与 config.DatabaseConfig 对齐的精简结构(避免 models→config 循环依赖)
+type DatabaseConfig struct {
+ Type string
+ DSN string
+ SQLitePath string
+ MaxOpenConns int
+ MaxIdleConns int
+ ConnMaxLifetimeSec int
+}
+
+// InitDB 按方言初始化数据库并自动迁移
+func InitDB(cfg DatabaseConfig) error {
+ typ := strings.ToLower(strings.TrimSpace(cfg.Type))
+ if typ == "" {
+ typ = "sqlite"
}
- db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
+ dialector, err := openDialector(typ, cfg)
+ if err != nil {
+ return err
+ }
+
+ db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
- return fmt.Errorf("连接 SQLite 失败: %w", err)
+ return fmt.Errorf("连接数据库失败 (%s): %w — 请检查 JIANG13_DB_TYPE / JIANG13_DB_DSN", typ, err)
}
sqlDB, err := db.DB()
if err != nil {
return err
}
- sqlDB.SetMaxOpenConns(1)
+ if cfg.MaxOpenConns > 0 {
+ sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
+ }
+ if cfg.MaxIdleConns > 0 {
+ sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
+ }
+ if cfg.ConnMaxLifetimeSec > 0 {
+ sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSec) * time.Second)
+ }
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{},
@@ -43,11 +70,11 @@ func InitDB(dbPath string) error {
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
&BadgeDef{}, &UserBadge{},
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
+ &Session{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
- // 存量数据默认视为已公开,避免升级后内容全部进入待审
_ = db.Model(&Post{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
_ = db.Model(&Comment{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
_ = db.Model(&Post{}).Where("post_type = '' OR post_type IS NULL").Update("post_type", PostTypeNormal).Error
@@ -55,11 +82,50 @@ func InitDB(dbPath string) error {
DB = db
seedDefaultBadges(db)
backfillUserExp(db)
- log.Println("[model] SQLite 数据库初始化完成:", dbPath)
+ log.Printf("[models] 数据库初始化完成 type=%s", typ)
return nil
}
-// PingDB 检测数据库连接是否可用(供健康检查使用)
+func openDialector(typ string, cfg DatabaseConfig) (gorm.Dialector, error) {
+ switch typ {
+ case "sqlite", "sqlite3":
+ path := cfg.SQLitePath
+ if path == "" {
+ path = cfg.DSN
+ }
+ if path == "" {
+ return nil, fmt.Errorf("sqlite 需要文件路径")
+ }
+ if path != ":memory:" {
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return nil, fmt.Errorf("创建数据库目录失败: %w", err)
+ }
+ }
+ return sqlite.Open(path), nil
+ case "postgres", "postgresql", "pg":
+ if cfg.DSN == "" {
+ return nil, fmt.Errorf("postgres 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
+ }
+ return postgres.Open(cfg.DSN), nil
+ case "mysql", "mariadb":
+ if cfg.DSN == "" {
+ return nil, fmt.Errorf("mysql 需要 JIANG13_DB_DSN 或 HOST/USER/NAME")
+ }
+ return mysql.Open(cfg.DSN), nil
+ default:
+ return nil, fmt.Errorf("不支持的数据库类型 %q", typ)
+ }
+}
+
+// DialectorName 当前驱动名(sqlite/postgres/mysql)
+func DialectorName() string {
+ if DB == nil {
+ return ""
+ }
+ return DB.Dialector.Name()
+}
+
+// PingDB 检测数据库连接是否可用
func PingDB() error {
if DB == nil {
return fmt.Errorf("数据库未初始化")
@@ -75,12 +141,12 @@ func PingDB() error {
func seedDefaultBadges(db *gorm.DB) {
defs := []BadgeDef{
{Code: "tenure_30", Name: "初来乍到", Description: "注册满 30 天", Icon: "calendar", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 30, SortOrder: 10, Enabled: true},
- {Code: "tenure_365", Name: "资深居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true},
+ {Code: "tenure_365", Name: "常驻居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true},
{Code: "likes_10", Name: "小有人气", Description: "帖子获赞累计 10", Icon: "heart", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 10, SortOrder: 30, Enabled: true},
{Code: "likes_100", Name: "人气作者", Description: "帖子获赞累计 100", Icon: "heart-handshake", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 100, SortOrder: 40, Enabled: true},
- {Code: "likes_1000", Name: "人气巨星", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true},
- {Code: "income_100", Name: "小有进账", Description: "创作分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true},
- {Code: "income_1000", Name: "创作达人", Description: "创作分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true},
+ {Code: "likes_1000", Name: "超级人气", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true},
+ {Code: "income_100", Name: "小有进账", Description: "创作者分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true},
+ {Code: "income_1000", Name: "创收达人", Description: "创作者分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true},
}
for _, d := range defs {
var n int64
@@ -91,7 +157,7 @@ func seedDefaultBadges(db *gorm.DB) {
}
}
-// backfillUserExp 对 Exp 仍为 0 的用户按存量公开内容粗算经验(仅补一次量级)
+// backfillUserExp 对 Exp 仍为 0 的用户按发帖/评论/获赞粗算经验(仅补一次语义)
func backfillUserExp(db *gorm.DB) {
var users []User
if err := db.Select("id", "exp").Where("exp = 0").Find(&users).Error; err != nil {
diff --git a/models/session.go b/models/session.go
new file mode 100644
index 0000000..fec9264
--- /dev/null
+++ b/models/session.go
@@ -0,0 +1,14 @@
+package models
+
+import "time"
+
+// Session 浏览器 opaque 会话(Cookie 只存 Id)
+type Session struct {
+ ID string `gorm:"primaryKey;size:64" json:"id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+ IP string `gorm:"size:45" json:"ip,omitempty"`
+ UserAgent string `gorm:"size:256" json:"user_agent,omitempty"`
+}
diff --git a/modules/auth/auth.go b/modules/auth/auth.go
index 863de2f..08a6705 100644
--- a/modules/auth/auth.go
+++ b/modules/auth/auth.go
@@ -3,18 +3,20 @@
import (
"fmt"
"net/http"
+ "net/url"
"strings"
- "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
)
const (
- CtxUserID = "user_id"
- CtxUsername = "username"
- CtxRole = "role"
- CookieName = "jiang13_token"
+ CtxUserID = "user_id"
+ CtxUsername = "username"
+ CtxRole = "role"
+ CtxSessionID = "session_id"
+ CookieName = "jiang13_session"
)
type AuthMiddleware struct {
@@ -25,67 +27,81 @@ func NewAuthMiddleware(auth *services.AuthService) *AuthMiddleware {
return &AuthMiddleware{auth: auth}
}
-// OptionalAuth 可选鉴权:有 token 则解析,无 token 不拦截。
-// 用户已删除或不存在时清除失效 cookie,避免前端误显示为已登录。
+// OptionalAuth 可选鉴权:有会话则加载用户;禁言/无效则清 Cookie
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
- token := extractToken(c)
- if token != "" {
- if claims, err := m.auth.ParseToken(token); err == nil {
- var user models.User
- if err := models.DB.Select("id", "username", "role").First(&user, claims.UserID).Error; err != nil {
- c.SetCookie(CookieName, "", -1, "/", "", false, true)
- } else {
- c.Set(CtxUserID, user.ID)
- c.Set(CtxUsername, user.Username)
- c.Set(CtxRole, user.Role)
- m.auth.TouchLastAccess(user.ID)
- }
- }
+ sid := extractSessionID(c)
+ if sid == "" {
+ c.Next()
+ return
}
+ user, sess, err := services.ResolveSession(sid)
+ if err != nil || user == nil {
+ ClearAuthCookie(c)
+ c.Next()
+ return
+ }
+ if user.Banned {
+ services.RevokeUserSessions(user.ID)
+ ClearAuthCookie(c)
+ c.Next()
+ return
+ }
+ c.Set(CtxUserID, user.ID)
+ c.Set(CtxUsername, user.Username)
+ c.Set(CtxRole, user.Role)
+ c.Set(CtxSessionID, sess.ID)
+ m.auth.TouchLastAccess(user.ID)
c.Next()
}
}
-// RequireAuth 必须登录
+// RequireAuth 必须登录且未禁言
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
- token := extractToken(c)
- if token == "" {
+ sid := extractSessionID(c)
+ if sid == "" {
respondAuthRequired(c)
c.Abort()
return
}
- claims, err := m.auth.ParseToken(token)
- if err != nil {
+ user, sess, err := services.ResolveSession(sid)
+ if err != nil || user == nil {
respondAuthExpired(c)
c.Abort()
return
}
- // 检查禁言
- var user models.User
- if err := models.DB.First(&user, claims.UserID).Error; err != nil || user.Banned {
+ if user.Banned {
+ services.RevokeUserSessions(user.ID)
+ ClearAuthCookie(c)
respondBanned(c)
c.Abort()
return
}
- c.Set(CtxUserID, claims.UserID)
- c.Set(CtxUsername, claims.Username)
- c.Set(CtxRole, claims.Role)
- m.auth.TouchLastAccess(claims.UserID)
+ c.Set(CtxUserID, user.ID)
+ c.Set(CtxUsername, user.Username)
+ c.Set(CtxRole, user.Role)
+ c.Set(CtxSessionID, sess.ID)
+ m.auth.TouchLastAccess(user.ID)
c.Next()
}
}
-// RequireAdmin 必须管理员
+// RequireAdmin 必须管理员(依赖上游已跑 OptionalAuth/RequireAuth,role 来自 DB)
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
+ uid, ok := c.Get(CtxUserID)
+ if !ok || uid == nil {
+ respondAuthRequired(c)
+ c.Abort()
+ return
+ }
role, exists := c.Get(CtxRole)
if !exists || role != models.RoleAdmin {
if isAPI(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
} else {
- c.Redirect(http.StatusFound, "/admin/login")
+ c.Redirect(http.StatusFound, "/")
}
c.Abort()
return
@@ -94,14 +110,13 @@ func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
}
}
-func extractToken(c *gin.Context) string {
- if auth := c.GetHeader("Authorization"); auth != "" {
- if strings.HasPrefix(auth, "Bearer ") {
- return strings.TrimPrefix(auth, "Bearer ")
- }
+func extractSessionID(c *gin.Context) string {
+ if sid, err := c.Cookie(CookieName); err == nil && sid != "" {
+ return sid
}
- if token, err := c.Cookie(CookieName); err == nil {
- return token
+ // 兼容清理旧 Cookie 名(一次性)
+ if old, err := c.Cookie("jiang13_token"); err == nil && old != "" {
+ ClearNamedCookie(c, "jiang13_token")
}
return ""
}
@@ -112,7 +127,7 @@ func isAPI(c *gin.Context) bool {
func adminLoginPath(c *gin.Context) string {
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
- return "/admin/login"
+ return "/login?redirect=" + url.QueryEscape("/admin/dashboard")
}
return "/login"
}
@@ -122,11 +137,15 @@ func respondAuthRequired(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
- c.Redirect(http.StatusFound, adminLoginPath(c))
+ redir := c.Request.URL.RequestURI()
+ if strings.HasPrefix(c.Request.URL.Path, "/admin") {
+ redir = "/admin/dashboard"
+ }
+ c.Redirect(http.StatusFound, "/login?redirect="+url.QueryEscape(redir))
}
func respondAuthExpired(c *gin.Context) {
- c.SetCookie(CookieName, "", -1, "/", "", false, true)
+ ClearAuthCookie(c)
if isAPI(c) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"})
return
@@ -139,10 +158,6 @@ func respondBanned(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
return
}
- if strings.HasPrefix(c.Request.URL.Path, "/admin") {
- c.Redirect(http.StatusFound, "/admin/login?banned=1")
- return
- }
c.Redirect(http.StatusFound, "/login?banned=1")
}
diff --git a/modules/auth/cookie.go b/modules/auth/cookie.go
new file mode 100644
index 0000000..6d9483c
--- /dev/null
+++ b/modules/auth/cookie.go
@@ -0,0 +1,43 @@
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+// ClearAuthCookie 清除登录会话 Cookie
+func ClearAuthCookie(c *gin.Context) {
+ ClearNamedCookie(c, CookieName)
+ ClearNamedCookie(c, "jiang13_token") // 清旧名
+}
+
+// ClearNamedCookie 按名清除
+func ClearNamedCookie(c *gin.Context, name string) {
+ secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: name,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// SetSessionCookie 写入 opaque session id
+func SetSessionCookie(c *gin.Context, sessionID string) {
+ secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: CookieName,
+ Value: sessionID,
+ Path: "/",
+ MaxAge: services.SessionCookieMaxAge(),
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
diff --git a/modules/webctx/context.go b/modules/webctx/context.go
new file mode 100644
index 0000000..ba1258e
--- /dev/null
+++ b/modules/webctx/context.go
@@ -0,0 +1,181 @@
+package webctx
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "html/template"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/models"
+ "git.iioio.com/freefire/jiang13-forum/modules/auth"
+ "git.iioio.com/freefire/jiang13-forum/modules/webrender"
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ csrfCookie = "jiang13_csrf"
+ flashCookie = "jiang13_flash"
+)
+
+// Context 浏览器请求上下文(对齐 Gitea context 的精简版)
+type Context struct {
+ C *gin.Context
+ Doer *models.User
+ Secret string
+}
+
+// New 从 Gin 构造;依赖 OptionalAuth / RequireAuth 已写入 user 信息时可再查库
+func New(c *gin.Context, secret string) *Context {
+ ctx := &Context{C: c, Secret: secret}
+ if id, ok := c.Get(auth.CtxUserID); ok {
+ if uid, ok := id.(uint); ok && uid > 0 {
+ var u models.User
+ if err := models.DB.First(&u, uid).Error; err == nil {
+ ctx.Doer = &u
+ }
+ }
+ }
+ return ctx
+}
+
+func (ctx *Context) IsSigned() bool { return ctx.Doer != nil }
+func (ctx *Context) IsAdmin() bool {
+ return ctx.Doer != nil && ctx.Doer.Role == models.RoleAdmin
+}
+func (ctx *Context) UserID() uint {
+ if ctx.Doer == nil {
+ return 0
+ }
+ return ctx.Doer.ID
+}
+
+// SkipsModeration 管理员或认证用户免审
+func (ctx *Context) SkipsModeration() bool {
+ return ctx.Doer != nil && ctx.Doer.SkipsModeration()
+}
+
+// HTML 渲染命名模板
+func (ctx *Context) HTML(status int, name string, data any) {
+ ctx.C.Header("Content-Type", "text/html; charset=utf-8")
+ ctx.C.Status(status)
+ if err := webrender.Execute(ctx.C.Writer, name, data); err != nil {
+ ctx.C.String(http.StatusInternalServerError, "模板渲染失败")
+ }
+}
+
+// Redirect 303 见其它 URI(PRG)
+func (ctx *Context) Redirect(url string) {
+ ctx.C.Redirect(http.StatusSeeOther, url)
+}
+
+// SetFlash 一次性提示(下一请求读取)
+func (ctx *Context) SetFlash(msg string) {
+ v := base64.RawURLEncoding.EncodeToString([]byte(msg))
+ ctx.writeCookie(flashCookie, v, 120, true)
+}
+
+// TakeFlash 读取并清除
+func (ctx *Context) TakeFlash() string {
+ v, err := ctx.C.Cookie(flashCookie)
+ if err != nil || v == "" {
+ return ""
+ }
+ ctx.writeCookie(flashCookie, "", -1, true)
+ b, err := base64.RawURLEncoding.DecodeString(v)
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+// EnsureCSRF 保证 CSRF cookie,并返回表单 token
+func (ctx *Context) EnsureCSRF() string {
+ if t, err := ctx.C.Cookie(csrfCookie); err == nil && t != "" && ctx.validCSRF(t) {
+ return t
+ }
+ t := ctx.newCSRF()
+ ctx.writeCookie(csrfCookie, t, int((12 * time.Hour).Seconds()), true)
+ return t
+}
+
+// CheckCSRF 校验表单 _csrf(或请求头 X-CSRF-Token,供上传 fetch)
+func (ctx *Context) CheckCSRF() bool {
+ form := strings.TrimSpace(ctx.C.PostForm("_csrf"))
+ if form == "" {
+ form = strings.TrimSpace(ctx.C.GetHeader("X-CSRF-Token"))
+ }
+ cookie, _ := ctx.C.Cookie(csrfCookie)
+ if form == "" || cookie == "" || form != cookie {
+ return false
+ }
+ return ctx.validCSRF(form)
+}
+
+func (ctx *Context) newCSRF() string {
+ ts := strconv.FormatInt(time.Now().Unix(), 10)
+ mac := hmac.New(sha256.New, []byte(ctx.Secret))
+ _, _ = mac.Write([]byte(ts))
+ sig := hex.EncodeToString(mac.Sum(nil))[:32]
+ return ts + "." + sig
+}
+
+func (ctx *Context) validCSRF(token string) bool {
+ parts := strings.SplitN(token, ".", 2)
+ if len(parts) != 2 {
+ return false
+ }
+ ts, err := strconv.ParseInt(parts[0], 10, 64)
+ if err != nil {
+ return false
+ }
+ if time.Since(time.Unix(ts, 0)) > 12*time.Hour {
+ return false
+ }
+ mac := hmac.New(sha256.New, []byte(ctx.Secret))
+ _, _ = mac.Write([]byte(parts[0]))
+ sig := hex.EncodeToString(mac.Sum(nil))[:32]
+ return hmac.Equal([]byte(sig), []byte(parts[1]))
+}
+
+// SetLoginCookie 写入 opaque 会话 Cookie
+func (ctx *Context) SetLoginCookie(sessionID string) {
+ auth.SetSessionCookie(ctx.C, sessionID)
+}
+
+// ClearLoginCookie 退出并删 Cookie;若有 session id 则吊销
+func (ctx *Context) ClearLoginCookie() {
+ if sid, err := ctx.C.Cookie(auth.CookieName); err == nil && sid != "" {
+ services.DeleteSession(sid)
+ }
+ auth.ClearAuthCookie(ctx.C)
+}
+
+func (ctx *Context) writeCookie(name, value string, maxAge int, httpOnly bool) {
+ secure := requestIsHTTPS(ctx.C)
+ http.SetCookie(ctx.C.Writer, &http.Cookie{
+ Name: name,
+ Value: value,
+ Path: "/",
+ MaxAge: maxAge,
+ HttpOnly: httpOnly,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// requestIsHTTPS 直连 TLS 或反代 X-Forwarded-Proto
+func requestIsHTTPS(c *gin.Context) bool {
+ if c.Request.TLS != nil {
+ return true
+ }
+ return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
+}
+
+// SafeHTML 供模板使用的类型别名说明(实际转换在 webrender FuncMap)
+type SafeHTML = template.HTML
diff --git a/modules/webrender/render.go b/modules/webrender/render.go
index 890e44d..27dafe8 100644
--- a/modules/webrender/render.go
+++ b/modules/webrender/render.go
@@ -19,6 +19,7 @@ var (
func funcMap() template.FuncMap {
return template.FuncMap{
+ "safeHTML": func(s string) template.HTML { return template.HTML(s) },
"sortURL": func(boardID uint, sort string) string {
q := url.Values{}
if sort != "" && sort != "latest" {
@@ -50,13 +51,36 @@ func funcMap() template.FuncMap {
}
return path
},
+ "postURL": func(id uint) string {
+ return fmt.Sprintf("/post/%d", id)
+ },
}
}
+var parseGlobs = []string{
+ "*.tmpl",
+ "base/*.tmpl",
+ "home/*.tmpl",
+ "post/*.tmpl",
+ "shared/*.tmpl",
+ "status/*.tmpl",
+ "auth/*.tmpl",
+ "admin/*.tmpl",
+}
+
// Load 解析全部模板(进程内一次)
func Load() (*template.Template, error) {
loadOnce.Do(func() {
- tpl, loadErr = template.New("root").Funcs(funcMap()).ParseFS(apptemplates.FS, "*.tmpl")
+ root := template.New("root").Funcs(funcMap())
+ var err error
+ for _, g := range parseGlobs {
+ root, err = root.ParseFS(apptemplates.FS, g)
+ if err != nil {
+ loadErr = err
+ return
+ }
+ }
+ tpl = root
})
return tpl, loadErr
}
@@ -69,3 +93,10 @@ func Execute(w io.Writer, name string, data any) error {
}
return t.ExecuteTemplate(w, name, data)
}
+
+// ResetForTest 测试用重置
+func ResetForTest() {
+ loadOnce = sync.Once{}
+ tpl = nil
+ loadErr = nil
+}
diff --git a/public/assets/site.css b/public/assets/site.css
index 1cecad3..c30437d 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -161,3 +161,187 @@ body.j13-body {
color: var(--j13-muted);
font-size: 0.8rem;
}
+
+.j13-flash, .j13-alert {
+ max-width: 1100px;
+ margin: 0.75rem auto;
+ padding: 0.65rem 1rem;
+ border-radius: var(--j13-radius);
+}
+.j13-flash { background: #ecfdf5; color: #065f46; }
+.j13-alert--error { background: #fef2f2; color: #991b1b; }
+
+.j13-main--solo {
+ max-width: 480px;
+ margin: 2rem auto;
+ padding: 0 1rem 2rem;
+}
+.j13-form label {
+ display: block;
+ margin-bottom: 0.85rem;
+ font-size: 0.9rem;
+}
+.j13-form input, .j13-form textarea {
+ display: block;
+ width: 100%;
+ margin-top: 0.25rem;
+ padding: 0.5rem 0.65rem;
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ font: inherit;
+}
+.j13-form button, .j13-post__actions button, .j13-comment-form button {
+ margin-top: 0.5rem;
+ padding: 0.45rem 0.9rem;
+ border: 0;
+ border-radius: var(--j13-radius);
+ background: var(--j13-accent);
+ color: #fff;
+ cursor: pointer;
+ font: inherit;
+}
+.j13-inline-form { display: inline; margin: 0; }
+.j13-linkbtn {
+ background: none;
+ border: 0;
+ color: var(--j13-accent);
+ cursor: pointer;
+ font: inherit;
+ padding: 0;
+ text-decoration: underline;
+}
+.j13-post__title { margin: 0 0 0.5rem; font-size: 1.5rem; }
+.j13-post__meta, .j13-post__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 0.75rem;
+ align-items: center;
+ color: var(--j13-muted);
+ font-size: 0.85rem;
+ margin-bottom: 1rem;
+}
+.j13-post__content {
+ background: var(--j13-surface);
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ padding: 1rem 1.15rem;
+ overflow-wrap: anywhere;
+}
+.j13-comments { margin-top: 2rem; }
+.j13-comment-list { list-style: none; margin: 0; padding: 0; }
+.j13-comment {
+ padding: 0.85rem 0;
+ border-bottom: 1px solid var(--j13-border);
+}
+.j13-comment__meta {
+ display: flex;
+ gap: 0.75rem;
+ color: var(--j13-muted);
+ font-size: 0.8rem;
+ margin-bottom: 0.35rem;
+}
+.j13-comment-form textarea { width: 100%; }
+.j13-muted { color: var(--j13-muted); }
+.j13-install fieldset {
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ margin: 0 0 1rem;
+ padding: 0.75rem 1rem 0.25rem;
+}
+.j13-form select {
+ display: block;
+ width: 100%;
+ margin-top: 0.25rem;
+ padding: 0.5rem 0.65rem;
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ font: inherit;
+ background: #fff;
+}
+.j13-form__row {
+ display: flex;
+ gap: 0.5rem;
+ align-items: stretch;
+ margin-top: 0.25rem;
+}
+.j13-form__row input { flex: 1; margin-top: 0; }
+.j13-btn-secondary {
+ margin-top: 0 !important;
+ padding: 0.45rem 0.75rem;
+ border: 1px solid var(--j13-border) !important;
+ border-radius: var(--j13-radius);
+ background: var(--j13-surface) !important;
+ color: var(--j13-text) !important;
+ cursor: pointer;
+ font: inherit;
+ white-space: nowrap;
+}
+.j13-compose__tools {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ margin: 0.5rem 0 1rem;
+}
+.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
+.j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; }
+.j13-post__content img { max-width: 100%; height: auto; border-radius: 4px; }
+
+.j13-admin { max-width: 880px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
+.j13-admin h1 { margin: 0 0 0.75rem; font-size: 1.4rem; }
+.j13-admin h2 { margin: 1.75rem 0 0.75rem; font-size: 1.1rem; }
+.j13-admin-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem 1rem;
+ margin-bottom: 1.25rem;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--j13-border);
+ font-size: 0.9rem;
+}
+.j13-admin-nav a { color: var(--j13-muted); text-decoration: none; }
+.j13-admin-nav a:hover,
+.j13-admin-nav a.is-active { color: var(--j13-accent); font-weight: 600; }
+.j13-admin-stats {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+.j13-admin-stats li {
+ min-width: 5.5rem;
+ padding: 0.75rem 1rem;
+ background: var(--j13-surface);
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+.j13-admin-stats strong { font-size: 1.35rem; }
+.j13-admin-stats span { font-size: 0.8rem; color: var(--j13-muted); }
+.j13-admin-list { list-style: none; margin: 0; padding: 0; }
+.j13-admin-card {
+ padding: 1rem 0;
+ border-bottom: 1px solid var(--j13-border);
+}
+.j13-admin-form { max-width: 36rem; }
+.j13-admin-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: flex-end;
+ margin-top: 0.5rem;
+}
+.j13-admin-reject {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+ margin: 0;
+}
+.j13-admin-reject input { width: auto; min-width: 12rem; margin: 0; }
+.j13-admin-reject button { margin: 0; }
+
+
diff --git a/public/assets/site.js b/public/assets/site.js
index 56e0267..a4443bb 100644
--- a/public/assets/site.js
+++ b/public/assets/site.js
@@ -1,2 +1,46 @@
-// 姜十三论坛 SSR 渐进增强入口(骨架阶段仅占位)
+// 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1";
+
+(function () {
+ const form = document.getElementById("compose-form");
+ const fileInput = document.getElementById("compose-image");
+ const textarea = document.getElementById("compose-content");
+ const statusEl = document.getElementById("compose-upload-status");
+ if (!form || !fileInput || !textarea) return;
+
+ const csrf = form.getAttribute("data-csrf") || "";
+ const uploadURL = form.getAttribute("data-upload") || "/compose/upload";
+
+ fileInput.addEventListener("change", async () => {
+ const file = fileInput.files && fileInput.files[0];
+ fileInput.value = "";
+ if (!file) return;
+ if (statusEl) statusEl.textContent = "上传中…";
+ const fd = new FormData();
+ fd.append("image", file);
+ fd.append("_csrf", csrf);
+ try {
+ const res = await fetch(uploadURL, {
+ method: "POST",
+ headers: { "X-CSRF-Token": csrf },
+ body: fd,
+ credentials: "same-origin",
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(data.error || "上传失败");
+ }
+ const url = data.url;
+ if (!url) throw new Error("未返回图片地址");
+ const md = `\n\n\n\n`;
+ const start = textarea.selectionStart || textarea.value.length;
+ const end = textarea.selectionEnd || start;
+ textarea.value =
+ textarea.value.slice(0, start) + md + textarea.value.slice(end);
+ textarea.focus();
+ if (statusEl) statusEl.textContent = "已插入图片";
+ } catch (e) {
+ if (statusEl) statusEl.textContent = e.message || "上传失败";
+ }
+ });
+})();
diff --git a/routers/api/api.go b/routers/api/api.go
index 80af074..a6b4e82 100644
--- a/routers/api/api.go
+++ b/routers/api/api.go
@@ -758,21 +758,9 @@ func (h *Handlers) APIProjects(c *gin.Context) {
})
}
-// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置
+// APIAdminUpdateGiteaSettings Gitea 同步已后置
func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
- var req services.GiteaSyncConfig
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
- return
- }
- if err := h.Settings.UpdateGiteaSyncConfig(req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{
- "message": "Gitea 同步设置已保存",
- "gitea": h.Settings.GiteaSyncConfigPublic(),
- })
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"})
}
// APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换
@@ -796,22 +784,9 @@ func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) {
})
}
-// APIAdminSyncGitea 立即同步 Gitea 公开仓库
+// APIAdminSyncGitea Gitea 同步已后置
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
- if h.Gitea == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrGiteaNotConfigured.Error()})
- return
- }
- n, err := h.Gitea.SyncRepos()
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{
- "message": fmt.Sprintf("同步完成,共更新 %d 个仓库", n),
- "count": n,
- "gitea": h.Settings.GiteaSyncConfigPublic(),
- })
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"})
}
// APIAdminListOAuthClients 列出 OAuth 应用
diff --git a/routers/api/handlers.go b/routers/api/handlers.go
index 8a2c417..4abd801 100644
--- a/routers/api/handlers.go
+++ b/routers/api/handlers.go
@@ -42,8 +42,11 @@ type Handlers struct {
FriendLinkApply *services.FriendLinkApplyService
}
-func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
- c.SetCookie(auth.CookieName, token, int(services.TokenExpire.Seconds()), "/", "", false, true)
+func (h *Handlers) setAuthCookie(c *gin.Context, sessionID string) {
+ if sessionID == "" {
+ return
+ }
+ auth.SetSessionCookie(c, sessionID)
}
func (h *Handlers) currentUserID(c *gin.Context) uint {
@@ -126,13 +129,12 @@ func (h *Handlers) APICaptcha(c *gin.Context) {
// APIRegisterConfig 注册页所需公开配置
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
- userCount := h.Auth.UserCount()
mailReady := h.Settings.MailReady()
c.JSON(http.StatusOK, gin.H{
- "is_first_user": userCount == 0,
+ "is_first_user": false, // 已废弃:管理员仅由 /install 创建
"mail_ready": mailReady,
"require_email_code": mailReady,
- "register_open": userCount == 0 || mailReady,
+ "register_open": mailReady,
"email_code_len": services.EmailCodeLen,
})
}
@@ -259,7 +261,7 @@ func (h *Handlers) APIRegister(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP())
+ token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
h.setAuthCookie(c, token)
c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID})
}
@@ -273,7 +275,7 @@ func (h *Handlers) APILogin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
- token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
+ token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
diff --git a/routers/api/seo.go b/routers/api/seo.go
index 30dc413..5b96278 100644
--- a/routers/api/seo.go
+++ b/routers/api/seo.go
@@ -1,30 +1,15 @@
package api
import (
- "encoding/json"
- "fmt"
- "html"
"net/http"
- "regexp"
- "strconv"
"strings"
"time"
- "github.com/gin-gonic/gin"
- "git.iioio.com/freefire/jiang13-forum/modules/seo"
- "git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
)
-var (
- seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
-)
-
-const (
- seoDescMax = 160
- seoPrerenderMax = 4000
- seoSitemapLimit = 5000
-)
+const seoSitemapLimit = 5000
// RobotsTxt 搜索引擎抓取规则
func (h *Handlers) RobotsTxt(c *gin.Context) {
@@ -34,14 +19,10 @@ func (h *Handlers) RobotsTxt(c *gin.Context) {
b.WriteString("Allow: /\n")
b.WriteString("Disallow: /api/\n")
b.WriteString("Disallow: /admin\n")
- b.WriteString("Disallow: /compose\n")
+ b.WriteString("Disallow: /install\n")
b.WriteString("Disallow: /login\n")
- b.WriteString("Disallow: /register\n")
- b.WriteString("Disallow: /profile\n")
- b.WriteString("Disallow: /favorites\n")
+ b.WriteString("Disallow: /compose\n")
b.WriteString("Disallow: /oauth/\n")
- b.WriteString("Disallow: /media/\n")
- b.WriteString("Disallow: /*/edit\n")
if base != "" {
b.WriteString("\nSitemap: ")
b.WriteString(base)
@@ -50,7 +31,7 @@ func (h *Handlers) RobotsTxt(c *gin.Context) {
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
}
-// SitemapXML 公开页面站点地图
+// SitemapXML 公开页面站点地图(与 SSR 同源路径)
func (h *Handlers) SitemapXML(c *gin.Context) {
base := h.publicBaseURL(c)
if base == "" {
@@ -62,8 +43,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
permalink := h.Settings.Permalink()
urls := []services.SitemapURL{
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
- {Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"},
- {Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"},
}
if boards, err := h.Board.List(); err == nil {
@@ -92,38 +71,11 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
}
}
- if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil {
- for _, u := range users {
- urls = append(urls, services.SitemapURL{
- Loc: base + permalink.UserPath(u.ID),
- LastMod: u.UpdatedAt.UTC(),
- ChangeFreq: "weekly",
- Priority: "0.5",
- })
- }
- }
-
- if pages, e3 := h.SitePage.ListSitemap(seoSitemapLimit); e3 == nil {
- for _, p := range pages {
- lm := p.UpdatedAt
- if lm.IsZero() {
- lm = p.CreatedAt
- }
- urls = append(urls, services.SitemapURL{
- Loc: base + permalink.PagePath(p.Slug),
- LastMod: lm.UTC(),
- ChangeFreq: "monthly",
- Priority: "0.5",
- })
- }
- }
-
var b strings.Builder
b.WriteString(``)
b.WriteString(``)
for _, u := range urls {
- b.WriteString("")
- b.WriteString("")
+ b.WriteString("")
b.WriteString(xmlEscape(u.Loc))
b.WriteString("")
if !u.LastMod.IsZero() {
@@ -147,382 +99,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
}
-// ServePublicSPA 公开页入口:
-// - 普通用户:干净 SPA + meta(无正文预渲染,避免刷新闪屏)
-// - 搜索/社交爬虫:服务端 HTML(动态渲染)
-// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301
-func (h *Handlers) ServePublicSPA(c *gin.Context) {
- path := c.Request.URL.Path
-
- brand := h.Settings.SiteBranding()
- base := h.publicBaseURL(c)
- siteName := strings.TrimSpace(brand.Name)
- if siteName == "" {
- siteName = "姜十三论坛"
- }
- defaultImage := services.AbsoluteURL(base, brand.DefaultShareImage())
- siteKeywords := brand.MetaKeywords()
- permalink := h.Settings.Permalink()
-
- // 旧版 /?board=id → 规范板块路径
- if path == "/" || path == "" {
- if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 {
- target := services.QueryBoardHome(uint(boardID), permalink)
- if q := c.Request.URL.RawQuery; q != "" {
- // 保留 sort/keyword 等 query,去掉 board
- vals := c.Request.URL.Query()
- vals.Del("board")
- if rest := vals.Encode(); rest != "" {
- target += "?" + rest
- }
- }
- c.Redirect(http.StatusMovedPermanently, target)
- return
- }
- }
-
- isBot := services.IsSEOCrawler(c.Request.UserAgent())
- if isBot {
- c.Header("Vary", "User-Agent")
- }
-
- // 板块首页(含可选伪静态后缀)
- if bm := permalink.MatchBoardPath(path); bm.OK {
- if bm.NeedsCanonicalRedirect(path) {
- c.Redirect(http.StatusMovedPermanently, bm.Canonical+preserveQueryExceptBoard(c))
- return
- }
- board, err := h.Board.GetByID(bm.ID)
- if err != nil {
- h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
- return
- }
- desc := strings.TrimSpace(board.Description)
- if desc == "" {
- desc = brand.MetaDescription()
- }
- meta := attachSiteSEO(&seo.PageMeta{
- Title: pageTitle(board.Name, siteName),
- Description: services.TruncateRunes(desc, seoDescMax),
- Keywords: services.JoinSEOKeywords(board.Name, siteKeywords),
- Canonical: services.AbsoluteURL(base, bm.Canonical),
- OGType: "website",
- OGImage: defaultImage,
- }, siteName, siteKeywords)
- if isBot {
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board)))
- return
- }
- servePendingSSR(c, meta.Title, `板块页 SSR 迁移中,请先从 首页 浏览。
`)
- return
- }
-
- // 帖子详情(含可选伪静态后缀)
- if pm := permalink.MatchPostPath(path); pm.OK {
- if pm.NeedsCanonicalRedirect(path) {
- c.Redirect(http.StatusMovedPermanently, pm.Canonical)
- return
- }
- post, err := h.Post.FindByID(pm.ID)
- if err != nil || !services.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) {
- h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
- return
- }
- postKeywords := services.JoinSEOKeywords(post.Board.Name, siteKeywords)
- if isBot {
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post)))
- return
- }
- servePendingSSR(c, pageTitle(post.Title, siteName), `帖子详情 SSR 迁移中,请先从 首页 返回。
`)
- return
- }
-
- // 用户主页
- if um := permalink.MatchUserPath(path); um.OK {
- if um.NeedsCanonicalRedirect(path) {
- c.Redirect(http.StatusMovedPermanently, um.Canonical)
- return
- }
- user, err := h.User.GetByID(um.ID)
- if err != nil || user.Banned {
- h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
- return
- }
- if isBot {
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user)))
- return
- }
- servePendingSSR(c, pageTitle(user.Nickname, siteName), `用户主页 SSR 迁移中,请先从 首页 返回。
`)
- return
- }
-
- // 自定义单页
- if pg := permalink.MatchPagePath(path); pg.OK {
- if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") {
- c.Redirect(http.StatusMovedPermanently, pg.Canonical)
- return
- }
- page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c))
- if err != nil {
- h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
- return
- }
- desc := services.ExcerptFromHTML(page.Content, seoDescMax)
- meta := attachSiteSEO(&seo.PageMeta{
- Title: pageTitle(page.Title, siteName),
- Description: desc,
- Keywords: services.JoinSEOKeywords(page.Title, siteKeywords),
- Canonical: services.AbsoluteURL(base, pg.Canonical),
- OGType: "article",
- OGImage: defaultImage,
- }, siteName, siteKeywords)
- if isBot {
- body := fmt.Sprintf(`%s
%s
`, html.EscapeString(page.Title), page.Content)
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body)))
- return
- }
- servePendingSSR(c, meta.Title, page.Content)
- return
- }
-
- // 未知路径 → 404
- if !isKnownPublicPath(path) {
- h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
- return
- }
-
- // 其余已知路由:爬虫可读首页;用户走占位页(首页本身已由 routers/web SSR)
- meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
- if isBot && (path == "/" || path == "") {
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand)))
- return
- }
- servePendingSSR(c, meta.Title, `该页面 SSR 迁移中。返回首页
`)
-}
-
-func servePendingSSR(c *gin.Context, title, bodyHTML string) {
- if strings.TrimSpace(title) == "" {
- title = "姜十三论坛"
- }
- page := fmt.Sprintf(`%s%s`,
- html.EscapeString(title), bodyHTML)
- c.Header("Cache-Control", "no-cache")
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
-}
-
-func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) {
- if isBot {
- c.Header("Vary", "User-Agent")
- c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path)))
- return
- }
- page := fmt.Sprintf(`%s404
页面不存在。
返回首页
`,
- html.EscapeString(pageTitle("页面不存在", siteName)))
- c.Header("Cache-Control", "no-cache")
- c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(page))
-}
-
-func notFoundPageMeta(base, siteName, keywords, path string) *seo.PageMeta {
- return attachSiteSEO(&seo.PageMeta{
- Title: pageTitle("页面不存在", siteName),
- Description: "您访问的页面不存在或已删除",
- Canonical: services.AbsoluteURL(base, path),
- OGType: "website",
- Robots: "noindex,follow",
- Status: http.StatusNotFound,
- }, siteName, keywords)
-}
-
-// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale
-func attachSiteSEO(meta *seo.PageMeta, siteName, keywords string) *seo.PageMeta {
- if meta == nil {
- return nil
- }
- meta.SiteName = strings.TrimSpace(siteName)
- if strings.TrimSpace(meta.Keywords) == "" {
- meta.Keywords = strings.TrimSpace(keywords)
- }
- meta.Locale = "zh_CN"
- return meta
-}
-
-func isKnownPublicPath(path string) bool {
- switch path {
- case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards":
- return true
- }
- if seoPostEditRe.MatchString(path) {
- return true
- }
- permalink := services.PermalinkConfig{}
- if permalink.MatchBoardPath(path).OK {
- return true
- }
- if permalink.MatchPagePath(path).OK {
- return true
- }
- return false
-}
-
-func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand services.SiteBranding, base, siteName, defaultImage string) *seo.PageMeta {
- siteTitle := brand.DocumentTitle()
- homeDesc := services.TruncateRunes(brand.MetaDescription(), seoDescMax)
- siteKeywords := brand.MetaKeywords()
- meta := attachSiteSEO(&seo.PageMeta{
- Title: siteTitle,
- Description: homeDesc,
- Keywords: siteKeywords,
- Canonical: services.AbsoluteURL(base, pathWithQuery(c)),
- OGType: "website",
- OGImage: defaultImage,
- }, siteName, siteKeywords)
-
- if isNoIndexPath(path) {
- meta.Robots = "noindex,nofollow"
- meta.Title = pageTitle(pathLabel(path), siteName)
- return meta
- }
-
- if path == "/" || path == "" {
- boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
- if boardID > 0 {
- if board, err := h.Board.GetByID(uint(boardID)); err == nil {
- desc := strings.TrimSpace(board.Description)
- if desc == "" {
- desc = brand.MetaDescription()
- }
- meta.Title = pageTitle(board.Name, siteName)
- meta.Description = services.TruncateRunes(desc, seoDescMax)
- meta.Canonical = services.AbsoluteURL(base, services.QueryBoardHome(board.ID, h.Settings.Permalink()))
- meta.Keywords = services.JoinSEOKeywords(board.Name, siteKeywords)
- return meta
- }
- // 无效板块 id:仍显示首页,但可标记 noindex
- meta.Robots = "noindex,follow"
- return meta
- }
- meta.JSONLD = mustJSON(map[string]any{
- "@context": "https://schema.org",
- "@type": "WebSite",
- "name": siteName,
- "description": meta.Description,
- "url": services.AbsoluteURL(base, "/"),
- })
- }
-
- if path == "/projects" {
- meta.Title = pageTitle("项目", siteName)
- meta.Description = services.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
- meta.Keywords = services.JoinSEOKeywords("项目", siteKeywords)
- }
-
- if path == "/links" {
- meta.Title = pageTitle("友情链接", siteName)
- meta.Description = services.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
- meta.Keywords = services.JoinSEOKeywords("友情链接", siteKeywords)
- }
-
- return meta
-}
-
-func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *models.Post) *seo.PageMeta {
- permalink := h.Settings.Permalink()
- content := services.RedactGatedPostHTML(post.Content)
- plain := post.ContentPlain
- if plain == "" {
- plain = services.StripHTMLForSearch(content)
- }
- desc := services.TruncateRunes(plain, seoDescMax)
- author := services.DisplayName(&post.User)
- canonical := services.AbsoluteURL(base, permalink.PostPath(post.ID))
- ogImage := services.AbsoluteURL(base, services.FirstImageURL(content))
- if ogImage == "" {
- ogImage = services.AbsoluteURL(base, post.User.Avatar)
- }
- if ogImage == "" {
- ogImage = defaultImage
- }
-
- jsonld := map[string]any{
- "@context": "https://schema.org",
- "@type": "DiscussionForumPosting",
- "headline": post.Title,
- "description": desc,
- "datePublished": post.CreatedAt.UTC().Format(time.RFC3339),
- "dateModified": post.UpdatedAt.UTC().Format(time.RFC3339),
- "url": canonical,
- "mainEntityOfPage": canonical,
- "author": map[string]any{
- "@type": "Person",
- "name": author,
- "url": services.AbsoluteURL(base, permalink.UserPath(post.UserID)),
- },
- "interactionStatistic": map[string]any{
- "@type": "InteractionCounter",
- "interactionType": "https://schema.org/ViewAction",
- "userInteractionCount": post.ViewCount,
- },
- }
- if post.Board.Name != "" {
- jsonld["articleSection"] = post.Board.Name
- }
- if ogImage != "" {
- jsonld["image"] = []string{ogImage}
- }
- body := services.TruncateRunes(plain, seoPrerenderMax)
- if body != "" {
- jsonld["articleBody"] = body
- }
-
- return &seo.PageMeta{
- Title: pageTitle(post.Title, siteName),
- Description: desc,
- Canonical: canonical,
- OGType: "article",
- OGImage: ogImage,
- JSONLD: mustJSON(jsonld),
- }
-}
-
-func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *models.User) *seo.PageMeta {
- permalink := h.Settings.Permalink()
- name := services.DisplayName(user)
- desc := strings.TrimSpace(user.Signature)
- if desc == "" {
- desc = name + " 的主页"
- }
- desc = services.TruncateRunes(desc, seoDescMax)
- canonical := services.AbsoluteURL(base, permalink.UserPath(user.ID))
- ogImage := services.AbsoluteURL(base, user.Avatar)
- if ogImage == "" {
- ogImage = defaultImage
- }
-
- jsonld := map[string]any{
- "@context": "https://schema.org",
- "@type": "ProfilePage",
- "url": canonical,
- "mainEntity": map[string]any{
- "@type": "Person",
- "name": name,
- "description": desc,
- "url": canonical,
- },
- }
- if ogImage != "" {
- jsonld["mainEntity"].(map[string]any)["image"] = ogImage
- }
-
- return &seo.PageMeta{
- Title: pageTitle(name+" 的主页", siteName),
- Description: desc,
- Canonical: canonical,
- OGType: "profile",
- OGImage: ogImage,
- JSONLD: mustJSON(jsonld),
- }
-}
-
func (h *Handlers) publicBaseURL(c *gin.Context) string {
return h.Settings.SitePublicBaseURL(requestOrigin(c))
}
@@ -546,105 +122,11 @@ func requestOrigin(c *gin.Context) string {
return proto + "://" + host
}
-func pathWithQuery(c *gin.Context) string {
- path := c.Request.URL.Path
- if path == "" {
- path = "/"
- }
- permalink := services.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
- if q := c.Request.URL.RawQuery; q != "" {
- if path == "/" {
- board := c.Query("board")
- if board != "" {
- _ = permalink
- return services.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
- }
- return "/"
- }
- return path + "?" + q
- }
- return path
-}
-
-func preserveQueryExceptBoard(c *gin.Context) string {
- vals := c.Request.URL.Query()
- vals.Del("board")
- if rest := vals.Encode(); rest != "" {
- return "?" + rest
- }
- return ""
-}
-
-func parseUintOrZero(s string) uint64 {
- n, _ := strconv.ParseUint(s, 10, 64)
- return n
-}
-
-func isNoIndexPath(path string) bool {
- switch {
- case path == "/login", path == "/register", path == "/compose",
- path == "/profile", path == "/favorites":
- return true
- case strings.HasPrefix(path, "/admin"):
- return true
- case strings.HasSuffix(path, "/edit"):
- return true
- default:
- return false
- }
-}
-
-func pathLabel(path string) string {
- switch {
- case path == "/login":
- return "登录"
- case path == "/register":
- return "注册"
- case path == "/compose":
- return "发帖"
- case path == "/profile":
- return "个人中心"
- case path == "/favorites":
- return "我的收藏"
- case strings.HasSuffix(path, "/edit"):
- return "编辑帖子"
- case strings.HasPrefix(path, "/admin"):
- return "管理后台"
- default:
- return ""
- }
-}
-
-func pageTitle(page, siteName string) string {
- page = strings.TrimSpace(page)
- siteName = strings.TrimSpace(siteName)
- switch {
- case page == "" && siteName == "":
- return "姜十三论坛"
- case page == "":
- return siteName
- case siteName == "":
- return page
- default:
- return page + " - " + siteName
- }
-}
-
-func mustJSON(v any) string {
- b, err := json.Marshal(v)
- if err != nil {
- return ""
- }
- return string(b)
-}
-
func xmlEscape(s string) string {
- r := strings.NewReplacer(
- `&`, "&",
- `<`, "<",
- `>`, ">",
- `"`, """,
- `'`, "'",
- )
- return r.Replace(s)
+ s = strings.ReplaceAll(s, "&", "&")
+ s = strings.ReplaceAll(s, "<", "<")
+ s = strings.ReplaceAll(s, ">", ">")
+ s = strings.ReplaceAll(s, "\"", """)
+ s = strings.ReplaceAll(s, "'", "'")
+ return s
}
diff --git a/routers/api/seo_bot.go b/routers/api/seo_bot.go
deleted file mode 100644
index 60c0626..0000000
--- a/routers/api/seo_bot.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package api
-
-import (
- "fmt"
- "html"
- "strings"
- "time"
-
- "git.iioio.com/freefire/jiang13-forum/modules/seo"
- "git.iioio.com/freefire/jiang13-forum/models"
- "git.iioio.com/freefire/jiang13-forum/services"
-)
-
-// 爬虫专用伪静态 HTML(无 SPA;仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏)
-
-func renderBotHTML(meta *seo.PageMeta, bodyInner string) string {
- if meta == nil {
- meta = &seo.PageMeta{}
- }
- ogType := strings.TrimSpace(meta.OGType)
- if ogType == "" {
- ogType = "website"
- }
- locale := strings.TrimSpace(meta.Locale)
- if locale == "" {
- locale = "zh_CN"
- }
- var b strings.Builder
- b.WriteString("")
- b.WriteString("")
- b.WriteString("")
- writeEscapedTag(&b, "title", meta.Title)
- writeEscapedMeta(&b, "name", "description", meta.Description)
- writeEscapedMeta(&b, "name", "keywords", meta.Keywords)
- if meta.Robots != "" {
- writeEscapedMeta(&b, "name", "robots", meta.Robots)
- }
- if meta.Canonical != "" {
- b.WriteString(``)
- }
- writeEscapedMeta(&b, "property", "og:type", ogType)
- writeEscapedMeta(&b, "property", "og:site_name", meta.SiteName)
- writeEscapedMeta(&b, "property", "og:locale", locale)
- writeEscapedMeta(&b, "property", "og:title", meta.Title)
- writeEscapedMeta(&b, "property", "og:description", meta.Description)
- writeEscapedMeta(&b, "property", "og:url", meta.Canonical)
- writeEscapedMeta(&b, "property", "og:image", meta.OGImage)
- card := "summary"
- if strings.TrimSpace(meta.OGImage) != "" {
- card = "summary_large_image"
- }
- writeEscapedMeta(&b, "name", "twitter:card", card)
- writeEscapedMeta(&b, "name", "twitter:title", meta.Title)
- writeEscapedMeta(&b, "name", "twitter:description", meta.Description)
- writeEscapedMeta(&b, "name", "twitter:image", meta.OGImage)
- if meta.JSONLD != "" {
- b.WriteString(``)
- }
- b.WriteString(``)
- b.WriteString("")
- b.WriteString(bodyInner)
- b.WriteString(`← 返回首页
`)
- b.WriteString("")
- return b.String()
-}
-
-func writeEscapedTag(b *strings.Builder, tag, text string) {
- text = strings.TrimSpace(text)
- if text == "" {
- return
- }
- b.WriteString("<" + tag + ">" + html.EscapeString(text) + "" + tag + ">")
-}
-
-func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
- content = strings.TrimSpace(content)
- if content == "" {
- return
- }
- b.WriteString("")
-}
-
-func (h *Handlers) botBoardHTML(meta *seo.PageMeta, board models.Board) string {
- desc := strings.TrimSpace(board.Description)
- if desc == "" {
- desc = meta.Description
- }
- body := fmt.Sprintf(`%s
%s
`,
- html.EscapeString(board.Name),
- html.EscapeString(desc),
- )
- return renderBotHTML(meta, body)
-}
-
-func (h *Handlers) botHomeHTML(meta *seo.PageMeta, brand services.SiteBranding) string {
- name := strings.TrimSpace(brand.Name)
- if name == "" {
- name = "姜十三论坛"
- }
- intro := brand.MetaDescription()
- if intro == "" {
- intro = brand.Slogan
- }
- var body strings.Builder
- body.WriteString("" + html.EscapeString(name) + "
")
- if intro != "" {
- body.WriteString("" + html.EscapeString(intro) + "
")
- }
- body.WriteString(`浏览项目
`)
- return renderBotHTML(meta, body.String())
-}
-
-func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *models.Post) string {
- meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
- content := services.SanitizePostHTML(services.RedactGatedPostHTML(post.Content))
- author := services.DisplayName(&post.User)
- var body strings.Builder
- body.WriteString("")
- body.WriteString("" + html.EscapeString(post.Title) + "
")
- body.WriteString(``)
- body.WriteString(html.EscapeString(author))
- body.WriteString(" · ")
- body.WriteString(html.EscapeString(post.CreatedAt.Local().Format("2006-01-02 15:04")))
- if post.Board.Name != "" {
- body.WriteString(" · ")
- body.WriteString(html.EscapeString(post.Board.Name))
- }
- body.WriteString("
")
- body.WriteString(content)
- body.WriteString("")
- return renderBotHTML(meta, body.String())
-}
-
-func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *models.User) string {
- meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords)
- name := services.DisplayName(user)
- sig := strings.TrimSpace(user.Signature)
- var body strings.Builder
- body.WriteString("" + html.EscapeString(name) + " 的主页
")
- if sig != "" {
- body.WriteString("" + html.EscapeString(sig) + "
")
- }
- body.WriteString(fmt.Sprintf(`加入于 %s
`, html.EscapeString(user.CreatedAt.Local().Format(time.DateOnly))))
- return renderBotHTML(meta, body.String())
-}
-
-func botNotFoundHTML(base, siteName, keywords, path string) string {
- meta := notFoundPageMeta(base, siteName, keywords, path)
- body := `页面不存在
您访问的页面不存在或已删除。
`
- return renderBotHTML(meta, body)
-}
diff --git a/routers/install/install.go b/routers/install/install.go
new file mode 100644
index 0000000..19c065d
--- /dev/null
+++ b/routers/install/install.go
@@ -0,0 +1,124 @@
+package install
+
+import (
+ "net/http"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/modules/webctx"
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+// Deps 安装依赖
+type Deps struct {
+ DataDir string
+ JWTSecret string
+ Auth *services.AuthService
+ Settings *services.ForumSettingsService
+}
+
+type pageData struct {
+ Title string
+ Description string
+ SiteName string
+ Slogan string
+ LogoMark string
+ CSRF string
+ Error string
+ Flash string
+ AdminUsername string
+ AdminEmail string
+ AdminNickname string
+}
+
+// Register 未安装时的路由(仅 /install)
+func Register(r *gin.Engine, deps Deps) {
+ r.GET("/install", deps.Get)
+ r.POST("/install", deps.Post)
+}
+
+// Get 安装页
+func (d Deps) Get(c *gin.Context) {
+ if services.IsInstalled(d.DataDir) {
+ c.Redirect(http.StatusFound, "/")
+ return
+ }
+ ctx := webctx.New(c, d.JWTSecret)
+ brand := d.Settings.SiteBranding()
+ name := brand.Name
+ if name == "" {
+ name = "姜十三论坛"
+ }
+ ctx.HTML(http.StatusOK, "install", pageData{
+ Title: "安装 · " + name, SiteName: name, LogoMark: "姜",
+ CSRF: ctx.EnsureCSRF(), AdminUsername: "admin",
+ })
+}
+
+// Post 提交安装
+func (d Deps) Post(c *gin.Context) {
+ if services.IsInstalled(d.DataDir) {
+ c.Redirect(http.StatusSeeOther, "/")
+ return
+ }
+ ctx := webctx.New(c, d.JWTSecret)
+ brand := d.Settings.SiteBranding()
+ siteName := strings.TrimSpace(c.PostForm("site_name"))
+ if siteName == "" {
+ siteName = "姜十三论坛"
+ }
+ data := pageData{
+ Title: "安装 · " + siteName, SiteName: siteName, LogoMark: "姜",
+ CSRF: ctx.EnsureCSRF(),
+ AdminUsername: strings.TrimSpace(c.PostForm("admin_username")),
+ AdminEmail: strings.TrimSpace(c.PostForm("admin_email")),
+ AdminNickname: strings.TrimSpace(c.PostForm("admin_nickname")),
+ }
+ if !ctx.CheckCSRF() {
+ data.Error = "无效请求,请重试"
+ ctx.HTML(http.StatusBadRequest, "install", data)
+ return
+ }
+ pass := c.PostForm("admin_password")
+ pass2 := c.PostForm("admin_password2")
+ if pass != pass2 {
+ data.Error = "两次密码不一致"
+ ctx.HTML(http.StatusBadRequest, "install", data)
+ return
+ }
+ if _, err := d.Auth.CreateAdmin(data.AdminUsername, pass, data.AdminNickname, data.AdminEmail); err != nil {
+ data.Error = err.Error()
+ ctx.HTML(http.StatusBadRequest, "install", data)
+ return
+ }
+ b := d.Settings.SiteBranding()
+ b.Name = siteName
+ _ = d.Settings.UpdateSiteBranding(b)
+ if err := services.WriteInstallLock(d.DataDir); err != nil {
+ data.Error = "写入安装锁失败: " + err.Error()
+ ctx.HTML(http.StatusInternalServerError, "install", data)
+ return
+ }
+ ctx.HTML(http.StatusOK, "post-install", pageData{
+ Title: "安装完成", SiteName: siteName, LogoMark: "姜",
+ })
+ _ = brand
+}
+
+// Guard 未安装则只允许 install / assets / health
+func Guard(dataDir string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if services.IsInstalled(dataDir) {
+ c.Next()
+ return
+ }
+ path := c.Request.URL.Path
+ if path == "/install" || path == "/health" ||
+ strings.HasPrefix(path, "/ssr-assets/") {
+ c.Next()
+ return
+ }
+ c.Redirect(http.StatusFound, "/install")
+ c.Abort()
+ }
+}
diff --git a/routers/setup.go b/routers/setup.go
index ad0f991..57702ff 100644
--- a/routers/setup.go
+++ b/routers/setup.go
@@ -11,6 +11,7 @@ import (
"git.iioio.com/freefire/jiang13-forum/modules/auth"
webpublic "git.iioio.com/freefire/jiang13-forum/public"
"git.iioio.com/freefire/jiang13-forum/routers/api"
+ "git.iioio.com/freefire/jiang13-forum/routers/install"
webpages "git.iioio.com/freefire/jiang13-forum/routers/web"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
@@ -22,7 +23,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery())
r.Use(gin.Logger())
- // SSR 静态资源
+ if err := services.EnsureInstallLockFromExistingData(cfg.DataDir); err != nil {
+ fmt.Fprintf(os.Stderr, "警告: 安装锁检查失败: %v\n", err)
+ }
+
if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil {
ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub)))
r.GET("/ssr-assets/*filepath", func(c *gin.Context) {
@@ -30,15 +34,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
ssrFiles.ServeHTTP(c.Writer, c.Request)
})
}
- if cfg.DevMode {
- fmt.Fprintf(os.Stderr, "[dev] SSR 请访问 http://localhost:%d (对照 SPA 请 checkout main)\n", cfg.Port)
- }
+
+ r.Use(install.Guard(cfg.DataDir))
filter := services.NewSensitiveFilter()
- _ = services.WriteDefaultFilterWords(cfg.FilterWordsPath())
- filter.LoadFromFile(cfg.FilterWordsPath())
-
settingsSvc := services.NewForumSettingsService()
+ services.EnsureFilterWordsInSettings(settingsSvc, cfg.FilterWordsPath(), filter)
+
authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := services.NewUserService(filter, settingsSvc)
boardSvc := services.NewBoardService()
@@ -58,16 +60,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
if err != nil {
return nil, err
}
+ // Gitea 仓库同步后置:本阶段不启动后台同步,亦不挂管理入口
giteaSvc := services.NewGiteaService(settingsSvc)
- giteaSvc.StartBackgroundSync()
uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
- // 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
_ = uploadStore.Apply(services.StorageConfig{Type: "local"})
}
- // 后台同步存量文件到媒体索引,避免列表依赖实时扫盘
go func() {
if n, err := uploadStore.SyncMediaIndex(); err != nil {
fmt.Fprintf(os.Stderr, "警告: 媒体索引同步失败: %v\n", err)
@@ -89,25 +89,26 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}
authMW := auth.NewAuthMiddleware(authSvc)
- // Gitea 式 SSR 公开页(优先于 SPA)
+ install.Register(r, install.Deps{
+ DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
+ Auth: authSvc, Settings: settingsSvc,
+ })
+
webpages.Register(r, webpages.Deps{
- Settings: settingsSvc,
- Board: boardSvc,
- Post: postSvc,
+ DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
+ Settings: settingsSvc, Auth: authSvc,
+ Board: boardSvc, Post: postSvc, Comment: commentSvc,
+ Message: messageSvc, Filter: filter,
+ Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
}, authMW)
- // 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
-
- // 健康检查(容器 / 负载均衡探活)
r.GET("/health", h.APIHealth)
-
- // SEO:抓取规则与站点地图
r.GET("/robots.txt", h.RobotsTxt)
r.GET("/sitemap.xml", h.SitemapXML)
- // OIDC Provider(Gitea 等外部站点 SSO)
+ // OIDC Provider(外部机器 / Gitea SSO)
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
r.GET("/oauth/jwks", h.OIDCJWKS)
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
@@ -117,179 +118,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.GET("/oauth/logout", h.OIDCLogout)
r.POST("/oauth/logout", h.OIDCLogout)
- // 公开 JSON API(可选登录)
- pubAPI := r.Group("/api", authMW.OptionalAuth())
- {
- pubAPI.GET("/me", h.APIMe)
- pubAPI.GET("/boards", h.APIBoards)
- pubAPI.GET("/stats", h.APIStats)
- pubAPI.GET("/forum-limits", h.APIForumLimits)
- pubAPI.GET("/site-branding", h.APISiteBranding)
- pubAPI.GET("/pages", h.APIPages)
- pubAPI.GET("/pages/:slug", h.APIPageDetail)
- pubAPI.GET("/captcha", h.APICaptcha)
- pubAPI.GET("/register/config", h.APIRegisterConfig)
- pubAPI.POST("/register/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
- pubAPI.POST("/password-reset/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
- pubAPI.POST("/password-reset", auth.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
- pubAPI.GET("/posts", h.APIPosts)
- pubAPI.GET("/posts/hot", h.APIHotPosts)
- pubAPI.GET("/tags", h.APITags)
- pubAPI.GET("/comments/recent", h.APIRecentComments)
- // search / recent 须在 :id 之前
- pubAPI.GET("/users/search", h.APISearchUsers)
- pubAPI.GET("/users/recent", h.APIRecentUsers)
- pubAPI.GET("/users/:id", h.APIUserPublic)
- pubAPI.GET("/posts/:id", h.APIPostDetail)
- pubAPI.GET("/posts/:id/comments", h.APIPostComments)
- pubAPI.POST("/posts/:id/comments", auth.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
- pubAPI.GET("/projects", h.APIProjects)
- pubAPI.POST("/register", auth.RateLimitMiddleware(limiter, "register"), h.APIRegister)
- pubAPI.POST("/login", auth.RateLimitMiddleware(limiter, "login"), h.APILogin)
- }
-
- // 需登录 API
- api := r.Group("/api", authMW.RequireAuth())
- {
- api.POST("/logout", h.APILogout)
- api.GET("/favorites", h.APIFavorites)
- api.GET("/profile/stats", h.APIProfileStats)
- api.POST("/profile/nickname", h.APIUpdateProfile)
- api.POST("/profile/signature", h.APIUpdateSignature)
- api.POST("/profile/password", h.APIUpdatePassword)
- api.POST("/profile/avatar", h.APIUploadAvatar)
- api.POST("/uploads/image", h.APIUploadPostImage)
- api.POST("/posts", auth.RateLimitMiddleware(limiter, "post"), h.APICreatePost)
- api.PUT("/posts/:id", h.APIUpdatePost)
- api.DELETE("/posts/:id", h.APIDeletePost)
- api.GET("/posts/:id/revisions", h.APIPostRevisions)
- api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
- api.POST("/posts/:id/like", h.APIToggleLike)
- api.POST("/posts/:id/favorite", h.APIToggleFavorite)
- api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
- api.POST("/posts/:id/poll/vote", h.APIPollVote)
- api.POST("/posts/:id/poll/close", h.APIPollClose)
- api.POST("/posts/:id/bounty/award", h.APIBountyAward)
- api.POST("/posts/:id/bounty/refund", h.APIBountyRefund)
- api.POST("/posts/:id/lottery/draw", h.APILotteryDraw)
- api.POST("/posts/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
- api.GET("/messages/unread-count", h.APIMessageUnreadCount)
- api.GET("/messages/notifications", h.APIMessageNotifications)
- api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
- api.GET("/messages/conversations", h.APIMessageConversations)
- api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
- api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
- api.POST("/messages", auth.RateLimitMiddleware(limiter, "message"), h.APISendMessage)
- api.POST("/messages/read-all", h.APIMarkAllMessagesRead)
- api.POST("/comments/:id/like", h.APIToggleCommentLike)
- api.POST("/comments/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport)
- api.DELETE("/comments/:id", h.APIDeleteComment)
- api.PUT("/comments/:id", h.APIUpdateComment)
- api.GET("/me/points", h.APIMePoints)
- api.GET("/me/check-in", h.APIMeCheckInGet)
- api.POST("/me/check-in", h.APIMeCheckIn)
- api.GET("/me/lottery", h.APIMeLotteryGet)
- api.POST("/me/lottery", h.APIMeLotteryDraw)
- api.POST("/posts/:id/unlock", auth.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
- api.POST("/friend-links/apply", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
- api.POST("/friend-links/logo", auth.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
- api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies)
- api.PUT("/friend-links/applies/:id", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply)
- api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply)
- }
-
- // 管理员 API(React SPA 后台统一使用 JSON)
- adminAPI := r.Group("/api/admin", authMW.RequireAuth(), authMW.RequireAdmin())
- {
- adminAPI.GET("/dashboard", h.APIAdminDashboard)
- adminAPI.GET("/settings", h.APIAdminSettings)
- adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
- adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
- adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
- adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
- adminAPI.PUT("/settings/gitea", h.APIAdminUpdateGiteaSettings)
- adminAPI.POST("/settings/gitea/sync", h.APIAdminSyncGitea)
- adminAPI.PUT("/settings/storage", h.APIAdminUpdateStorageSettings)
- adminAPI.PUT("/settings/branding", h.APIAdminUpdateBranding)
- adminAPI.POST("/settings/branding/upload", h.APIAdminUploadBrandingAsset)
- adminAPI.POST("/settings/branding/clear", h.APIAdminClearBrandingAsset)
- adminAPI.GET("/oauth/clients", h.APIAdminListOAuthClients)
- adminAPI.POST("/oauth/clients", h.APIAdminCreateOAuthClient)
- adminAPI.PUT("/oauth/clients/:id", h.APIAdminUpdateOAuthClient)
- adminAPI.DELETE("/oauth/clients/:id", h.APIAdminDeleteOAuthClient)
- adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords)
- adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
- adminAPI.POST("/boards", h.APIAdminCreateBoard)
- adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
- adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
- adminAPI.GET("/pages", h.APIAdminPages)
- adminAPI.GET("/pages/:id", h.APIAdminGetPage)
- adminAPI.POST("/pages", h.APIAdminCreatePage)
- adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage)
- adminAPI.PUT("/pages/:id/published", h.APIAdminSetPagePublished)
- adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage)
- adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies)
- adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings)
- adminAPI.POST("/friend-link-applies/:id/approve", h.APIAdminApproveFriendLinkApply)
- adminAPI.POST("/friend-link-applies/:id/reject", h.APIAdminRejectFriendLinkApply)
- adminAPI.POST("/friend-link-applies/:id/recheck", h.APIAdminRecheckFriendLinkApply)
- adminAPI.GET("/posts", h.APIAdminPosts)
- adminAPI.GET("/posts/trash", h.APIAdminTrashPosts)
- adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
- adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
- adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
- adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
- adminAPI.POST("/posts/:id/comments-lock", h.APIAdminCommentsLockPost)
- adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
- adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost)
- adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost)
- adminAPI.DELETE("/posts/:id/purge", h.APIAdminPurgePost)
- adminAPI.DELETE("/posts/:id", h.APIAdminDeletePost)
- adminAPI.GET("/reports", h.APIAdminReports)
- adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport)
- adminAPI.GET("/comments", h.APIAdminComments)
- adminAPI.GET("/comments/trash", h.APIAdminTrashComments)
- adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions)
- adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment)
- adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment)
- adminAPI.POST("/comments/:id/restore", h.APIAdminRestoreComment)
- adminAPI.DELETE("/comments/:id/purge", h.APIAdminPurgeComment)
- adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
- adminAPI.GET("/users", h.APIAdminUsers)
- adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
- adminAPI.POST("/users/:id/verify", h.APIAdminVerifyUser)
- adminAPI.POST("/users/:id/level", h.APIAdminSetUserLevel)
- adminAPI.POST("/users/:id/points", h.APIAdminAdjustPoints)
- adminAPI.POST("/users/:id/badges", h.APIAdminAwardBadge)
- adminAPI.GET("/badges", h.APIAdminListBadges)
- adminAPI.POST("/badges", h.APIAdminUpsertBadge)
- adminAPI.GET("/media", h.APIAdminMedia)
- adminAPI.POST("/media/delete", h.APIAdminDeleteMedia)
- adminAPI.POST("/backup", h.APIAdminBackup)
- adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
- }
-
- // 管理后台 HTML:SSR 尚未迁移;勿用 /*filepath(与 /admin/login 冲突)
- adminPendingHTML := `管理后台管理后台 SSR 迁移中
API 仍可用;UI 请暂时对照 main 分支 SPA,或等待后续模板页。
返回首页
`
- adminPending := func(c *gin.Context) {
- c.Header("Content-Type", "text/html; charset=utf-8")
- c.String(http.StatusOK, adminPendingHTML)
- }
- admin := r.Group("/admin")
- {
- admin.GET("/login", func(c *gin.Context) {
- c.Redirect(http.StatusFound, "/login")
- })
- adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
- {
- adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
- adminAuth.GET("/dashboard", adminPending)
- adminAuth.GET("/:page", adminPending)
- }
- }
-
- // 未迁移公开路径:爬虫可读 HTML / 用户占位(首页与板块已由 routers/web 接管)
- r.NoRoute(h.ServePublicSPA)
+ // 精简机器 API:健康检查已注册;保留只读探测与 OIDC,论坛 UI 不再走 /api
+ r.NoRoute(webpages.Deps{
+ DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
+ Settings: settingsSvc, Auth: authSvc,
+ Board: boardSvc, Post: postSvc, Comment: commentSvc,
+ }.NotFound)
return r, nil
}
diff --git a/routers/web/admin.go b/routers/web/admin.go
new file mode 100644
index 0000000..5ea7b95
--- /dev/null
+++ b/routers/web/admin.go
@@ -0,0 +1,500 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/models"
+ "git.iioio.com/freefire/jiang13-forum/modules/webctx"
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+// AdminChrome 后台布局公共字段
+type AdminChrome struct {
+ PageChrome
+ NavActive string
+}
+
+func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome {
+ site := d.Settings.SiteBranding().Name
+ if title == "" {
+ title = "管理后台 · " + site
+ } else {
+ title = title + " · " + site
+ }
+ base := d.chrome(ctx, title, "", "")
+ return AdminChrome{PageChrome: base, NavActive: nav}
+}
+
+type adminDashData struct {
+ AdminChrome
+ UserCount int64
+ PostCount int64
+ PendingPosts int64
+ PendingComments int64
+ BoardCount int64
+}
+
+// AdminDashboard 概览
+func (d Deps) AdminDashboard(c *gin.Context) {
+ ctx := d.ctx(c)
+ var users, posts, boards int64
+ _ = models.DB.Model(&models.User{}).Count(&users).Error
+ _ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error
+ _ = models.DB.Model(&models.Board{}).Count(&boards).Error
+ pendingPosts, _ := d.Post.PendingPostCount()
+ pendingComments, _ := d.Comment.PendingCommentCount()
+ ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{
+ AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"),
+ UserCount: users,
+ PostCount: posts,
+ PendingPosts: pendingPosts,
+ PendingComments: pendingComments,
+ BoardCount: boards,
+ })
+}
+
+type adminBoardRow struct {
+ ID uint
+ Name string
+ Description string
+ Icon string
+ ColorIndex int
+ SortOrder int
+ PostCount int
+}
+
+type adminBoardsData struct {
+ AdminChrome
+ Boards []adminBoardRow
+ Form adminBoardForm
+}
+
+type adminBoardForm struct {
+ ID uint
+ Name string
+ Description string
+ Icon string
+ ColorIndex int
+ SortOrder int
+}
+
+// AdminBoardsGet 板块列表
+func (d Deps) AdminBoardsGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ d.renderAdminBoards(ctx, "", adminBoardForm{})
+}
+
+func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) {
+ list, _ := d.Board.ListWithStats()
+ rows := make([]adminBoardRow, 0, len(list))
+ for _, b := range list {
+ rows = append(rows, adminBoardRow{
+ ID: b.ID, Name: b.Name, Description: b.Description,
+ Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder,
+ PostCount: b.PostCount,
+ })
+ }
+ data := adminBoardsData{
+ AdminChrome: d.adminChrome(ctx, "板块", "boards"),
+ Boards: rows,
+ Form: form,
+ }
+ data.Error = errMsg
+ ctx.HTML(http.StatusOK, "admin/boards", data)
+}
+
+// AdminBoardCreate 新建板块
+func (d Deps) AdminBoardCreate(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
+ return
+ }
+ form := adminBoardFormFrom(c)
+ if strings.TrimSpace(form.Name) == "" {
+ d.renderAdminBoards(ctx, "请填写板块名称", form)
+ return
+ }
+ if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
+ d.renderAdminBoards(ctx, err.Error(), form)
+ return
+ }
+ ctx.SetFlash("板块已创建")
+ ctx.Redirect("/admin/boards")
+}
+
+// AdminBoardUpdate 更新板块
+func (d Deps) AdminBoardUpdate(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ form := adminBoardFormFrom(c)
+ form.ID = uint(id)
+ if strings.TrimSpace(form.Name) == "" {
+ d.renderAdminBoards(ctx, "请填写板块名称", form)
+ return
+ }
+ if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
+ d.renderAdminBoards(ctx, err.Error(), form)
+ return
+ }
+ ctx.SetFlash("板块已更新")
+ ctx.Redirect("/admin/boards")
+}
+
+// AdminBoardDelete 删除板块
+func (d Deps) AdminBoardDelete(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect("/admin/boards")
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err := d.Board.Delete(uint(id)); err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect("/admin/boards")
+ return
+ }
+ ctx.SetFlash("板块已删除")
+ ctx.Redirect("/admin/boards")
+}
+
+func adminBoardFormFrom(c *gin.Context) adminBoardForm {
+ color, _ := strconv.Atoi(c.PostForm("color_index"))
+ sort, _ := strconv.Atoi(c.PostForm("sort_order"))
+ return adminBoardForm{
+ Name: strings.TrimSpace(c.PostForm("name")),
+ Description: strings.TrimSpace(c.PostForm("description")),
+ Icon: strings.TrimSpace(c.PostForm("icon")),
+ ColorIndex: color,
+ SortOrder: sort,
+ }
+}
+
+type adminModPostRow struct {
+ ID uint
+ Title string
+ AuthorName string
+ BoardName string
+ CreatedAt string
+}
+
+type adminModCommentRow struct {
+ ID uint
+ PostID uint
+ PostTitle string
+ Floor int
+ AuthorName string
+ Excerpt string
+ CreatedAt string
+}
+
+type adminModData struct {
+ AdminChrome
+ Posts []adminModPostRow
+ Comments []adminModCommentRow
+}
+
+// AdminModerationGet 待审帖/评
+func (d Deps) AdminModerationGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ posts, _, _ := d.Post.List(services.PostListQuery{
+ Page: 1, Size: 50,
+ ViewerIsAdmin: true,
+ Status: models.ContentStatusPending,
+ Sort: "latest",
+ })
+ postRows := make([]adminModPostRow, 0, len(posts))
+ for _, p := range posts {
+ author := ""
+ if p.User.ID > 0 {
+ author = p.User.Nickname
+ if author == "" {
+ author = p.User.Username
+ }
+ }
+ board := ""
+ if p.Board.ID > 0 {
+ board = p.Board.Name
+ }
+ postRows = append(postRows, adminModPostRow{
+ ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board,
+ CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"),
+ })
+ }
+ comments, _, _ := d.Comment.ListPending(1, 50)
+ commentRows := make([]adminModCommentRow, 0, len(comments))
+ for _, cm := range comments {
+ author := "游客"
+ if cm.UserID > 0 {
+ author = cm.User.Nickname
+ if author == "" {
+ author = cm.User.Username
+ }
+ } else if cm.GuestNick != "" {
+ author = cm.GuestNick
+ }
+ excerpt := strings.TrimSpace(stripTagsRough(cm.Content))
+ runes := []rune(excerpt)
+ if len(runes) > 80 {
+ excerpt = string(runes[:80]) + "…"
+ }
+ title := ""
+ if cm.Post.ID > 0 {
+ title = cm.Post.Title
+ }
+ commentRows = append(commentRows, adminModCommentRow{
+ ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor,
+ AuthorName: author, Excerpt: excerpt,
+ CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"),
+ })
+ }
+ ctx.HTML(http.StatusOK, "admin/moderation", adminModData{
+ AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"),
+ Posts: postRows,
+ Comments: commentRows,
+ })
+}
+
+func stripTagsRough(s string) string {
+ var b strings.Builder
+ inTag := false
+ for _, r := range s {
+ switch {
+ case r == '<':
+ inTag = true
+ case r == '>':
+ inTag = false
+ case !inTag:
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// AdminPostApprove 通过帖子
+func (d Deps) AdminPostApprove(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
+ ctx.SetFlash(err.Error())
+ } else {
+ ctx.SetFlash("帖子已通过")
+ }
+ ctx.Redirect("/admin/moderation")
+}
+
+// AdminPostReject 拒绝帖子
+func (d Deps) AdminPostReject(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ reason := strings.TrimSpace(c.PostForm("reason"))
+ if reason == "" {
+ ctx.SetFlash("请填写拒绝原因")
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ post, err := d.Post.FindByID(uint(id))
+ if err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ pid := post.ID
+ if d.Message != nil {
+ _, _ = d.Message.SendSystem(
+ post.UserID,
+ "帖子《"+post.Title+"》未通过审核",
+ services.FormatRejectContent(post.Title, post.ID, reason),
+ models.MessageKindReject,
+ &pid,
+ nil,
+ )
+ }
+ ctx.SetFlash("已拒绝该帖")
+ ctx.Redirect("/admin/moderation")
+}
+
+// AdminCommentApprove 通过评论
+func (d Deps) AdminCommentApprove(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
+ ctx.SetFlash(err.Error())
+ } else {
+ ctx.SetFlash("评论已通过")
+ }
+ ctx.Redirect("/admin/moderation")
+}
+
+// AdminCommentReject 拒绝评论
+func (d Deps) AdminCommentReject(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ reason := strings.TrimSpace(c.PostForm("reason"))
+ if reason == "" {
+ ctx.SetFlash("请填写拒绝原因")
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ cm, err := d.Comment.GetByID(uint(id))
+ if err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect("/admin/moderation")
+ return
+ }
+ if d.Message != nil && cm.UserID > 0 {
+ pid := cm.PostID
+ title := cm.Post.Title
+ _, _ = d.Message.SendSystem(
+ cm.UserID,
+ fmt.Sprintf("评论未通过审核(帖 #%d)", cm.PostID),
+ services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason),
+ models.MessageKindReject,
+ &pid,
+ nil,
+ )
+ }
+ ctx.SetFlash("已拒绝该评论")
+ ctx.Redirect("/admin/moderation")
+}
+
+type adminSettingsData struct {
+ AdminChrome
+ Brand services.SiteBranding
+ RatePost int
+ RateComment int
+ RateReg int
+ RateLogin int
+ RateWindow int
+ FilterWords string
+ FilterCount int
+}
+
+// AdminSettingsGet 设置页
+func (d Deps) AdminSettingsGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ d.renderAdminSettings(ctx, "")
+}
+
+func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
+ words := d.Settings.FilterWordsContent()
+ lim := d.Settings.Limits()
+ data := adminSettingsData{
+ AdminChrome: d.adminChrome(ctx, "站点设置", "settings"),
+ Brand: d.Settings.SiteBranding(),
+ RatePost: lim.RateLimitPost,
+ RateComment: lim.RateLimitComment,
+ RateReg: lim.RateLimitRegister,
+ RateLogin: lim.RateLimitLogin,
+ RateWindow: lim.RateLimitWindowSec,
+ FilterWords: words,
+ FilterCount: services.CountFilterWords(words),
+ }
+ data.Error = errMsg
+ ctx.HTML(http.StatusOK, "admin/settings", data)
+}
+
+// AdminSettingsBrandPost 品牌
+func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ d.renderAdminSettings(ctx, "无效请求,请重试")
+ return
+ }
+ cur := d.Settings.SiteBranding()
+ in := services.SiteBranding{
+ Name: strings.TrimSpace(c.PostForm("name")),
+ Slogan: strings.TrimSpace(c.PostForm("slogan")),
+ Description: strings.TrimSpace(c.PostForm("description")),
+ Keywords: strings.TrimSpace(c.PostForm("keywords")),
+ LogoMark: strings.TrimSpace(c.PostForm("logo_mark")),
+ Logo: cur.Logo,
+ Favicon: cur.Favicon,
+ OGImage: cur.OGImage,
+ ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")),
+ ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")),
+ FriendLinks: cur.FriendLinks,
+ }
+ if err := d.Settings.UpdateSiteBranding(in); err != nil {
+ d.renderAdminSettings(ctx, err.Error())
+ return
+ }
+ ctx.SetFlash("品牌设置已保存")
+ ctx.Redirect("/admin/settings")
+}
+
+// AdminSettingsLimitsPost 限流
+func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ d.renderAdminSettings(ctx, "无效请求,请重试")
+ return
+ }
+ postN, _ := strconv.Atoi(c.PostForm("rate_limit_post"))
+ commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment"))
+ regN, _ := strconv.Atoi(c.PostForm("rate_limit_register"))
+ loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login"))
+ windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec"))
+ if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil {
+ d.renderAdminSettings(ctx, err.Error())
+ return
+ }
+ ctx.SetFlash("限流设置已保存")
+ ctx.Redirect("/admin/settings")
+}
+
+// AdminSettingsFilterWordsPost 敏感词
+func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ d.renderAdminSettings(ctx, "无效请求,请重试")
+ return
+ }
+ content := c.PostForm("filter_words")
+ if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil {
+ d.renderAdminSettings(ctx, err.Error())
+ return
+ }
+ ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05")))
+ ctx.Redirect("/admin/settings")
+}
diff --git a/routers/web/auth.go b/routers/web/auth.go
new file mode 100644
index 0000000..08a9952
--- /dev/null
+++ b/routers/web/auth.go
@@ -0,0 +1,208 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/modules/webctx"
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+type loginData struct {
+ PageChrome
+ Username string
+ Redirect string
+}
+
+type registerData struct {
+ PageChrome
+ Username string
+ Nickname string
+ Email string
+ MailReady bool
+ RequireEmailCode bool
+}
+
+type registerForm struct {
+ Username string
+ Nickname string
+ Email string
+}
+
+// LoginGet 登录页
+func (d Deps) LoginGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ if ctx.IsSigned() {
+ ctx.Redirect("/")
+ return
+ }
+ chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
+ if c.Query("banned") == "1" {
+ chrome.Error = "账号已被禁言"
+ }
+ ctx.HTML(http.StatusOK, "auth/login", loginData{
+ PageChrome: chrome,
+ Redirect: c.Query("redirect"),
+ })
+}
+
+// LoginPost 登录提交
+func (d Deps) LoginPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ d.renderLogin(ctx, "无效请求,请重试", c.PostForm("username"), c.PostForm("redirect"))
+ return
+ }
+ if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
+ d.renderLogin(ctx, "操作过于频繁,请稍后再试", c.PostForm("username"), c.PostForm("redirect"))
+ return
+ }
+ user := strings.TrimSpace(c.PostForm("username"))
+ pass := c.PostForm("password")
+ redir := strings.TrimSpace(c.PostForm("redirect"))
+ if redir == "" || !strings.HasPrefix(redir, "/") || strings.HasPrefix(redir, "//") {
+ redir = "/"
+ }
+ token, _, err := d.Auth.Login(user, pass, c.ClientIP(), c.Request.UserAgent())
+ if err != nil {
+ msg := "用户名或密码错误"
+ if err == services.ErrUserBanned {
+ msg = "账号已被禁言"
+ }
+ d.renderLogin(ctx, msg, user, redir)
+ return
+ }
+ ctx.SetLoginCookie(token)
+ ctx.Redirect(redir)
+}
+
+func (d Deps) renderLogin(ctx *webctx.Context, errMsg, username, redir string) {
+ chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
+ chrome.Error = errMsg
+ ctx.HTML(http.StatusOK, "auth/login", loginData{
+ PageChrome: chrome,
+ Username: username,
+ Redirect: redir,
+ })
+}
+
+// LogoutPost 退出
+func (d Deps) LogoutPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if ctx.CheckCSRF() {
+ ctx.ClearLoginCookie()
+ }
+ ctx.Redirect("/")
+}
+
+// RegisterGet 注册页
+func (d Deps) RegisterGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ if ctx.IsSigned() {
+ ctx.Redirect("/")
+ return
+ }
+ d.renderRegister(ctx, "", registerForm{})
+}
+
+// RegisterSendCode POST 发送注册邮箱验证码
+func (d Deps) RegisterSendCode(c *gin.Context) {
+ ctx := d.ctx(c)
+ if ctx.IsSigned() {
+ ctx.Redirect("/")
+ return
+ }
+ form := registerFormFrom(c)
+ if !ctx.CheckCSRF() {
+ d.renderRegister(ctx, "无效请求,请重试", form)
+ return
+ }
+ if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
+ d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
+ return
+ }
+ if d.EmailCode == nil || !d.Settings.MailReady() {
+ d.renderRegister(ctx, "邮件服务未配置,无需验证码即可注册", form)
+ return
+ }
+ if err := d.EmailCode.SendRegisterCode(form.Email); err != nil {
+ d.renderRegister(ctx, err.Error(), form)
+ return
+ }
+ chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
+ chrome.Flash = "验证码已发送,请查收邮箱"
+ d.renderRegisterWithChrome(ctx, chrome, "", form)
+}
+
+// RegisterPost 注册提交
+func (d Deps) RegisterPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if ctx.IsSigned() {
+ ctx.Redirect("/")
+ return
+ }
+ form := registerFormFrom(c)
+ if !ctx.CheckCSRF() {
+ d.renderRegister(ctx, "无效请求,请重试", form)
+ return
+ }
+ if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
+ d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
+ return
+ }
+ mailReady := d.Settings.MailReady()
+ if mailReady {
+ code := strings.TrimSpace(c.PostForm("email_code"))
+ if d.EmailCode == nil || !d.EmailCode.Verify(form.Email, code) {
+ d.renderRegister(ctx, services.ErrEmailCodeInvalid.Error(), form)
+ return
+ }
+ }
+ pass := c.PostForm("password")
+ pass2 := c.PostForm("password2")
+ if pass != pass2 {
+ d.renderRegister(ctx, "两次密码不一致", form)
+ return
+ }
+ user, err := d.Auth.Register(form.Username, pass, form.Nickname, form.Email)
+ if err != nil {
+ d.renderRegister(ctx, err.Error(), form)
+ return
+ }
+ sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
+ if err != nil {
+ ctx.SetFlash("注册成功,请登录")
+ ctx.Redirect("/login")
+ return
+ }
+ ctx.SetLoginCookie(sid)
+ ctx.SetFlash("注册成功,欢迎加入")
+ ctx.Redirect("/")
+}
+
+func registerFormFrom(c *gin.Context) registerForm {
+ return registerForm{
+ Username: strings.TrimSpace(c.PostForm("username")),
+ Nickname: strings.TrimSpace(c.PostForm("nickname")),
+ Email: strings.TrimSpace(c.PostForm("email")),
+ }
+}
+
+func (d Deps) renderRegister(ctx *webctx.Context, errMsg string, form registerForm) {
+ chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
+ d.renderRegisterWithChrome(ctx, chrome, errMsg, form)
+}
+
+func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) {
+ chrome.Error = errMsg
+ mailReady := d.Settings.MailReady()
+ ctx.HTML(http.StatusOK, "auth/register", registerData{
+ PageChrome: chrome,
+ Username: form.Username,
+ Nickname: form.Nickname,
+ Email: form.Email,
+ MailReady: mailReady,
+ RequireEmailCode: mailReady,
+ })
+}
diff --git a/routers/web/chrome.go b/routers/web/chrome.go
new file mode 100644
index 0000000..68fb141
--- /dev/null
+++ b/routers/web/chrome.go
@@ -0,0 +1,110 @@
+package web
+
+import (
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/modules/webctx"
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+// Deps 页面依赖
+type Deps struct {
+ DataDir string
+ JWTSecret string
+ Settings *services.ForumSettingsService
+ Auth *services.AuthService
+ Board *services.BoardService
+ Post *services.PostService
+ Comment *services.CommentService
+ Message *services.MessageService
+ Filter *services.SensitiveFilter
+ Limiter *services.RateLimiter
+ EmailCode *services.EmailCodeService
+ Store *services.UploadStore
+}
+
+// BoardView 侧栏
+type BoardView struct {
+ ID uint
+ Name string
+}
+
+// PageChrome 布局公共字段
+type PageChrome struct {
+ Title string
+ Description string
+ SiteName string
+ Slogan string
+ LogoMark string
+ LoggedIn bool
+ IsAdmin bool
+ ViewerName string
+ Boards []BoardView
+ ActiveBoard uint
+ Inner string // 保留字段;入口模板已固定组合,不再动态 template
+ CSRF string
+ Flash string
+ Error string
+}
+
+func (d Deps) ctx(c *gin.Context) *webctx.Context {
+ return webctx.New(c, d.JWTSecret)
+}
+
+func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome {
+ brand := d.Settings.SiteBranding()
+ if title == "" {
+ title = brand.DocumentTitle()
+ }
+ if desc == "" {
+ desc = brand.MetaDescription()
+ }
+ name := "我的"
+ if ctx.IsSigned() {
+ name = strings.TrimSpace(ctx.Doer.Nickname)
+ if name == "" {
+ name = ctx.Doer.Username
+ }
+ }
+ boards, _ := d.Board.List()
+ bv := make([]BoardView, 0, len(boards))
+ for _, b := range boards {
+ bv = append(bv, BoardView{ID: b.ID, Name: b.Name})
+ }
+ return PageChrome{
+ Title: title,
+ Description: desc,
+ SiteName: brand.Name,
+ Slogan: brand.Slogan,
+ LogoMark: firstRuneOr(brand.LogoMark, "姜"),
+ LoggedIn: ctx.IsSigned(),
+ IsAdmin: ctx.IsAdmin(),
+ ViewerName: name,
+ Boards: bv,
+ Inner: inner,
+ CSRF: ctx.EnsureCSRF(),
+ Flash: ctx.TakeFlash(),
+ }
+}
+
+func firstRuneOr(s, fallback string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return fallback
+ }
+ for _, r := range s {
+ return string(r)
+ }
+ return fallback
+}
+
+func stripIDParam(raw, permalinkExt string) string {
+ raw = strings.TrimSpace(raw)
+ if permalinkExt != "" {
+ raw = strings.TrimSuffix(raw, "."+permalinkExt)
+ }
+ raw = strings.TrimSuffix(raw, ".html")
+ raw = strings.TrimSuffix(raw, ".htm")
+ return raw
+}
diff --git a/routers/web/compose.go b/routers/web/compose.go
new file mode 100644
index 0000000..d32ff97
--- /dev/null
+++ b/routers/web/compose.go
@@ -0,0 +1,224 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/models"
+ "git.iioio.com/freefire/jiang13-forum/modules/webctx"
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+type composeData struct {
+ PageChrome
+ IsEdit bool
+ PostID uint
+ FormAction string
+ BoardID uint
+ Title string
+ Tags string
+ Content string
+ Boards []BoardView
+ TitleMax int
+ TagsMax int
+ ContentMax int
+}
+
+// ComposeGet 发帖页
+func (d Deps) ComposeGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ if err := d.ensureCanWrite(ctx); err != "" {
+ ctx.SetFlash(err)
+ ctx.Redirect("/")
+ return
+ }
+ boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
+ d.renderCompose(ctx, "", composeForm{
+ BoardID: uint(boardID),
+ }, false, 0)
+}
+
+// ComposePost 发帖提交
+func (d Deps) ComposePost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), false, 0)
+ return
+ }
+ if msg := d.ensureCanWrite(ctx); msg != "" {
+ ctx.SetFlash(msg)
+ ctx.Redirect("/")
+ return
+ }
+ if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
+ d.renderCompose(ctx, "发帖过于频繁,请稍后再试", composeFormFrom(c), false, 0)
+ return
+ }
+ form := composeFormFrom(c)
+ htmlBody := services.ComposeBodyToHTML(form.Content)
+ post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, models.PostTypeNormal, ctx.SkipsModeration())
+ if err != nil {
+ d.renderCompose(ctx, err.Error(), form, false, 0)
+ return
+ }
+ if post.Status == models.ContentStatusPending {
+ ctx.SetFlash("帖子已提交,等待审核")
+ } else {
+ ctx.SetFlash("发帖成功")
+ }
+ ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
+}
+
+// PostEditGet 编辑帖
+func (d Deps) PostEditGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
+ if errMsg != "" {
+ ctx.SetFlash(errMsg)
+ ctx.Redirect("/")
+ return
+ }
+ d.renderCompose(ctx, "", composeForm{
+ BoardID: post.BoardID,
+ Title: post.Title,
+ Tags: post.Tags,
+ Content: services.HTMLToComposePlain(post.Content),
+ }, true, post.ID)
+}
+
+// PostEditPost 编辑提交
+func (d Deps) PostEditPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
+ if errMsg != "" {
+ ctx.SetFlash(errMsg)
+ ctx.Redirect("/")
+ return
+ }
+ if !ctx.CheckCSRF() {
+ d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), true, post.ID)
+ return
+ }
+ if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
+ d.renderCompose(ctx, "操作过于频繁,请稍后再试", composeFormFrom(c), true, post.ID)
+ return
+ }
+ form := composeFormFrom(c)
+ htmlBody := services.ComposeBodyToHTML(form.Content)
+ if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, models.PostTypeNormal, form.BoardID); err != nil {
+ d.renderCompose(ctx, err.Error(), form, true, post.ID)
+ return
+ }
+ ctx.SetFlash("已保存")
+ ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
+}
+
+// ComposeUpload 帖图上传(JSON,供 compose 页 fetch)
+func (d Deps) ComposeUpload(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.IsSigned() {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ if !ctx.CheckCSRF() {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
+ return
+ }
+ if ctx.Doer != nil && ctx.Doer.Banned {
+ c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
+ return
+ }
+ if d.Store == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"})
+ return
+ }
+ file, err := c.FormFile("image")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
+ return
+ }
+ url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategoryPosts, fmt.Sprintf("%d", ctx.UserID()))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"url": url})
+}
+
+type composeForm struct {
+ BoardID uint
+ Title string
+ Tags string
+ Content string
+}
+
+func composeFormFrom(c *gin.Context) composeForm {
+ bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
+ return composeForm{
+ BoardID: uint(bid),
+ Title: strings.TrimSpace(c.PostForm("title")),
+ Tags: strings.TrimSpace(c.PostForm("tags")),
+ Content: c.PostForm("content"),
+ }
+}
+
+func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm, isEdit bool, postID uint) {
+ title := "发帖"
+ action := "/compose"
+ if isEdit {
+ title = "编辑帖子"
+ action = fmt.Sprintf("/post/%d/edit", postID)
+ }
+ chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
+ chrome.Error = errMsg
+ chrome.ActiveBoard = form.BoardID
+ ctx.HTML(http.StatusOK, "compose", composeData{
+ PageChrome: chrome,
+ IsEdit: isEdit,
+ PostID: postID,
+ FormAction: action,
+ BoardID: form.BoardID,
+ Title: form.Title,
+ Tags: form.Tags,
+ Content: form.Content,
+ Boards: chrome.Boards,
+ TitleMax: d.Settings.PostTitleMax(),
+ TagsMax: d.Settings.PostTagsMax(),
+ ContentMax: d.Settings.PostContentMax(),
+ })
+}
+
+func (d Deps) ensureCanWrite(ctx *webctx.Context) string {
+ if !ctx.IsSigned() {
+ return "请先登录"
+ }
+ if ctx.Doer != nil && ctx.Doer.Banned {
+ return "账号已被禁言,无法发帖"
+ }
+ return ""
+}
+
+func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Post, string) {
+ if msg := d.ensureCanWrite(ctx); msg != "" {
+ return nil, msg
+ }
+ idStr := stripIDParam(idParam, d.Settings.Permalink().Ext)
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil || id == 0 {
+ return nil, "帖子不存在"
+ }
+ post, err := d.Post.FindByID(uint(id))
+ if err != nil {
+ return nil, "帖子不存在"
+ }
+ if !ctx.IsAdmin() && post.UserID != ctx.UserID() {
+ return nil, "无权编辑此帖"
+ }
+ if reason := d.Post.EditBlockReason(post, ctx.IsAdmin()); reason != "" {
+ return nil, reason
+ }
+ return post, ""
+}
diff --git a/routers/web/home.go b/routers/web/home.go
index afe8f37..547e419 100644
--- a/routers/web/home.go
+++ b/routers/web/home.go
@@ -7,27 +7,74 @@ import (
"time"
"git.iioio.com/freefire/jiang13-forum/modules/auth"
- "git.iioio.com/freefire/jiang13-forum/models"
- "git.iioio.com/freefire/jiang13-forum/modules/webrender"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
-// Deps 页面路由依赖(复用现有 service,避免 Phase 1 大搬家)
-type Deps struct {
- Settings *services.ForumSettingsService
- Board *services.BoardService
- Post *services.PostService
+// Register 注册已安装后的 web 路由
+func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
+ g := r.Group("/", authMW.OptionalAuth())
+ g.GET("/", deps.Home)
+ g.GET("/board/:id", deps.Home)
+ g.GET("/post/:id", deps.PostView)
+ g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
+ g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost)
+ g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
+ g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
+ g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
+ g.GET("/login", deps.LoginGet)
+ g.POST("/login", deps.LoginPost)
+ g.POST("/logout", deps.LogoutPost)
+ g.GET("/register", deps.RegisterGet)
+ g.POST("/register", deps.RegisterPost)
+ g.POST("/register/send-code", deps.RegisterSendCode)
+ g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet)
+ g.POST("/compose", authMW.RequireAuth(), deps.ComposePost)
+ g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload)
+ g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") })
+
+ admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin())
+ {
+ admin.GET("", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
+ admin.GET("/dashboard", deps.AdminDashboard)
+ admin.GET("/boards", deps.AdminBoardsGet)
+ admin.POST("/boards", deps.AdminBoardCreate)
+ admin.POST("/boards/:id", deps.AdminBoardUpdate)
+ admin.POST("/boards/:id/delete", deps.AdminBoardDelete)
+ admin.GET("/moderation", deps.AdminModerationGet)
+ admin.POST("/posts/:id/approve", deps.AdminPostApprove)
+ admin.POST("/posts/:id/reject", deps.AdminPostReject)
+ admin.POST("/comments/:id/approve", deps.AdminCommentApprove)
+ admin.POST("/comments/:id/reject", deps.AdminCommentReject)
+ admin.GET("/settings", deps.AdminSettingsGet)
+ admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
+ admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
+ admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
+ }
+
+ g.GET("/profile", deps.PendingPage)
+ g.GET("/messages", deps.PendingPage)
+ g.GET("/favorites", deps.PendingPage)
+ g.GET("/projects", deps.PendingPage)
+ g.GET("/links", deps.PendingPage)
+ g.GET("/boards", deps.PendingPage)
}
-// BoardView 侧栏板块
-type BoardView struct {
- ID uint
- Name string
+// HomePageData Feed
+type HomePageData struct {
+ PageChrome
+ BoardName string
+ Sort string
+ Posts []PostListItem
+ Page int
+ PrevPage int
+ NextPage int
+ HasPrev bool
+ HasMore bool
}
-// PostView 列表项
-type PostView struct {
+// PostListItem 列表项
+type PostListItem struct {
ID uint
Title string
AuthorName string
@@ -38,39 +85,10 @@ type PostView struct {
CreatedLabel string
}
-// HomePageData 首页 / 板块 Feed
-type HomePageData struct {
- Title string
- Description string
- SiteName string
- Slogan string
- LogoMark string
- LoggedIn bool
- IsAdmin bool
- ViewerName string
- Boards []BoardView
- ActiveBoard uint
- BoardName string
- Sort string
- Posts []PostView
- Page int
- PrevPage int
- NextPage int
- HasPrev bool
- HasMore bool
-}
-
-// Register 注册已迁移的 SSR 页面(优先于 SPA)
-func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
- g := r.Group("/", authMW.OptionalAuth())
- g.GET("/", deps.Home)
- g.GET("/board/:id", deps.Home)
-}
-
-// Home SSR 首页与板块列表
+// Home 首页 / 板块
func (d Deps) Home(c *gin.Context) {
- brand := d.Settings.SiteBranding()
- sort := c.DefaultQuery("sort", "latest")
+ ctx := d.ctx(c)
+ sort := normalizeSort(c.DefaultQuery("sort", "latest"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
@@ -80,54 +98,38 @@ func (d Deps) Home(c *gin.Context) {
var boardID uint
var boardName string
if idStr := c.Param("id"); idStr != "" {
- // 兼容伪静态后缀 123.html
- idStr = strings.TrimSuffix(idStr, "."+d.Settings.Permalink().Ext)
- idStr = strings.TrimSuffix(idStr, ".html")
- idStr = strings.TrimSuffix(idStr, ".htm")
+ idStr = stripIDParam(idStr, d.Settings.Permalink().Ext)
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
boardID = uint(n)
}
}
- boards, _ := d.Board.List()
- boardViews := make([]BoardView, 0, len(boards))
- for _, b := range boards {
- boardViews = append(boardViews, BoardView{ID: b.ID, Name: b.Name})
+ chrome := d.chrome(ctx, "", "", "home/feed")
+ chrome.ActiveBoard = boardID
+ for _, b := range chrome.Boards {
if b.ID == boardID {
boardName = b.Name
+ break
}
}
-
- var uid uint
- if v, ok := c.Get(auth.CtxUserID); ok {
- uid, _ = v.(uint)
+ if boardName != "" {
+ chrome.Title = boardName + " · " + chrome.SiteName
}
- isAdmin := false
- if v, ok := c.Get(auth.CtxRole); ok {
- switch r := v.(type) {
- case models.Role:
- isAdmin = r == models.RoleAdmin
- case string:
- isAdmin = r == string(models.RoleAdmin)
- }
- }
- username, _ := c.Get(auth.CtxUsername)
- q := services.PostListQuery{
+ items, total, err := d.Post.ListItems(services.PostListQuery{
BoardID: boardID,
Page: page,
Size: size,
Sort: sort,
- ViewerID: uid,
- ViewerIsAdmin: isAdmin,
- }
- items, total, err := d.Post.ListItems(q)
+ ViewerID: ctx.UserID(),
+ ViewerIsAdmin: ctx.IsAdmin(),
+ })
if err != nil {
c.String(http.StatusInternalServerError, "加载帖子失败")
return
}
- posts := make([]PostView, 0, len(items))
+ posts := make([]PostListItem, 0, len(items))
for _, it := range items {
author := strings.TrimSpace(it.User.Nickname)
if author == "" {
@@ -137,49 +139,24 @@ func (d Deps) Home(c *gin.Context) {
if it.Board.ID > 0 {
bname = it.Board.Name
}
- posts = append(posts, PostView{
- ID: it.ID,
- Title: it.Title,
- AuthorName: author,
- BoardName: bname,
- Pinned: it.Pinned,
- Featured: it.Featured,
- CommentCount: it.CommentCount,
- CreatedLabel: formatTime(it.CreatedAt),
+ posts = append(posts, PostListItem{
+ ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname,
+ Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount,
+ CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
})
}
- title := brand.DocumentTitle()
- if boardName != "" {
- title = boardName + " · " + brand.Name
- }
-
- data := HomePageData{
- Title: title,
- Description: brand.MetaDescription(),
- SiteName: brand.Name,
- Slogan: brand.Slogan,
- LogoMark: firstRuneOr(brand.LogoMark, "姜"),
- LoggedIn: uid > 0,
- IsAdmin: isAdmin,
- ViewerName: fmtViewer(username),
- Boards: boardViews,
- ActiveBoard: boardID,
- BoardName: boardName,
- Sort: normalizeSort(sort),
- Posts: posts,
- Page: page,
- PrevPage: page - 1,
- NextPage: page + 1,
- HasPrev: page > 1,
- HasMore: int64(page*size) < total,
- }
-
- c.Header("Content-Type", "text/html; charset=utf-8")
- c.Status(http.StatusOK)
- if err := webrender.Execute(c.Writer, "home", data); err != nil {
- c.String(http.StatusInternalServerError, "模板渲染失败: %v", err)
- }
+ ctx.HTML(http.StatusOK, "home", HomePageData{
+ PageChrome: chrome,
+ BoardName: boardName,
+ Sort: sort,
+ Posts: posts,
+ Page: page,
+ PrevPage: page - 1,
+ NextPage: page + 1,
+ HasPrev: page > 1,
+ HasMore: int64(page*size) < total,
+ })
}
func normalizeSort(s string) string {
@@ -194,26 +171,3 @@ func normalizeSort(s string) string {
func formatTime(t time.Time) string {
return t.Local().Format("2006-01-02 15:04")
}
-
-func firstRuneOr(s, fallback string) string {
- s = strings.TrimSpace(s)
- if s == "" {
- return fallback
- }
- for _, r := range s {
- return string(r)
- }
- return fallback
-}
-
-func fmtViewer(v any) string {
- if v == nil {
- return "我的"
- }
- s, _ := v.(string)
- s = strings.TrimSpace(s)
- if s == "" {
- return "我的"
- }
- return s
-}
diff --git a/routers/web/pending.go b/routers/web/pending.go
new file mode 100644
index 0000000..1bd7353
--- /dev/null
+++ b/routers/web/pending.go
@@ -0,0 +1,35 @@
+package web
+
+import (
+ "net/http"
+
+ "git.iioio.com/freefire/jiang13-forum/modules/webctx"
+ "github.com/gin-gonic/gin"
+)
+
+type pendingData struct {
+ PageChrome
+ Heading string
+ Message string
+}
+
+// PendingPage 未迁移页
+func (d Deps) PendingPage(c *gin.Context) {
+ ctx := d.ctx(c)
+ chrome := d.chrome(ctx, "页面准备中 · "+d.Settings.SiteBranding().Name, "", "")
+ ctx.HTML(http.StatusOK, "status/pending", pendingData{
+ PageChrome: chrome,
+ Heading: "页面准备中",
+ Message: "该功能尚未用模板实现,请稍后再来。",
+ })
+}
+
+func (d Deps) render404(ctx *webctx.Context) {
+ chrome := d.chrome(ctx, "页面不存在 · "+d.Settings.SiteBranding().Name, "", "")
+ ctx.HTML(http.StatusNotFound, "status/404", chrome)
+}
+
+// NotFound NoRoute
+func (d Deps) NotFound(c *gin.Context) {
+ d.render404(d.ctx(c))
+}
diff --git a/routers/web/post.go b/routers/web/post.go
new file mode 100644
index 0000000..ce0c587
--- /dev/null
+++ b/routers/web/post.go
@@ -0,0 +1,193 @@
+package web
+
+import (
+ "fmt"
+ "html"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/models"
+ "git.iioio.com/freefire/jiang13-forum/services"
+ "github.com/gin-gonic/gin"
+)
+
+// PostPageData 帖详情
+type PostPageData struct {
+ PageChrome
+ PostID uint
+ PostPath string
+ PostTitle string
+ AuthorName string
+ BoardID uint
+ BoardName string
+ Pinned bool
+ Featured bool
+ PostTypeLabel string
+ CreatedLabel string
+ ViewCount int
+ LikeCount int
+ Liked bool
+ Favorited bool
+ BodyHTML string
+ CommentCount int
+ Comments []CommentView
+ CommentsLocked bool
+ CanEdit bool
+}
+
+// CommentView 评论
+type CommentView struct {
+ Floor int
+ AuthorName string
+ CreatedLabel string
+ Content string
+ ContentHidden bool
+}
+
+// PostView GET /post/:id
+func (d Deps) PostView(c *gin.Context) {
+ ctx := d.ctx(c)
+ idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil || id == 0 {
+ d.render404(ctx)
+ return
+ }
+ post, err := d.Post.FindByID(uint(id))
+ if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
+ d.render404(ctx)
+ return
+ }
+ if post.Status == models.ContentStatusPublished {
+ d.Post.RecordView(uint(id))
+ }
+
+ hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
+ body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
+
+ comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
+ cv := make([]CommentView, 0, len(comments))
+ for _, cm := range comments {
+ an := strings.TrimSpace(cm.User.Nickname)
+ if an == "" {
+ an = cm.User.Username
+ }
+ cv = append(cv, CommentView{
+ Floor: cm.Floor, AuthorName: an, CreatedLabel: formatTime(cm.CreatedAt),
+ Content: cm.Content, ContentHidden: cm.ContentHidden,
+ })
+ }
+
+ author := strings.TrimSpace(post.User.Nickname)
+ if author == "" {
+ author = post.User.Username
+ }
+ boardName := ""
+ if post.Board.ID > 0 {
+ boardName = post.Board.Name
+ }
+
+ chrome := d.chrome(ctx, post.Title+" · "+d.Settings.SiteBranding().Name, "", "post/body")
+ chrome.ActiveBoard = post.BoardID
+
+ ctx.HTML(http.StatusOK, "post", PostPageData{
+ PageChrome: chrome, PostID: post.ID,
+ PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
+ PostTitle: post.Title, AuthorName: author, BoardID: post.BoardID, BoardName: boardName,
+ Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured,
+ PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
+ ViewCount: post.ViewCount, LikeCount: post.LikeCount,
+ Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID),
+ BodyHTML: body, CommentCount: len(cv), Comments: cv,
+ CommentsLocked: post.CommentsLocked,
+ CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
+ })
+}
+
+func postTypeLabel(t string) string {
+ switch t {
+ case models.PostTypeQuestion:
+ return "问答"
+ case models.PostTypePoll:
+ return "投票"
+ case models.PostTypeBounty:
+ return "悬赏"
+ case models.PostTypeLottery:
+ return "抽奖"
+ default:
+ return ""
+ }
+}
+
+// PostComment POST 评论
+func (d Deps) PostComment(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect("/")
+ return
+ }
+ id, err := parsePostID(c, d)
+ if err != nil {
+ d.render404(ctx)
+ return
+ }
+ content := strings.TrimSpace(c.PostForm("content"))
+ if content == "" {
+ ctx.SetFlash("评论不能为空")
+ ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
+ return
+ }
+ safe := "" + html.EscapeString(content) + "
"
+ _, err = d.Comment.Create(services.CommentCreateInput{
+ PostID: id, UserID: ctx.UserID(), Content: safe,
+ })
+ if err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
+ return
+ }
+ ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
+}
+
+// PostLike 赞
+func (d Deps) PostLike(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求")
+ ctx.Redirect("/")
+ return
+ }
+ id, err := parsePostID(c, d)
+ if err != nil {
+ d.render404(ctx)
+ return
+ }
+ _, _ = d.Post.ToggleLike(ctx.UserID(), id)
+ ctx.Redirect(fmt.Sprintf("/post/%d", id))
+}
+
+// PostFavorite 收藏
+func (d Deps) PostFavorite(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求")
+ ctx.Redirect("/")
+ return
+ }
+ id, err := parsePostID(c, d)
+ if err != nil {
+ d.render404(ctx)
+ return
+ }
+ _, _ = d.Post.ToggleFavorite(ctx.UserID(), id)
+ ctx.Redirect(fmt.Sprintf("/post/%d", id))
+}
+
+func parsePostID(c *gin.Context, d Deps) (uint, error) {
+ idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
+ id, err := strconv.ParseUint(idStr, 10, 64)
+ return uint(id), err
+}
diff --git a/services/auth.go b/services/auth.go
index c45733c..9f56b03 100644
--- a/services/auth.go
+++ b/services/auth.go
@@ -1,38 +1,33 @@
package services
import (
- "errors"
"sync"
"time"
- "github.com/golang-jwt/jwt/v5"
"git.iioio.com/freefire/jiang13-forum/models"
)
-// 最近访问写入节流,避免每次 API 都打库
+// 最近访问写入节流,避免每次请求都打库
const lastAccessTouchInterval = 5 * time.Minute
var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
-const TokenExpire = 7 * 24 * time.Hour
-
-type Claims struct {
- UserID uint `json:"user_id"`
- Username string `json:"username"`
- Role models.Role `json:"role"`
- jwt.RegisteredClaims
-}
+// SessionTTL 与浏览器会话对齐(兼容旧常量名)
+const TokenExpire = SessionTTL
type AuthService struct {
- jwtSecret string
- filter *SensitiveFilter
- settings *ForumSettingsService
+ hmacSecret string // CSRF 等 HMAC;不再用于浏览器登录 JWT
+ filter *SensitiveFilter
+ settings *ForumSettingsService
}
-func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
- return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
+func NewAuthService(hmacSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
+ return &AuthService{hmacSecret: hmacSecret, filter: filter, settings: settings}
}
+// HMACSecret 供 CSRF 等使用
+func (s *AuthService) HMACSecret() string { return s.hmacSecret }
+
// UserCount 当前用户数
func (s *AuthService) UserCount() int64 {
var n int64
@@ -70,18 +65,12 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
}
nickname = s.filter.Filter(nickname)
- // 首个注册用户自动成为管理员
- role := models.RoleUser
- if s.UserCount() == 0 {
- role = models.RoleAdmin
- }
-
user := &models.User{
Username: username,
Email: email,
Password: hash,
Nickname: nickname,
- Role: role,
+ Role: models.RoleUser,
}
if err := models.DB.Create(user).Error; err != nil {
return nil, err
@@ -89,24 +78,69 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
return user, nil
}
-// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录
-func (s *AuthService) Login(username, password, clientIP string) (string, *models.User, error) {
- var user models.User
- if err := models.DB.Where("username = ?", username).First(&user).Error; err != nil {
- return "", nil, ErrInvalidCred
+// CreateAdmin 安装向导创建管理员(仅应在未安装时调用)
+func (s *AuthService) CreateAdmin(username, password, nickname, email string) (*models.User, error) {
+ if err := ValidateUsername(username); err != nil {
+ return nil, err
}
- if user.Banned {
- return "", nil, ErrUserBanned
+ if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
+ return nil, err
}
- if !CheckPassword(user.Password, password) {
- return "", nil, ErrInvalidCred
+ email = NormalizeEmail(email)
+ if err := ValidateEmail(email); err != nil {
+ return nil, err
}
- s.recordLogin(&user, clientIP)
- token, err := s.GenerateToken(&user)
- return token, &user, err
+ if nickname == "" {
+ nickname = username
+ }
+ nickname = s.filter.Filter(nickname)
+ hash, err := HashPassword(password)
+ if err != nil {
+ return nil, err
+ }
+ user := &models.User{
+ Username: username,
+ Email: email,
+ Password: hash,
+ Nickname: nickname,
+ Role: models.RoleAdmin,
+ Verified: true,
+ }
+ if err := models.DB.Create(user).Error; err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+
+// Login 校验密码并创建会话,返回 session id
+func (s *AuthService) Login(username, password, clientIP, userAgent string) (sessionID string, user *models.User, err error) {
+ var u models.User
+ if err := models.DB.Where("username = ?", username).First(&u).Error; err != nil {
+ return "", nil, ErrInvalidCred
+ }
+ if u.Banned {
+ return "", nil, ErrUserBanned
+ }
+ if !CheckPassword(u.Password, password) {
+ return "", nil, ErrInvalidCred
+ }
+ s.recordLogin(&u, clientIP)
+ sid, err := CreateSession(u.ID, clientIP, userAgent)
+ if err != nil {
+ return "", nil, err
+ }
+ return sid, &u, nil
+}
+
+// CreateSessionForUser 已认证用户直接建会话(注册后自动登录)
+func (s *AuthService) CreateSessionForUser(user *models.User, clientIP, userAgent string) (string, error) {
+ if user == nil {
+ return "", ErrInvalidCred
+ }
+ s.recordLogin(user, clientIP)
+ return CreateSession(user.ID, clientIP, userAgent)
}
-// recordLogin 记录上次登录时间与 IP;登录同时视为一次访问(失败不影响登录)
func (s *AuthService) recordLogin(user *models.User, clientIP string) {
now := time.Now()
ip := clientIP
@@ -138,33 +172,3 @@ func (s *AuthService) TouchLastAccess(userID uint) {
lastAccessTouchCache.Store(userID, now)
_ = models.DB.Model(&models.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
}
-
-// GenerateToken 生成 JWT
-func (s *AuthService) GenerateToken(user *models.User) (string, error) {
- claims := Claims{
- UserID: user.ID,
- Username: user.Username,
- Role: user.Role,
- RegisteredClaims: jwt.RegisteredClaims{
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpire)),
- IssuedAt: jwt.NewNumericDate(time.Now()),
- },
- }
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- return token.SignedString([]byte(s.jwtSecret))
-}
-
-// ParseToken 解析 JWT
-func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) {
- token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
- return []byte(s.jwtSecret), nil
- })
- if err != nil {
- return nil, err
- }
- claims, ok := token.Claims.(*Claims)
- if !ok || !token.Valid {
- return nil, errors.New("invalid token")
- }
- return claims, nil
-}
diff --git a/services/backup.go b/services/backup.go
index d02ee21..205330c 100644
--- a/services/backup.go
+++ b/services/backup.go
@@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "git.iioio.com/freefire/jiang13-forum/models"
)
type BackupService struct {
@@ -17,8 +19,11 @@ func NewBackupService(dbPath, dataDir string) *BackupService {
return &BackupService{dbPath: dbPath, dataDir: dataDir}
}
-// ExportSQLite 导出 SQLite 备份文件到 data 目录
+// ExportSQLite 导出 SQLite 备份文件到 data 目录(仅 sqlite)
func (s *BackupService) ExportSQLite() (string, error) {
+ if models.DialectorName() != "sqlite" {
+ return "", fmt.Errorf("一键文件备份仅支持 SQLite;当前为 %s,请使用数据库自带备份工具", models.DialectorName())
+ }
src, err := os.Open(s.dbPath)
if err != nil {
return "", fmt.Errorf("打开数据库失败: %w", err)
@@ -38,15 +43,3 @@ func (s *BackupService) ExportSQLite() (string, error) {
}
return destPath, nil
}
-
-// WriteDefaultFilterWords 写入默认敏感词配置
-func WriteDefaultFilterWords(path string) error {
- if _, err := os.Stat(path); err == nil {
- return nil
- }
- content := `# 姜十三论坛敏感词配置,每行一个词,# 开头为注释
-违禁词示例
-广告刷单
-`
- return os.WriteFile(path, []byte(content), 0644)
-}
diff --git a/services/comment.go b/services/comment.go
index 9eaeeb1..d21c987 100644
--- a/services/comment.go
+++ b/services/comment.go
@@ -345,6 +345,29 @@ func (s *CommentService) PendingCommentCount() (int64, error) {
return n, err
}
+// ListPending 待审评论列表(管理端)
+func (s *CommentService) ListPending(page, size int) ([]models.Comment, int64, error) {
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 {
+ size = 30
+ }
+ if size > 100 {
+ size = 100
+ }
+ db := models.DB.Model(&models.Comment{}).
+ Where("status = ?", models.ContentStatusPending).
+ Preload("User").Preload("Post")
+ var total int64
+ if err := db.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var list []models.Comment
+ err := db.Order("id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error
+ return list, total, err
+}
+
func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
if !isAdmin {
return ErrPermissionDenied
diff --git a/services/common.go b/services/common.go
index 314b669..366583b 100644
--- a/services/common.go
+++ b/services/common.go
@@ -115,7 +115,12 @@ func (f *SensitiveFilter) LoadFromFile(path string) {
if err != nil {
return
}
- lines := strings.Split(string(data), "\n")
+ f.LoadFromContent(string(data))
+}
+
+// LoadFromContent 从文本内容加载敏感词(每行一词,# 注释)
+func (f *SensitiveFilter) LoadFromContent(content string) {
+ lines := strings.Split(content, "\n")
var words []string
for _, line := range lines {
line = strings.TrimSpace(line)
@@ -123,11 +128,12 @@ func (f *SensitiveFilter) LoadFromFile(path string) {
words = append(words, line)
}
}
- if len(words) > 0 {
- f.mu.Lock()
- f.words = words
- f.mu.Unlock()
+ if len(words) == 0 {
+ return
}
+ f.mu.Lock()
+ f.words = words
+ f.mu.Unlock()
}
func (f *SensitiveFilter) Filter(text string) string {
diff --git a/services/compose_body.go b/services/compose_body.go
new file mode 100644
index 0000000..9831700
--- /dev/null
+++ b/services/compose_body.go
@@ -0,0 +1,97 @@
+package services
+
+import (
+ "html"
+ "regexp"
+ "strings"
+)
+
+var (
+ mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
+ mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+)
+
+// ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。
+// 若已含块级 HTML 标签则原样交 Sanitize;否则按纯文本/轻量 Markdown 转段落。
+func ComposeBodyToHTML(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ""
+ }
+ lower := strings.ToLower(raw)
+ if strings.Contains(lower, " + `)
`
+ })
+ esc = mdLinkRe.ReplaceAllStringFunc(esc, func(m string) string {
+ sub := mdLinkRe.FindStringSubmatch(m)
+ if len(sub) != 3 {
+ return m
+ }
+ text, href := sub[1], sub[2]
+ if !safeComposeURL(href) {
+ return m
+ }
+ return `` + text + ``
+ })
+
+ parts := strings.Split(esc, "\n\n")
+ var b strings.Builder
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ // 已是独立 img 段
+ if strings.HasPrefix(p, "
\n")
+ b.WriteString("
")
+ b.WriteString(p)
+ b.WriteString("
\n")
+ }
+ return b.String()
+}
+
+func safeComposeURL(u string) bool {
+ u = strings.TrimSpace(u)
+ if strings.HasPrefix(u, "/uploads/") || strings.HasPrefix(u, "/media/") {
+ return true
+ }
+ return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://")
+}
+
+// HTMLToComposePlain 编辑页回显:去掉简单标签便于 textarea 编辑(尽力而为)
+func HTMLToComposePlain(htmlBody string) string {
+ s := strings.TrimSpace(htmlBody)
+ if s == "" {
+ return ""
+ }
+ // img → markdown
+ imgRe := regexp.MustCompile(`(?i)
]+src="([^"]+)"[^>]*>`)
+ s = imgRe.ReplaceAllString(s, "")
+ s = regexp.MustCompile(`(?i)
\s*`).ReplaceAllString(s, "\n\n")
+ s = regexp.MustCompile(`(?i)
`).ReplaceAllString(s, "\n")
+ s = regexp.MustCompile(`(?i)?p[^>]*>`).ReplaceAllString(s, "")
+ s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
+ return strings.TrimSpace(html.UnescapeString(s))
+}
diff --git a/services/content_gate.go b/services/content_gate.go
new file mode 100644
index 0000000..4edbcfa
--- /dev/null
+++ b/services/content_gate.go
@@ -0,0 +1,21 @@
+package services
+
+import "git.iioio.com/freefire/jiang13-forum/models"
+
+// ApplyPostContentGates 按观众身份对帖文 HTML 做门控遮盖(消毒 + members/reply/points)
+func ApplyPostContentGates(content string, post *models.Post, viewerID uint, isAdmin bool, hasReplied bool) string {
+ content = SanitizePostHTML(content)
+ if viewerID == 0 {
+ content = RedactMembersOnlyHTML(content)
+ content = RedactReplyOnlyHTML(content)
+ } else if !isAdmin && post.UserID != viewerID && !hasReplied {
+ content = RedactReplyOnlyHTML(content)
+ }
+ if isAdmin || post.UserID == viewerID {
+ content = RevealAllPointsOnly(content)
+ } else {
+ unlocked, _ := ListUnlockedKeys(viewerID, post.ID)
+ content = RedactPointsOnlyHTML(content, unlocked)
+ }
+ return content
+}
diff --git a/services/filter_words.go b/services/filter_words.go
index e67d2f5..2a340ee 100644
--- a/services/filter_words.go
+++ b/services/filter_words.go
@@ -5,7 +5,49 @@ import (
"strings"
)
-// ReadFilterWordsFile 读取敏感词配置文件内容
+const defaultFilterWordsContent = `# 姜十三论坛敏感词配置,每行一个词,# 开头为注释
+违禁词示例
+广告刷单
+`
+
+// EnsureFilterWordsInSettings 将敏感词迁入 forum_settings(优先已有键;否则从文件导入;否则默认)
+func EnsureFilterWordsInSettings(settings *ForumSettingsService, legacyFilePath string, filter *SensitiveFilter) {
+ if settings == nil {
+ return
+ }
+ cur := strings.TrimSpace(settings.getString(SettingFilterWords, ""))
+ if cur == "" {
+ if data, err := os.ReadFile(legacyFilePath); err == nil && len(strings.TrimSpace(string(data))) > 0 {
+ cur = string(data)
+ } else {
+ cur = defaultFilterWordsContent
+ }
+ _ = settings.setString(SettingFilterWords, cur)
+ }
+ filter.LoadFromContent(cur)
+}
+
+// FilterWordsContent 读取敏感词全文
+func (s *ForumSettingsService) FilterWordsContent() string {
+ v := s.getString(SettingFilterWords, "")
+ if strings.TrimSpace(v) == "" {
+ return defaultFilterWordsContent
+ }
+ return v
+}
+
+// UpdateFilterWords 更新敏感词并热加载过滤器
+func (s *ForumSettingsService) UpdateFilterWords(content string, filter *SensitiveFilter) error {
+ if err := s.setString(SettingFilterWords, content); err != nil {
+ return err
+ }
+ if filter != nil {
+ filter.LoadFromContent(content)
+ }
+ return nil
+}
+
+// ReadFilterWordsFile 兼容旧 API:读文件(Admin 未迁时)
func ReadFilterWordsFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -14,15 +56,23 @@ func ReadFilterWordsFile(path string) (string, error) {
return string(data), nil
}
-// WriteFilterWordsFile 写入敏感词配置并热加载到过滤器
+// WriteFilterWordsFile 兼容旧写入:写文件并加载;新路径请用 UpdateFilterWords
func WriteFilterWordsFile(path string, content string, filter *SensitiveFilter) error {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return err
}
- filter.LoadFromFile(path)
+ filter.LoadFromContent(content)
return nil
}
+// WriteDefaultFilterWords 若文件不存在则写默认(遗留兼容,新站以 DB 为准)
+func WriteDefaultFilterWords(path string) error {
+ if _, err := os.Stat(path); err == nil {
+ return nil
+ }
+ return os.WriteFile(path, []byte(defaultFilterWordsContent), 0644)
+}
+
// CountFilterWords 统计有效敏感词数量(不含空行与注释)
func CountFilterWords(content string) int {
count := 0
diff --git a/services/gitea.go b/services/gitea.go
index 888082e..6e85360 100644
--- a/services/gitea.go
+++ b/services/gitea.go
@@ -71,31 +71,9 @@ func NewGiteaService(settings *ForumSettingsService) *GiteaService {
}
}
-// StartBackgroundSync 按配置间隔后台同步;失败只记日志
+// StartBackgroundSync 已后置:本阶段不启动定时同步(保留空实现以免旧调用 panic)
func (g *GiteaService) StartBackgroundSync() {
- g.wg.Add(1)
- go func() {
- defer g.wg.Done()
- // 启动后稍等再首次尝试,避免拖慢启动
- timer := time.NewTimer(15 * time.Second)
- defer timer.Stop()
- for {
- select {
- case <-g.stopCh:
- return
- case <-timer.C:
- if _, err := g.SyncRepos(); err != nil && !errors.Is(err, ErrGiteaNotConfigured) && !errors.Is(err, ErrGiteaSyncBusy) {
- log.Printf("[gitea] 后台同步失败: %v", err)
- }
- cfg := g.settings.GiteaSyncConfig()
- interval := time.Duration(cfg.SyncIntervalMin) * time.Minute
- if interval < 5*time.Minute {
- interval = 5 * time.Minute
- }
- timer.Reset(interval)
- }
- }
- }()
+ log.Printf("[gitea] 仓库同步已后置,跳过后台定时任务")
}
// Stop 停止后台同步
diff --git a/services/install.go b/services/install.go
new file mode 100644
index 0000000..ad1b727
--- /dev/null
+++ b/services/install.go
@@ -0,0 +1,48 @@
+package services
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+
+ "git.iioio.com/freefire/jiang13-forum/models"
+)
+
+const installLockName = "install.lock"
+
+// InstallLockPath 安装锁文件路径
+func InstallLockPath(dataDir string) string {
+ return filepath.Join(dataDir, installLockName)
+}
+
+// IsInstalled 是否已完成安装向导
+func IsInstalled(dataDir string) bool {
+ _, err := os.Stat(InstallLockPath(dataDir))
+ return err == nil
+}
+
+// EnsureInstallLockFromExistingData 已有用户时自动写锁(避免旧数据无法启动)
+func EnsureInstallLockFromExistingData(dataDir string) error {
+ if IsInstalled(dataDir) {
+ return nil
+ }
+ var n int64
+ if err := models.DB.Model(&models.User{}).Count(&n).Error; err != nil {
+ return err
+ }
+ if n == 0 {
+ return nil
+ }
+ return WriteInstallLock(dataDir)
+}
+
+// WriteInstallLock 写入安装锁
+func WriteInstallLock(dataDir string) error {
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(InstallLockPath(dataDir), []byte("installed\n"), 0o644)
+}
+
+// ErrAlreadyInstalled 已安装
+var ErrAlreadyInstalled = errors.New("already installed")
diff --git a/services/oidc.go b/services/oidc.go
index 2df1254..83bd693 100644
--- a/services/oidc.go
+++ b/services/oidc.go
@@ -52,11 +52,13 @@ type OIDCService struct {
privateKey *rsa.PrivateKey
}
-// NewOIDCService 创建并加载/生成 RSA 密钥
+// NewOIDCService 创建;RSA 密钥仅在启用 OIDC 时懒加载/生成(存 forum_settings)
func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) {
s := &OIDCService{cfg: cfg, settings: settings}
- if err := s.loadOrCreateKey(); err != nil {
- return nil, err
+ if s.runtime().Enabled {
+ if err := s.ensureKey(); err != nil {
+ return nil, err
+ }
}
return s, nil
}
@@ -68,29 +70,36 @@ func (s *OIDCService) runtime() OIDCConfig {
return OIDCConfig{}
}
-func (s *OIDCService) loadOrCreateKey() error {
- keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
- if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
- block, _ := pem.Decode(data)
- if block == nil {
- return fmt.Errorf("解析 OIDC RSA 密钥失败")
- }
- key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
- if err != nil {
- parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
- if err2 != nil {
- return fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
- }
- var ok bool
- key, ok = parsed.(*rsa.PrivateKey)
- if !ok {
- return fmt.Errorf("OIDC 密钥不是 RSA")
- }
- }
- s.privateKey = key
+// ensureKey 懒加载:settings PEM → 旧文件迁移 → 新生成写入 settings(不再主动写 .oidc_rsa.pem)
+func (s *OIDCService) ensureKey() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.privateKey != nil {
+ return nil
+ }
+ if s.settings != nil {
+ if pemStr := strings.TrimSpace(s.settings.getString(SettingOIDCRSAPrivatePEM, "")); pemStr != "" {
+ key, err := parseOIDCRSAPrivateKey([]byte(pemStr))
+ if err != nil {
+ return err
+ }
+ s.privateKey = key
+ return nil
+ }
+ }
+ // 兼容旧文件一次迁入
+ keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
+ if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
+ key, err := parseOIDCRSAPrivateKey(data)
+ if err != nil {
+ return err
+ }
+ s.privateKey = key
+ if s.settings != nil {
+ _ = s.settings.setString(SettingOIDCRSAPrivatePEM, string(data))
+ }
return nil
}
-
key, err := rsa.GenerateKey(rand.Reader, oidcRSABits)
if err != nil {
return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err)
@@ -99,13 +108,39 @@ func (s *OIDCService) loadOrCreateKey() error {
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
- if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil {
- return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err)
+ if s.settings != nil {
+ if err := s.settings.setString(SettingOIDCRSAPrivatePEM, string(pemBytes)); err != nil {
+ return fmt.Errorf("持久化 OIDC RSA 密钥失败: %w", err)
+ }
}
s.privateKey = key
return nil
}
+func parseOIDCRSAPrivateKey(data []byte) (*rsa.PrivateKey, error) {
+ block, _ := pem.Decode(data)
+ if block == nil {
+ return nil, fmt.Errorf("解析 OIDC RSA 密钥失败")
+ }
+ key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+ if err != nil {
+ parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err2 != nil {
+ return nil, fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
+ }
+ var ok bool
+ key, ok = parsed.(*rsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("OIDC 密钥不是 RSA")
+ }
+ }
+ return key, nil
+}
+
+func (s *OIDCService) loadOrCreateKey() error {
+ return s.ensureKey()
+}
+
// Enabled 是否可对外提供 OIDC
func (s *OIDCService) Enabled() bool {
return s.runtime().Ready
@@ -144,6 +179,12 @@ func (s *OIDCService) Discovery() (map[string]any, error) {
// JWKS 返回 JSON Web Key Set
func (s *OIDCService) JWKS() (map[string]any, error) {
+ if !s.Enabled() {
+ return nil, ErrOIDCNotConfigured
+ }
+ if err := s.ensureKey(); err != nil {
+ return nil, err
+ }
s.mu.RLock()
key := s.privateKey
s.mu.RUnlock()
@@ -424,6 +465,9 @@ func (s *OIDCService) signAccessToken(user *models.User, scope, clientID string)
}
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
t.Header["kid"] = oidcKeyID
+ if err := s.ensureKey(); err != nil {
+ return "", err
+ }
s.mu.RLock()
defer s.mu.RUnlock()
return t.SignedString(s.privateKey)
@@ -457,6 +501,9 @@ func (s *OIDCService) signIDToken(user *models.User, scope, clientID, nonce stri
}
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
t.Header["kid"] = oidcKeyID
+ if err := s.ensureKey(); err != nil {
+ return "", err
+ }
s.mu.RLock()
defer s.mu.RUnlock()
return t.SignedString(s.privateKey)
diff --git a/services/post.go b/services/post.go
index e10d998..79fc2f0 100644
--- a/services/post.go
+++ b/services/post.go
@@ -338,10 +338,9 @@ func (s *PostService) List(q PostListQuery) ([]models.Post, int64, error) {
}
}
if tag := strings.TrimSpace(q.Tag); tag != "" {
- // 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
+ // 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感(跨 sqlite/postgres/mysql)
escaped := escapeLikePattern(strings.ToLower(tag))
- normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), ',', ','), ', ', ','), ' ,', ',') || ',')"
- db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
+ db = db.Where(tagsNormalizedExpr()+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
}
var total int64
db.Count(&total)
@@ -390,6 +389,18 @@ func escapeLikePattern(s string) string {
return s
}
+// tagsNormalizedExpr 标签列规范化表达式(跨方言)
+// 方言:sqlite / postgres 用 ||;mysql 用 CONCAT;COALESCE 三库通用
+func tagsNormalizedExpr() string {
+ inner := "REPLACE(REPLACE(REPLACE(COALESCE(tags,''), ',', ','), ', ', ','), ' ,', ',')"
+ switch models.DialectorName() {
+ case "mysql":
+ return "LOWER(CONCAT(',', " + inner + ", ','))"
+ default:
+ return "LOWER(',' || " + inner + " || ',')"
+ }
+}
+
// resolveAuthorUserID 按用户名精确匹配,否则按昵称精确匹配(优先用户名)
func resolveAuthorUserID(author string) (uint, bool) {
author = strings.TrimSpace(author)
diff --git a/services/session.go b/services/session.go
new file mode 100644
index 0000000..b1d0e27
--- /dev/null
+++ b/services/session.go
@@ -0,0 +1,129 @@
+package services
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "strings"
+ "sync"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/models"
+)
+
+const (
+ // SessionTTL 浏览器会话默认有效期
+ SessionTTL = 7 * 24 * time.Hour
+ // sessionTouchMin 滑动续期写库节流
+ sessionTouchMin = 2 * time.Minute
+)
+
+var (
+ ErrSessionInvalid = errors.New("会话无效或已过期")
+ sessionTouchCache sync.Map // sessionID -> time.Time
+)
+
+// CreateSession 为用户创建会话,返回 cookie 值
+func CreateSession(userID uint, ip, userAgent string) (string, error) {
+ id, err := newSessionID()
+ if err != nil {
+ return "", err
+ }
+ now := time.Now()
+ ip = trimLen(ip, 45)
+ ua := trimLen(userAgent, 256)
+ rec := models.Session{
+ ID: id,
+ UserID: userID,
+ ExpiresAt: now.Add(SessionTTL),
+ CreatedAt: now,
+ LastSeenAt: now,
+ IP: ip,
+ UserAgent: ua,
+ }
+ if err := models.DB.Create(&rec).Error; err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+// ResolveSession 校验会话并返回用户;无效则删行并返回错误
+func ResolveSession(sessionID string) (*models.User, *models.Session, error) {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return nil, nil, ErrSessionInvalid
+ }
+ var sess models.Session
+ if err := models.DB.First(&sess, "id = ?", sessionID).Error; err != nil {
+ return nil, nil, ErrSessionInvalid
+ }
+ now := time.Now()
+ if now.After(sess.ExpiresAt) {
+ _ = models.DB.Delete(&models.Session{}, "id = ?", sessionID).Error
+ return nil, nil, ErrSessionInvalid
+ }
+ var user models.User
+ if err := models.DB.First(&user, sess.UserID).Error; err != nil {
+ _ = models.DB.Delete(&models.Session{}, "id = ?", sessionID).Error
+ return nil, nil, ErrSessionInvalid
+ }
+ touchSession(&sess, now)
+ return &user, &sess, nil
+}
+
+func touchSession(sess *models.Session, now time.Time) {
+ if v, ok := sessionTouchCache.Load(sess.ID); ok {
+ if t, ok := v.(time.Time); ok && now.Sub(t) < sessionTouchMin {
+ return
+ }
+ }
+ sessionTouchCache.Store(sess.ID, now)
+ half := SessionTTL / 2
+ remaining := sess.ExpiresAt.Sub(now)
+ updates := map[string]interface{}{"last_seen_at": now}
+ if remaining < half {
+ updates["expires_at"] = now.Add(SessionTTL)
+ sess.ExpiresAt = now.Add(SessionTTL)
+ }
+ sess.LastSeenAt = now
+ _ = models.DB.Model(&models.Session{}).Where("id = ?", sess.ID).Updates(updates).Error
+}
+
+// DeleteSession 登出当前会话
+func DeleteSession(sessionID string) {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return
+ }
+ _ = models.DB.Delete(&models.Session{}, "id = ?", sessionID).Error
+ sessionTouchCache.Delete(sessionID)
+}
+
+// RevokeUserSessions 吊销用户全部会话(禁言 / 改密)
+func RevokeUserSessions(userID uint) {
+ if userID == 0 {
+ return
+ }
+ _ = models.DB.Where("user_id = ?", userID).Delete(&models.Session{}).Error
+}
+
+func newSessionID() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func trimLen(s string, n int) string {
+ s = strings.TrimSpace(s)
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}
+
+// SessionCookieMaxAge Cookie MaxAge(秒)
+func SessionCookieMaxAge() int {
+ return int(SessionTTL.Seconds())
+}
diff --git a/services/settings.go b/services/settings.go
index 7229f48..35ce9ea 100644
--- a/services/settings.go
+++ b/services/settings.go
@@ -11,6 +11,9 @@ import (
"git.iioio.com/freefire/jiang13-forum/models"
)
+// ForumSettingsService 读写 forum_settings:全部为运行时热更新(改后无需重启进程)。
+// 需重启的项仅在 CLI/Env(端口、DATA、DB_*)与 data 下密钥文件,见 docs/rebuild-spec/07-config-ops.md。
+
// 论坛设置键名
const (
SettingPostEditWindowHours = "post_edit_window_hours"
@@ -64,6 +67,7 @@ const (
SettingOIDCAdminGroup = "oidc_admin_group"
SettingOIDCUserGroup = "oidc_user_group"
+ // Gitea 同步键保留兼容,产品能力已后置(见 02-features.md §K)
SettingGiteaSyncEnabled = "gitea_sync_enabled"
SettingGiteaBaseURL = "gitea_base_url"
SettingGiteaToken = "gitea_token"
@@ -93,6 +97,10 @@ const (
SettingSiteFriendLinks = "site_friend_links"
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
+ SettingFilterWords = "filter_words"
+
+ SettingOIDCRSAPrivatePEM = "oidc_rsa_private_pem"
+
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
pageSizeAPIMax = 100
)
@@ -403,63 +411,63 @@ func NewForumSettingsService() *ForumSettingsService {
func (s *ForumSettingsService) ensureDefaults() {
for _, def := range forumSettingDefs {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", def.key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: def.key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: def.key, Value: def.defaultVal})
}
}
for key, val := range feedSettingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range asideSettingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range mailSettingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range oidcSettingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range giteaSettingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range storageSettingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range siteBrandingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range friendLinkSettingDefaults {
var count int64
- models.DB.Model(&models.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
@@ -468,7 +476,7 @@ func (s *ForumSettingsService) ensureDefaults() {
func (s *ForumSettingsService) getString(key, fallback string) string {
var setting models.ForumSetting
- if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
+ if err := models.DB.First(&setting, &models.ForumSetting{Key: key}).Error; err != nil {
return fallback
}
return setting.Value
@@ -482,7 +490,7 @@ func (s *ForumSettingsService) setString(key, value string) error {
func (s *ForumSettingsService) getInt(key string, fallback int) int {
var setting models.ForumSetting
- if err := models.DB.First(&setting, "`key` = ?", key).Error; err != nil {
+ if err := models.DB.First(&setting, &models.ForumSetting{Key: key}).Error; err != nil {
return fallback
}
v, err := strconv.Atoi(setting.Value)
@@ -589,6 +597,23 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
}
}
+// UpdateRateLimits 仅更新基础限流(Admin SSR 子集,不影响其它 limits)
+func (s *ForumSettingsService) UpdateRateLimits(post, comment, register, login, windowSec int) error {
+ updates := map[string]int{
+ SettingRateLimitPost: post,
+ SettingRateLimitComment: comment,
+ SettingRateLimitRegister: register,
+ SettingRateLimitLogin: login,
+ SettingRateLimitWindow: windowSec,
+ }
+ for key, val := range updates {
+ if err := s.setInt(key, val); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours,
diff --git a/services/user.go b/services/user.go
index 3ec8ffb..4812c3f 100644
--- a/services/user.go
+++ b/services/user.go
@@ -95,7 +95,11 @@ func (s *UserService) ResetPasswordByEmail(email, newPass string) error {
if err != nil {
return err
}
- return models.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("password", hash).Error
+ if err := models.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("password", hash).Error; err != nil {
+ return err
+ }
+ RevokeUserSessions(user.ID)
+ return nil
}
// SearchUsersBrief 公开用户搜索(@补全):匹配用户名/昵称,不含邮箱
@@ -288,7 +292,13 @@ func (s *UserService) BanUser(userID uint, banned bool) error {
if banned {
updates["banned_at"] = &now
}
- return models.DB.Model(&models.User{}).Where("id = ?", userID).Updates(updates).Error
+ if err := models.DB.Model(&models.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
+ return err
+ }
+ if banned {
+ RevokeUserSessions(userID)
+ }
+ return nil
}
// SitemapUser 站点地图用的轻量用户字段
diff --git a/templates/admin/boards.tmpl b/templates/admin/boards.tmpl
new file mode 100644
index 0000000..205c173
--- /dev/null
+++ b/templates/admin/boards.tmpl
@@ -0,0 +1,47 @@
+{{define "admin/boards"}}
+{{template "base/head" .}}
+{{template "base/navbar" .}}
+
+ 板块管理
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+ 新建板块
+
+
+ 已有板块
+ {{if .Boards}}
+
+ {{range .Boards}}
+ -
+
+
+
+ {{end}}
+
+ {{else}}
+ 暂无板块
+ {{end}}
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl
new file mode 100644
index 0000000..5f0074f
--- /dev/null
+++ b/templates/admin/dashboard.tmpl
@@ -0,0 +1,18 @@
+{{define "admin/dashboard"}}
+{{template "base/head" .}}
+{{template "base/navbar" .}}
+
+ 管理后台
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+ - {{.UserCount}}用户
+ - {{.PostCount}}已发布帖
+ - {{.BoardCount}}板块
+ - {{.PendingPosts}}待审帖
+ - {{.PendingComments}}待审评
+
+ 有待审内容时请前往 内容审核。
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/admin/moderation.tmpl b/templates/admin/moderation.tmpl
new file mode 100644
index 0000000..e9505b2
--- /dev/null
+++ b/templates/admin/moderation.tmpl
@@ -0,0 +1,60 @@
+{{define "admin/moderation"}}
+{{template "base/head" .}}
+{{template "base/navbar" .}}
+
+ 内容审核
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+ 待审帖子({{len .Posts}})
+ {{if .Posts}}
+
+ {{else}}
+ 无待审帖子
+ {{end}}
+
+ 待审评论({{len .Comments}})
+ {{if .Comments}}
+
+ {{else}}
+ 无待审评论
+ {{end}}
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl
new file mode 100644
index 0000000..9ec8853
--- /dev/null
+++ b/templates/admin/nav.tmpl
@@ -0,0 +1,9 @@
+{{define "admin/nav"}}
+
+{{end}}
diff --git a/templates/admin/settings.tmpl b/templates/admin/settings.tmpl
new file mode 100644
index 0000000..f3c0b95
--- /dev/null
+++ b/templates/admin/settings.tmpl
@@ -0,0 +1,43 @@
+{{define "admin/settings"}}
+{{template "base/head" .}}
+{{template "base/navbar" .}}
+
+ 站点设置
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+ 品牌
+
+
+ 基础限流
+ 窗口内允许的操作次数(秒)。
+
+
+ 敏感词(有效 {{.FilterCount}} 个)
+ 每行一个词,# 开头为注释。保存后立即热更新。
+
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/auth/login.tmpl b/templates/auth/login.tmpl
new file mode 100644
index 0000000..a9542b1
--- /dev/null
+++ b/templates/auth/login.tmpl
@@ -0,0 +1,20 @@
+{{define "auth/login"}}
+{{template "base/head" .}}
+
+ 登录
+ {{template "base/alert" .}}
+
+ 没有账号?注册 · 返回首页
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/auth/register.tmpl b/templates/auth/register.tmpl
new file mode 100644
index 0000000..f5abbe9
--- /dev/null
+++ b/templates/auth/register.tmpl
@@ -0,0 +1,39 @@
+{{define "auth/register"}}
+{{template "base/head" .}}
+
+ 注册
+ {{template "base/alert" .}}
+
+ 已有账号?登录 · 返回首页
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/base.tmpl b/templates/base.tmpl
deleted file mode 100644
index c0a64c3..0000000
--- a/templates/base.tmpl
+++ /dev/null
@@ -1,51 +0,0 @@
-{{define "base"}}
-
-
-
-
-
- {{.Title}}
- {{if .Description}}{{end}}
-
-
-
-
-
-
-
- {{template "content" .}}
-
-
-
-
-
-
-{{end}}
diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl
new file mode 100644
index 0000000..14a00c4
--- /dev/null
+++ b/templates/base/alert.tmpl
@@ -0,0 +1,4 @@
+{{define "base/alert"}}
+{{if .Error}}{{.Error}}
{{end}}
+{{if .Flash}}{{.Flash}}
{{end}}
+{{end}}
diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl
new file mode 100644
index 0000000..73cb8ca
--- /dev/null
+++ b/templates/base/footer.tmpl
@@ -0,0 +1,8 @@
+{{define "base/footer"}}
+
+
+
+