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(` 板块页 SSR 迁移中,请先从 首页 浏览。 帖子详情 SSR 迁移中,请先从 首页 返回。 用户主页 SSR 迁移中,请先从 首页 返回。 该页面 SSR 迁移中。返回首页 页面不存在。 " + html.EscapeString(intro) + " " + html.EscapeString(sig) + " 您访问的页面不存在或已删除。 API 仍可用;UI 请暂时对照 " + html.EscapeString(content) + " ")
+ b.WriteString(p)
+ b.WriteString("%s
404
%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(post.Title) + "
")
- body.WriteString(`")
- body.WriteString(content)
- body.WriteString("" + html.EscapeString(name) + " 的主页
")
- if sig != "" {
- body.WriteString("页面不存在
管理后台 SSR 迁移中
main 分支 SPA,或等待后续模板页。\n")
+ b.WriteString("
]+src="([^"]+)"[^>]*>`)
+ s = imgRe.ReplaceAllString(s, "")
+ s = regexp.MustCompile(`(?i)
`).ReplaceAllString(s, "\n\n")
+ s = regexp.MustCompile(`(?i) 暂无板块 有待审内容时请前往 内容审核。 {{.AuthorName}} · {{.BoardName}} · {{.CreatedAt}} 无待审帖子 #{{.Floor}} · {{.AuthorName}} · {{.CreatedAt}} · {{.PostTitle}} {{.Excerpt}} 无待审评论 窗口内允许的操作次数(秒)。 每行一个词,# 开头为注释。保存后立即热更新。
`).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}}
+
+ {{else}}
+ 管理后台
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+
+ 内容审核
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+ 待审帖子({{len .Posts}})
+ {{if .Posts}}
+
+ {{range .Posts}}
+
+ {{else}}
+ 待审评论({{len .Comments}})
+ {{if .Comments}}
+
+ {{range .Comments}}
+
+ {{else}}
+ 站点设置
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+ 品牌
+
+
+ 基础限流
+ 敏感词(有效 {{.FilterCount}} 个)
+ 登录
+ {{template "base/alert" .}}
+
+
+注册
+ {{template "base/alert" .}}
+
+
+