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![](${url})\n\n`; + const start = textarea.selectionStart || textarea.value.length; + const end = textarea.selectionEnd || start; + textarea.value = + textarea.value.slice(0, start) + md + textarea.value.slice(end); + textarea.focus(); + if (statusEl) statusEl.textContent = "已插入图片"; + } catch (e) { + if (statusEl) statusEl.textContent = e.message || "上传失败"; + } + }); +})(); diff --git a/routers/api/api.go b/routers/api/api.go index 80af074..a6b4e82 100644 --- a/routers/api/api.go +++ b/routers/api/api.go @@ -758,21 +758,9 @@ func (h *Handlers) APIProjects(c *gin.Context) { }) } -// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置 +// APIAdminUpdateGiteaSettings Gitea 同步已后置 func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) { - var req services.GiteaSyncConfig - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) - return - } - if err := h.Settings.UpdateGiteaSyncConfig(req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "Gitea 同步设置已保存", - "gitea": h.Settings.GiteaSyncConfigPublic(), - }) + c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"}) } // APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换 @@ -796,22 +784,9 @@ func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) { }) } -// APIAdminSyncGitea 立即同步 Gitea 公开仓库 +// APIAdminSyncGitea Gitea 同步已后置 func (h *Handlers) APIAdminSyncGitea(c *gin.Context) { - if h.Gitea == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrGiteaNotConfigured.Error()}) - return - } - n, err := h.Gitea.SyncRepos() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": fmt.Sprintf("同步完成,共更新 %d 个仓库", n), - "count": n, - "gitea": h.Settings.GiteaSyncConfigPublic(), - }) + c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"}) } // APIAdminListOAuthClients 列出 OAuth 应用 diff --git a/routers/api/handlers.go b/routers/api/handlers.go index 8a2c417..4abd801 100644 --- a/routers/api/handlers.go +++ b/routers/api/handlers.go @@ -42,8 +42,11 @@ type Handlers struct { FriendLinkApply *services.FriendLinkApplyService } -func (h *Handlers) setAuthCookie(c *gin.Context, token string) { - c.SetCookie(auth.CookieName, token, int(services.TokenExpire.Seconds()), "/", "", false, true) +func (h *Handlers) setAuthCookie(c *gin.Context, sessionID string) { + if sessionID == "" { + return + } + auth.SetSessionCookie(c, sessionID) } func (h *Handlers) currentUserID(c *gin.Context) uint { @@ -126,13 +129,12 @@ func (h *Handlers) APICaptcha(c *gin.Context) { // APIRegisterConfig 注册页所需公开配置 func (h *Handlers) APIRegisterConfig(c *gin.Context) { - userCount := h.Auth.UserCount() mailReady := h.Settings.MailReady() c.JSON(http.StatusOK, gin.H{ - "is_first_user": userCount == 0, + "is_first_user": false, // 已废弃:管理员仅由 /install 创建 "mail_ready": mailReady, "require_email_code": mailReady, - "register_open": userCount == 0 || mailReady, + "register_open": mailReady, "email_code_len": services.EmailCodeLen, }) } @@ -259,7 +261,7 @@ func (h *Handlers) APIRegister(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP()) + token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) h.setAuthCookie(c, token) c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID}) } @@ -273,7 +275,7 @@ func (h *Handlers) APILogin(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) return } - token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP()) + token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return diff --git a/routers/api/seo.go b/routers/api/seo.go index 30dc413..5b96278 100644 --- a/routers/api/seo.go +++ b/routers/api/seo.go @@ -1,30 +1,15 @@ package api import ( - "encoding/json" - "fmt" - "html" "net/http" - "regexp" - "strconv" "strings" "time" - "github.com/gin-gonic/gin" - "git.iioio.com/freefire/jiang13-forum/modules/seo" - "git.iioio.com/freefire/jiang13-forum/models" "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" ) -var ( - seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`) -) - -const ( - seoDescMax = 160 - seoPrerenderMax = 4000 - seoSitemapLimit = 5000 -) +const seoSitemapLimit = 5000 // RobotsTxt 搜索引擎抓取规则 func (h *Handlers) RobotsTxt(c *gin.Context) { @@ -34,14 +19,10 @@ func (h *Handlers) RobotsTxt(c *gin.Context) { b.WriteString("Allow: /\n") b.WriteString("Disallow: /api/\n") b.WriteString("Disallow: /admin\n") - b.WriteString("Disallow: /compose\n") + b.WriteString("Disallow: /install\n") b.WriteString("Disallow: /login\n") - b.WriteString("Disallow: /register\n") - b.WriteString("Disallow: /profile\n") - b.WriteString("Disallow: /favorites\n") + b.WriteString("Disallow: /compose\n") b.WriteString("Disallow: /oauth/\n") - b.WriteString("Disallow: /media/\n") - b.WriteString("Disallow: /*/edit\n") if base != "" { b.WriteString("\nSitemap: ") b.WriteString(base) @@ -50,7 +31,7 @@ func (h *Handlers) RobotsTxt(c *gin.Context) { c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String())) } -// SitemapXML 公开页面站点地图 +// SitemapXML 公开页面站点地图(与 SSR 同源路径) func (h *Handlers) SitemapXML(c *gin.Context) { base := h.publicBaseURL(c) if base == "" { @@ -62,8 +43,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) { permalink := h.Settings.Permalink() urls := []services.SitemapURL{ {Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"}, - {Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"}, - {Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"}, } if boards, err := h.Board.List(); err == nil { @@ -92,38 +71,11 @@ func (h *Handlers) SitemapXML(c *gin.Context) { } } - if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil { - for _, u := range users { - urls = append(urls, services.SitemapURL{ - Loc: base + permalink.UserPath(u.ID), - LastMod: u.UpdatedAt.UTC(), - ChangeFreq: "weekly", - Priority: "0.5", - }) - } - } - - if pages, e3 := h.SitePage.ListSitemap(seoSitemapLimit); e3 == nil { - for _, p := range pages { - lm := p.UpdatedAt - if lm.IsZero() { - lm = p.CreatedAt - } - urls = append(urls, services.SitemapURL{ - Loc: base + permalink.PagePath(p.Slug), - LastMod: lm.UTC(), - ChangeFreq: "monthly", - Priority: "0.5", - }) - } - } - var b strings.Builder b.WriteString(``) b.WriteString(``) for _, u := range urls { - b.WriteString("") - b.WriteString("") + b.WriteString("") b.WriteString(xmlEscape(u.Loc)) b.WriteString("") if !u.LastMod.IsZero() { @@ -147,382 +99,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) { c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String())) } -// ServePublicSPA 公开页入口: -// - 普通用户:干净 SPA + meta(无正文预渲染,避免刷新闪屏) -// - 搜索/社交爬虫:服务端 HTML(动态渲染) -// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301 -func (h *Handlers) ServePublicSPA(c *gin.Context) { - path := c.Request.URL.Path - - brand := h.Settings.SiteBranding() - base := h.publicBaseURL(c) - siteName := strings.TrimSpace(brand.Name) - if siteName == "" { - siteName = "姜十三论坛" - } - defaultImage := services.AbsoluteURL(base, brand.DefaultShareImage()) - siteKeywords := brand.MetaKeywords() - permalink := h.Settings.Permalink() - - // 旧版 /?board=id → 规范板块路径 - if path == "/" || path == "" { - if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 { - target := services.QueryBoardHome(uint(boardID), permalink) - if q := c.Request.URL.RawQuery; q != "" { - // 保留 sort/keyword 等 query,去掉 board - vals := c.Request.URL.Query() - vals.Del("board") - if rest := vals.Encode(); rest != "" { - target += "?" + rest - } - } - c.Redirect(http.StatusMovedPermanently, target) - return - } - } - - isBot := services.IsSEOCrawler(c.Request.UserAgent()) - if isBot { - c.Header("Vary", "User-Agent") - } - - // 板块首页(含可选伪静态后缀) - if bm := permalink.MatchBoardPath(path); bm.OK { - if bm.NeedsCanonicalRedirect(path) { - c.Redirect(http.StatusMovedPermanently, bm.Canonical+preserveQueryExceptBoard(c)) - return - } - board, err := h.Board.GetByID(bm.ID) - if err != nil { - h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) - return - } - desc := strings.TrimSpace(board.Description) - if desc == "" { - desc = brand.MetaDescription() - } - meta := attachSiteSEO(&seo.PageMeta{ - Title: pageTitle(board.Name, siteName), - Description: services.TruncateRunes(desc, seoDescMax), - Keywords: services.JoinSEOKeywords(board.Name, siteKeywords), - Canonical: services.AbsoluteURL(base, bm.Canonical), - OGType: "website", - OGImage: defaultImage, - }, siteName, siteKeywords) - if isBot { - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board))) - return - } - servePendingSSR(c, meta.Title, `

板块页 SSR 迁移中,请先从 首页 浏览。

`) - return - } - - // 帖子详情(含可选伪静态后缀) - if pm := permalink.MatchPostPath(path); pm.OK { - if pm.NeedsCanonicalRedirect(path) { - c.Redirect(http.StatusMovedPermanently, pm.Canonical) - return - } - post, err := h.Post.FindByID(pm.ID) - if err != nil || !services.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) { - h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) - return - } - postKeywords := services.JoinSEOKeywords(post.Board.Name, siteKeywords) - if isBot { - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post))) - return - } - servePendingSSR(c, pageTitle(post.Title, siteName), `

帖子详情 SSR 迁移中,请先从 首页 返回。

`) - return - } - - // 用户主页 - if um := permalink.MatchUserPath(path); um.OK { - if um.NeedsCanonicalRedirect(path) { - c.Redirect(http.StatusMovedPermanently, um.Canonical) - return - } - user, err := h.User.GetByID(um.ID) - if err != nil || user.Banned { - h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) - return - } - if isBot { - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user))) - return - } - servePendingSSR(c, pageTitle(user.Nickname, siteName), `

用户主页 SSR 迁移中,请先从 首页 返回。

`) - return - } - - // 自定义单页 - if pg := permalink.MatchPagePath(path); pg.OK { - if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") { - c.Redirect(http.StatusMovedPermanently, pg.Canonical) - return - } - page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c)) - if err != nil { - h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) - return - } - desc := services.ExcerptFromHTML(page.Content, seoDescMax) - meta := attachSiteSEO(&seo.PageMeta{ - Title: pageTitle(page.Title, siteName), - Description: desc, - Keywords: services.JoinSEOKeywords(page.Title, siteKeywords), - Canonical: services.AbsoluteURL(base, pg.Canonical), - OGType: "article", - OGImage: defaultImage, - }, siteName, siteKeywords) - if isBot { - body := fmt.Sprintf(`

%s

%s
`, html.EscapeString(page.Title), page.Content) - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body))) - return - } - servePendingSSR(c, meta.Title, page.Content) - return - } - - // 未知路径 → 404 - if !isKnownPublicPath(path) { - h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) - return - } - - // 其余已知路由:爬虫可读首页;用户走占位页(首页本身已由 routers/web SSR) - meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage) - if isBot && (path == "/" || path == "") { - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand))) - return - } - servePendingSSR(c, meta.Title, `

该页面 SSR 迁移中。返回首页

`) -} - -func servePendingSSR(c *gin.Context, title, bodyHTML string) { - if strings.TrimSpace(title) == "" { - title = "姜十三论坛" - } - page := fmt.Sprintf(`%s
%s
`, - html.EscapeString(title), bodyHTML) - c.Header("Cache-Control", "no-cache") - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page)) -} - -func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) { - if isBot { - c.Header("Vary", "User-Agent") - c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path))) - return - } - page := fmt.Sprintf(`%s

404

页面不存在。

返回首页

`, - html.EscapeString(pageTitle("页面不存在", siteName))) - c.Header("Cache-Control", "no-cache") - c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(page)) -} - -func notFoundPageMeta(base, siteName, keywords, path string) *seo.PageMeta { - return attachSiteSEO(&seo.PageMeta{ - Title: pageTitle("页面不存在", siteName), - Description: "您访问的页面不存在或已删除", - Canonical: services.AbsoluteURL(base, path), - OGType: "website", - Robots: "noindex,follow", - Status: http.StatusNotFound, - }, siteName, keywords) -} - -// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale -func attachSiteSEO(meta *seo.PageMeta, siteName, keywords string) *seo.PageMeta { - if meta == nil { - return nil - } - meta.SiteName = strings.TrimSpace(siteName) - if strings.TrimSpace(meta.Keywords) == "" { - meta.Keywords = strings.TrimSpace(keywords) - } - meta.Locale = "zh_CN" - return meta -} - -func isKnownPublicPath(path string) bool { - switch path { - case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards": - return true - } - if seoPostEditRe.MatchString(path) { - return true - } - permalink := services.PermalinkConfig{} - if permalink.MatchBoardPath(path).OK { - return true - } - if permalink.MatchPagePath(path).OK { - return true - } - return false -} - -func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand services.SiteBranding, base, siteName, defaultImage string) *seo.PageMeta { - siteTitle := brand.DocumentTitle() - homeDesc := services.TruncateRunes(brand.MetaDescription(), seoDescMax) - siteKeywords := brand.MetaKeywords() - meta := attachSiteSEO(&seo.PageMeta{ - Title: siteTitle, - Description: homeDesc, - Keywords: siteKeywords, - Canonical: services.AbsoluteURL(base, pathWithQuery(c)), - OGType: "website", - OGImage: defaultImage, - }, siteName, siteKeywords) - - if isNoIndexPath(path) { - meta.Robots = "noindex,nofollow" - meta.Title = pageTitle(pathLabel(path), siteName) - return meta - } - - if path == "/" || path == "" { - boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64) - if boardID > 0 { - if board, err := h.Board.GetByID(uint(boardID)); err == nil { - desc := strings.TrimSpace(board.Description) - if desc == "" { - desc = brand.MetaDescription() - } - meta.Title = pageTitle(board.Name, siteName) - meta.Description = services.TruncateRunes(desc, seoDescMax) - meta.Canonical = services.AbsoluteURL(base, services.QueryBoardHome(board.ID, h.Settings.Permalink())) - meta.Keywords = services.JoinSEOKeywords(board.Name, siteKeywords) - return meta - } - // 无效板块 id:仍显示首页,但可标记 noindex - meta.Robots = "noindex,follow" - return meta - } - meta.JSONLD = mustJSON(map[string]any{ - "@context": "https://schema.org", - "@type": "WebSite", - "name": siteName, - "description": meta.Description, - "url": services.AbsoluteURL(base, "/"), - }) - } - - if path == "/projects" { - meta.Title = pageTitle("项目", siteName) - meta.Description = services.TruncateRunes(siteName+" 的公开项目列表", seoDescMax) - meta.Keywords = services.JoinSEOKeywords("项目", siteKeywords) - } - - if path == "/links" { - meta.Title = pageTitle("友情链接", siteName) - meta.Description = services.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax) - meta.Keywords = services.JoinSEOKeywords("友情链接", siteKeywords) - } - - return meta -} - -func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *models.Post) *seo.PageMeta { - permalink := h.Settings.Permalink() - content := services.RedactGatedPostHTML(post.Content) - plain := post.ContentPlain - if plain == "" { - plain = services.StripHTMLForSearch(content) - } - desc := services.TruncateRunes(plain, seoDescMax) - author := services.DisplayName(&post.User) - canonical := services.AbsoluteURL(base, permalink.PostPath(post.ID)) - ogImage := services.AbsoluteURL(base, services.FirstImageURL(content)) - if ogImage == "" { - ogImage = services.AbsoluteURL(base, post.User.Avatar) - } - if ogImage == "" { - ogImage = defaultImage - } - - jsonld := map[string]any{ - "@context": "https://schema.org", - "@type": "DiscussionForumPosting", - "headline": post.Title, - "description": desc, - "datePublished": post.CreatedAt.UTC().Format(time.RFC3339), - "dateModified": post.UpdatedAt.UTC().Format(time.RFC3339), - "url": canonical, - "mainEntityOfPage": canonical, - "author": map[string]any{ - "@type": "Person", - "name": author, - "url": services.AbsoluteURL(base, permalink.UserPath(post.UserID)), - }, - "interactionStatistic": map[string]any{ - "@type": "InteractionCounter", - "interactionType": "https://schema.org/ViewAction", - "userInteractionCount": post.ViewCount, - }, - } - if post.Board.Name != "" { - jsonld["articleSection"] = post.Board.Name - } - if ogImage != "" { - jsonld["image"] = []string{ogImage} - } - body := services.TruncateRunes(plain, seoPrerenderMax) - if body != "" { - jsonld["articleBody"] = body - } - - return &seo.PageMeta{ - Title: pageTitle(post.Title, siteName), - Description: desc, - Canonical: canonical, - OGType: "article", - OGImage: ogImage, - JSONLD: mustJSON(jsonld), - } -} - -func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *models.User) *seo.PageMeta { - permalink := h.Settings.Permalink() - name := services.DisplayName(user) - desc := strings.TrimSpace(user.Signature) - if desc == "" { - desc = name + " 的主页" - } - desc = services.TruncateRunes(desc, seoDescMax) - canonical := services.AbsoluteURL(base, permalink.UserPath(user.ID)) - ogImage := services.AbsoluteURL(base, user.Avatar) - if ogImage == "" { - ogImage = defaultImage - } - - jsonld := map[string]any{ - "@context": "https://schema.org", - "@type": "ProfilePage", - "url": canonical, - "mainEntity": map[string]any{ - "@type": "Person", - "name": name, - "description": desc, - "url": canonical, - }, - } - if ogImage != "" { - jsonld["mainEntity"].(map[string]any)["image"] = ogImage - } - - return &seo.PageMeta{ - Title: pageTitle(name+" 的主页", siteName), - Description: desc, - Canonical: canonical, - OGType: "profile", - OGImage: ogImage, - JSONLD: mustJSON(jsonld), - } -} - func (h *Handlers) publicBaseURL(c *gin.Context) string { return h.Settings.SitePublicBaseURL(requestOrigin(c)) } @@ -546,105 +122,11 @@ func requestOrigin(c *gin.Context) string { return proto + "://" + host } -func pathWithQuery(c *gin.Context) string { - path := c.Request.URL.Path - if path == "" { - path = "/" - } - permalink := services.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board - if q := c.Request.URL.RawQuery; q != "" { - if path == "/" { - board := c.Query("board") - if board != "" { - _ = permalink - return services.LegacyQueryBoardHome(uint(parseUintOrZero(board))) - } - return "/" - } - return path + "?" + q - } - return path -} - -func preserveQueryExceptBoard(c *gin.Context) string { - vals := c.Request.URL.Query() - vals.Del("board") - if rest := vals.Encode(); rest != "" { - return "?" + rest - } - return "" -} - -func parseUintOrZero(s string) uint64 { - n, _ := strconv.ParseUint(s, 10, 64) - return n -} - -func isNoIndexPath(path string) bool { - switch { - case path == "/login", path == "/register", path == "/compose", - path == "/profile", path == "/favorites": - return true - case strings.HasPrefix(path, "/admin"): - return true - case strings.HasSuffix(path, "/edit"): - return true - default: - return false - } -} - -func pathLabel(path string) string { - switch { - case path == "/login": - return "登录" - case path == "/register": - return "注册" - case path == "/compose": - return "发帖" - case path == "/profile": - return "个人中心" - case path == "/favorites": - return "我的收藏" - case strings.HasSuffix(path, "/edit"): - return "编辑帖子" - case strings.HasPrefix(path, "/admin"): - return "管理后台" - default: - return "" - } -} - -func pageTitle(page, siteName string) string { - page = strings.TrimSpace(page) - siteName = strings.TrimSpace(siteName) - switch { - case page == "" && siteName == "": - return "姜十三论坛" - case page == "": - return siteName - case siteName == "": - return page - default: - return page + " - " + siteName - } -} - -func mustJSON(v any) string { - b, err := json.Marshal(v) - if err != nil { - return "" - } - return string(b) -} - func xmlEscape(s string) string { - r := strings.NewReplacer( - `&`, "&", - `<`, "<", - `>`, ">", - `"`, """, - `'`, "'", - ) - return r.Replace(s) + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s } diff --git a/routers/api/seo_bot.go b/routers/api/seo_bot.go deleted file mode 100644 index 60c0626..0000000 --- a/routers/api/seo_bot.go +++ /dev/null @@ -1,158 +0,0 @@ -package api - -import ( - "fmt" - "html" - "strings" - "time" - - "git.iioio.com/freefire/jiang13-forum/modules/seo" - "git.iioio.com/freefire/jiang13-forum/models" - "git.iioio.com/freefire/jiang13-forum/services" -) - -// 爬虫专用伪静态 HTML(无 SPA;仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏) - -func renderBotHTML(meta *seo.PageMeta, bodyInner string) string { - if meta == nil { - meta = &seo.PageMeta{} - } - ogType := strings.TrimSpace(meta.OGType) - if ogType == "" { - ogType = "website" - } - locale := strings.TrimSpace(meta.Locale) - if locale == "" { - locale = "zh_CN" - } - var b strings.Builder - b.WriteString("") - b.WriteString("") - b.WriteString("") - writeEscapedTag(&b, "title", meta.Title) - writeEscapedMeta(&b, "name", "description", meta.Description) - writeEscapedMeta(&b, "name", "keywords", meta.Keywords) - if meta.Robots != "" { - writeEscapedMeta(&b, "name", "robots", meta.Robots) - } - if meta.Canonical != "" { - b.WriteString(``) - } - writeEscapedMeta(&b, "property", "og:type", ogType) - writeEscapedMeta(&b, "property", "og:site_name", meta.SiteName) - writeEscapedMeta(&b, "property", "og:locale", locale) - writeEscapedMeta(&b, "property", "og:title", meta.Title) - writeEscapedMeta(&b, "property", "og:description", meta.Description) - writeEscapedMeta(&b, "property", "og:url", meta.Canonical) - writeEscapedMeta(&b, "property", "og:image", meta.OGImage) - card := "summary" - if strings.TrimSpace(meta.OGImage) != "" { - card = "summary_large_image" - } - writeEscapedMeta(&b, "name", "twitter:card", card) - writeEscapedMeta(&b, "name", "twitter:title", meta.Title) - writeEscapedMeta(&b, "name", "twitter:description", meta.Description) - writeEscapedMeta(&b, "name", "twitter:image", meta.OGImage) - if meta.JSONLD != "" { - b.WriteString(``) - } - b.WriteString(``) - b.WriteString("") - b.WriteString(bodyInner) - b.WriteString(``) - b.WriteString("") - return b.String() -} - -func writeEscapedTag(b *strings.Builder, tag, text string) { - text = strings.TrimSpace(text) - if text == "" { - return - } - b.WriteString("<" + tag + ">" + html.EscapeString(text) + "") -} - -func writeEscapedMeta(b *strings.Builder, attr, key, content string) { - content = strings.TrimSpace(content) - if content == "" { - return - } - b.WriteString("") -} - -func (h *Handlers) botBoardHTML(meta *seo.PageMeta, board models.Board) string { - desc := strings.TrimSpace(board.Description) - if desc == "" { - desc = meta.Description - } - body := fmt.Sprintf(`

%s

%s

`, - html.EscapeString(board.Name), - html.EscapeString(desc), - ) - return renderBotHTML(meta, body) -} - -func (h *Handlers) botHomeHTML(meta *seo.PageMeta, brand services.SiteBranding) string { - name := strings.TrimSpace(brand.Name) - if name == "" { - name = "姜十三论坛" - } - intro := brand.MetaDescription() - if intro == "" { - intro = brand.Slogan - } - var body strings.Builder - body.WriteString("

" + html.EscapeString(name) + "

") - if intro != "" { - body.WriteString("

" + html.EscapeString(intro) + "

") - } - body.WriteString(`

浏览项目

`) - return renderBotHTML(meta, body.String()) -} - -func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *models.Post) string { - meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords) - content := services.SanitizePostHTML(services.RedactGatedPostHTML(post.Content)) - author := services.DisplayName(&post.User) - var body strings.Builder - body.WriteString("
") - body.WriteString("

" + html.EscapeString(post.Title) + "

") - body.WriteString(`

`) - body.WriteString(html.EscapeString(author)) - body.WriteString(" · ") - body.WriteString(html.EscapeString(post.CreatedAt.Local().Format("2006-01-02 15:04"))) - if post.Board.Name != "" { - body.WriteString(" · ") - body.WriteString(html.EscapeString(post.Board.Name)) - } - body.WriteString("

") - body.WriteString(content) - body.WriteString("
") - return renderBotHTML(meta, body.String()) -} - -func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *models.User) string { - meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords) - name := services.DisplayName(user) - sig := strings.TrimSpace(user.Signature) - var body strings.Builder - body.WriteString("

" + html.EscapeString(name) + " 的主页

") - if sig != "" { - body.WriteString("

" + html.EscapeString(sig) + "

") - } - body.WriteString(fmt.Sprintf(`

加入于 %s

`, html.EscapeString(user.CreatedAt.Local().Format(time.DateOnly)))) - return renderBotHTML(meta, body.String()) -} - -func botNotFoundHTML(base, siteName, keywords, path string) string { - meta := notFoundPageMeta(base, siteName, keywords, path) - body := `

页面不存在

您访问的页面不存在或已删除。

` - return renderBotHTML(meta, body) -} diff --git a/routers/install/install.go b/routers/install/install.go new file mode 100644 index 0000000..19c065d --- /dev/null +++ b/routers/install/install.go @@ -0,0 +1,124 @@ +package install + +import ( + "net/http" + "strings" + + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +// Deps 安装依赖 +type Deps struct { + DataDir string + JWTSecret string + Auth *services.AuthService + Settings *services.ForumSettingsService +} + +type pageData struct { + Title string + Description string + SiteName string + Slogan string + LogoMark string + CSRF string + Error string + Flash string + AdminUsername string + AdminEmail string + AdminNickname string +} + +// Register 未安装时的路由(仅 /install) +func Register(r *gin.Engine, deps Deps) { + r.GET("/install", deps.Get) + r.POST("/install", deps.Post) +} + +// Get 安装页 +func (d Deps) Get(c *gin.Context) { + if services.IsInstalled(d.DataDir) { + c.Redirect(http.StatusFound, "/") + return + } + ctx := webctx.New(c, d.JWTSecret) + brand := d.Settings.SiteBranding() + name := brand.Name + if name == "" { + name = "姜十三论坛" + } + ctx.HTML(http.StatusOK, "install", pageData{ + Title: "安装 · " + name, SiteName: name, LogoMark: "姜", + CSRF: ctx.EnsureCSRF(), AdminUsername: "admin", + }) +} + +// Post 提交安装 +func (d Deps) Post(c *gin.Context) { + if services.IsInstalled(d.DataDir) { + c.Redirect(http.StatusSeeOther, "/") + return + } + ctx := webctx.New(c, d.JWTSecret) + brand := d.Settings.SiteBranding() + siteName := strings.TrimSpace(c.PostForm("site_name")) + if siteName == "" { + siteName = "姜十三论坛" + } + data := pageData{ + Title: "安装 · " + siteName, SiteName: siteName, LogoMark: "姜", + CSRF: ctx.EnsureCSRF(), + AdminUsername: strings.TrimSpace(c.PostForm("admin_username")), + AdminEmail: strings.TrimSpace(c.PostForm("admin_email")), + AdminNickname: strings.TrimSpace(c.PostForm("admin_nickname")), + } + if !ctx.CheckCSRF() { + data.Error = "无效请求,请重试" + ctx.HTML(http.StatusBadRequest, "install", data) + return + } + pass := c.PostForm("admin_password") + pass2 := c.PostForm("admin_password2") + if pass != pass2 { + data.Error = "两次密码不一致" + ctx.HTML(http.StatusBadRequest, "install", data) + return + } + if _, err := d.Auth.CreateAdmin(data.AdminUsername, pass, data.AdminNickname, data.AdminEmail); err != nil { + data.Error = err.Error() + ctx.HTML(http.StatusBadRequest, "install", data) + return + } + b := d.Settings.SiteBranding() + b.Name = siteName + _ = d.Settings.UpdateSiteBranding(b) + if err := services.WriteInstallLock(d.DataDir); err != nil { + data.Error = "写入安装锁失败: " + err.Error() + ctx.HTML(http.StatusInternalServerError, "install", data) + return + } + ctx.HTML(http.StatusOK, "post-install", pageData{ + Title: "安装完成", SiteName: siteName, LogoMark: "姜", + }) + _ = brand +} + +// Guard 未安装则只允许 install / assets / health +func Guard(dataDir string) gin.HandlerFunc { + return func(c *gin.Context) { + if services.IsInstalled(dataDir) { + c.Next() + return + } + path := c.Request.URL.Path + if path == "/install" || path == "/health" || + strings.HasPrefix(path, "/ssr-assets/") { + c.Next() + return + } + c.Redirect(http.StatusFound, "/install") + c.Abort() + } +} diff --git a/routers/setup.go b/routers/setup.go index ad0f991..57702ff 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -11,6 +11,7 @@ import ( "git.iioio.com/freefire/jiang13-forum/modules/auth" webpublic "git.iioio.com/freefire/jiang13-forum/public" "git.iioio.com/freefire/jiang13-forum/routers/api" + "git.iioio.com/freefire/jiang13-forum/routers/install" webpages "git.iioio.com/freefire/jiang13-forum/routers/web" "git.iioio.com/freefire/jiang13-forum/services" "github.com/gin-gonic/gin" @@ -22,7 +23,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.Use(gin.Recovery()) r.Use(gin.Logger()) - // SSR 静态资源 + if err := services.EnsureInstallLockFromExistingData(cfg.DataDir); err != nil { + fmt.Fprintf(os.Stderr, "警告: 安装锁检查失败: %v\n", err) + } + if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil { ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub))) r.GET("/ssr-assets/*filepath", func(c *gin.Context) { @@ -30,15 +34,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { ssrFiles.ServeHTTP(c.Writer, c.Request) }) } - if cfg.DevMode { - fmt.Fprintf(os.Stderr, "[dev] SSR 请访问 http://localhost:%d (对照 SPA 请 checkout main)\n", cfg.Port) - } + + r.Use(install.Guard(cfg.DataDir)) filter := services.NewSensitiveFilter() - _ = services.WriteDefaultFilterWords(cfg.FilterWordsPath()) - filter.LoadFromFile(cfg.FilterWordsPath()) - settingsSvc := services.NewForumSettingsService() + services.EnsureFilterWordsInSettings(settingsSvc, cfg.FilterWordsPath(), filter) + authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc) userSvc := services.NewUserService(filter, settingsSvc) boardSvc := services.NewBoardService() @@ -58,16 +60,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { if err != nil { return nil, err } + // Gitea 仓库同步后置:本阶段不启动后台同步,亦不挂管理入口 giteaSvc := services.NewGiteaService(settingsSvc) - giteaSvc.StartBackgroundSync() uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc) if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil { - // 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换 fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err) _ = uploadStore.Apply(services.StorageConfig{Type: "local"}) } - // 后台同步存量文件到媒体索引,避免列表依赖实时扫盘 go func() { if n, err := uploadStore.SyncMediaIndex(); err != nil { fmt.Fprintf(os.Stderr, "警告: 媒体索引同步失败: %v\n", err) @@ -89,25 +89,26 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { } authMW := auth.NewAuthMiddleware(authSvc) - // Gitea 式 SSR 公开页(优先于 SPA) + install.Register(r, install.Deps{ + DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret, + Auth: authSvc, Settings: settingsSvc, + }) + webpages.Register(r, webpages.Deps{ - Settings: settingsSvc, - Board: boardSvc, - Post: postSvc, + DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret, + Settings: settingsSvc, Auth: authSvc, + Board: boardSvc, Post: postSvc, Comment: commentSvc, + Message: messageSvc, Filter: filter, + Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, }, authMW) - // 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突 r.GET("/media/thumb/*filepath", h.ServeImageThumb) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) - - // 健康检查(容器 / 负载均衡探活) r.GET("/health", h.APIHealth) - - // SEO:抓取规则与站点地图 r.GET("/robots.txt", h.RobotsTxt) r.GET("/sitemap.xml", h.SitemapXML) - // OIDC Provider(Gitea 等外部站点 SSO) + // OIDC Provider(外部机器 / Gitea SSO) r.GET("/.well-known/openid-configuration", h.OIDCDiscovery) r.GET("/oauth/jwks", h.OIDCJWKS) r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize) @@ -117,179 +118,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.GET("/oauth/logout", h.OIDCLogout) r.POST("/oauth/logout", h.OIDCLogout) - // 公开 JSON API(可选登录) - pubAPI := r.Group("/api", authMW.OptionalAuth()) - { - pubAPI.GET("/me", h.APIMe) - pubAPI.GET("/boards", h.APIBoards) - pubAPI.GET("/stats", h.APIStats) - pubAPI.GET("/forum-limits", h.APIForumLimits) - pubAPI.GET("/site-branding", h.APISiteBranding) - pubAPI.GET("/pages", h.APIPages) - pubAPI.GET("/pages/:slug", h.APIPageDetail) - pubAPI.GET("/captcha", h.APICaptcha) - pubAPI.GET("/register/config", h.APIRegisterConfig) - pubAPI.POST("/register/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode) - pubAPI.POST("/password-reset/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode) - pubAPI.POST("/password-reset", auth.RateLimitMiddleware(limiter, "login"), h.APIResetPassword) - pubAPI.GET("/posts", h.APIPosts) - pubAPI.GET("/posts/hot", h.APIHotPosts) - pubAPI.GET("/tags", h.APITags) - pubAPI.GET("/comments/recent", h.APIRecentComments) - // search / recent 须在 :id 之前 - pubAPI.GET("/users/search", h.APISearchUsers) - pubAPI.GET("/users/recent", h.APIRecentUsers) - pubAPI.GET("/users/:id", h.APIUserPublic) - pubAPI.GET("/posts/:id", h.APIPostDetail) - pubAPI.GET("/posts/:id/comments", h.APIPostComments) - pubAPI.POST("/posts/:id/comments", auth.RateLimitMiddleware(limiter, "comment"), h.APICreateComment) - pubAPI.GET("/projects", h.APIProjects) - pubAPI.POST("/register", auth.RateLimitMiddleware(limiter, "register"), h.APIRegister) - pubAPI.POST("/login", auth.RateLimitMiddleware(limiter, "login"), h.APILogin) - } - - // 需登录 API - api := r.Group("/api", authMW.RequireAuth()) - { - api.POST("/logout", h.APILogout) - api.GET("/favorites", h.APIFavorites) - api.GET("/profile/stats", h.APIProfileStats) - api.POST("/profile/nickname", h.APIUpdateProfile) - api.POST("/profile/signature", h.APIUpdateSignature) - api.POST("/profile/password", h.APIUpdatePassword) - api.POST("/profile/avatar", h.APIUploadAvatar) - api.POST("/uploads/image", h.APIUploadPostImage) - api.POST("/posts", auth.RateLimitMiddleware(limiter, "post"), h.APICreatePost) - api.PUT("/posts/:id", h.APIUpdatePost) - api.DELETE("/posts/:id", h.APIDeletePost) - api.GET("/posts/:id/revisions", h.APIPostRevisions) - api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail) - api.POST("/posts/:id/like", h.APIToggleLike) - api.POST("/posts/:id/favorite", h.APIToggleFavorite) - api.POST("/posts/:id/resolve", h.APISetQuestionResolved) - api.POST("/posts/:id/poll/vote", h.APIPollVote) - api.POST("/posts/:id/poll/close", h.APIPollClose) - api.POST("/posts/:id/bounty/award", h.APIBountyAward) - api.POST("/posts/:id/bounty/refund", h.APIBountyRefund) - api.POST("/posts/:id/lottery/draw", h.APILotteryDraw) - api.POST("/posts/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport) - api.GET("/messages/unread-count", h.APIMessageUnreadCount) - api.GET("/messages/notifications", h.APIMessageNotifications) - api.POST("/messages/notifications/read", h.APIMarkNotificationsRead) - api.GET("/messages/conversations", h.APIMessageConversations) - api.GET("/messages/conversations/:peerId", h.APIConversationMessages) - api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead) - api.POST("/messages", auth.RateLimitMiddleware(limiter, "message"), h.APISendMessage) - api.POST("/messages/read-all", h.APIMarkAllMessagesRead) - api.POST("/comments/:id/like", h.APIToggleCommentLike) - api.POST("/comments/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport) - api.DELETE("/comments/:id", h.APIDeleteComment) - api.PUT("/comments/:id", h.APIUpdateComment) - api.GET("/me/points", h.APIMePoints) - api.GET("/me/check-in", h.APIMeCheckInGet) - api.POST("/me/check-in", h.APIMeCheckIn) - api.GET("/me/lottery", h.APIMeLotteryGet) - api.POST("/me/lottery", h.APIMeLotteryDraw) - api.POST("/posts/:id/unlock", auth.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock) - api.POST("/friend-links/apply", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink) - api.POST("/friend-links/logo", auth.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo) - api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies) - api.PUT("/friend-links/applies/:id", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply) - api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply) - } - - // 管理员 API(React SPA 后台统一使用 JSON) - adminAPI := r.Group("/api/admin", authMW.RequireAuth(), authMW.RequireAdmin()) - { - adminAPI.GET("/dashboard", h.APIAdminDashboard) - adminAPI.GET("/settings", h.APIAdminSettings) - adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings) - adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings) - adminAPI.POST("/settings/mail/test", h.APIAdminTestMail) - adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings) - adminAPI.PUT("/settings/gitea", h.APIAdminUpdateGiteaSettings) - adminAPI.POST("/settings/gitea/sync", h.APIAdminSyncGitea) - adminAPI.PUT("/settings/storage", h.APIAdminUpdateStorageSettings) - adminAPI.PUT("/settings/branding", h.APIAdminUpdateBranding) - adminAPI.POST("/settings/branding/upload", h.APIAdminUploadBrandingAsset) - adminAPI.POST("/settings/branding/clear", h.APIAdminClearBrandingAsset) - adminAPI.GET("/oauth/clients", h.APIAdminListOAuthClients) - adminAPI.POST("/oauth/clients", h.APIAdminCreateOAuthClient) - adminAPI.PUT("/oauth/clients/:id", h.APIAdminUpdateOAuthClient) - adminAPI.DELETE("/oauth/clients/:id", h.APIAdminDeleteOAuthClient) - adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords) - adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords) - adminAPI.POST("/boards", h.APIAdminCreateBoard) - adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard) - adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard) - adminAPI.GET("/pages", h.APIAdminPages) - adminAPI.GET("/pages/:id", h.APIAdminGetPage) - adminAPI.POST("/pages", h.APIAdminCreatePage) - adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage) - adminAPI.PUT("/pages/:id/published", h.APIAdminSetPagePublished) - adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage) - adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies) - adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings) - adminAPI.POST("/friend-link-applies/:id/approve", h.APIAdminApproveFriendLinkApply) - adminAPI.POST("/friend-link-applies/:id/reject", h.APIAdminRejectFriendLinkApply) - adminAPI.POST("/friend-link-applies/:id/recheck", h.APIAdminRecheckFriendLinkApply) - adminAPI.GET("/posts", h.APIAdminPosts) - adminAPI.GET("/posts/trash", h.APIAdminTrashPosts) - adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost) - adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost) - adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost) - adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost) - adminAPI.POST("/posts/:id/comments-lock", h.APIAdminCommentsLockPost) - adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost) - adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost) - adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost) - adminAPI.DELETE("/posts/:id/purge", h.APIAdminPurgePost) - adminAPI.DELETE("/posts/:id", h.APIAdminDeletePost) - adminAPI.GET("/reports", h.APIAdminReports) - adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport) - adminAPI.GET("/comments", h.APIAdminComments) - adminAPI.GET("/comments/trash", h.APIAdminTrashComments) - adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions) - adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment) - adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment) - adminAPI.POST("/comments/:id/restore", h.APIAdminRestoreComment) - adminAPI.DELETE("/comments/:id/purge", h.APIAdminPurgeComment) - adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment) - adminAPI.GET("/users", h.APIAdminUsers) - adminAPI.POST("/users/:id/ban", h.APIAdminBanUser) - adminAPI.POST("/users/:id/verify", h.APIAdminVerifyUser) - adminAPI.POST("/users/:id/level", h.APIAdminSetUserLevel) - adminAPI.POST("/users/:id/points", h.APIAdminAdjustPoints) - adminAPI.POST("/users/:id/badges", h.APIAdminAwardBadge) - adminAPI.GET("/badges", h.APIAdminListBadges) - adminAPI.POST("/badges", h.APIAdminUpsertBadge) - adminAPI.GET("/media", h.APIAdminMedia) - adminAPI.POST("/media/delete", h.APIAdminDeleteMedia) - adminAPI.POST("/backup", h.APIAdminBackup) - adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup) - } - - // 管理后台 HTML:SSR 尚未迁移;勿用 /*filepath(与 /admin/login 冲突) - adminPendingHTML := `管理后台

管理后台 SSR 迁移中

API 仍可用;UI 请暂时对照 main 分支 SPA,或等待后续模板页。

返回首页

` - adminPending := func(c *gin.Context) { - c.Header("Content-Type", "text/html; charset=utf-8") - c.String(http.StatusOK, adminPendingHTML) - } - admin := r.Group("/admin") - { - admin.GET("/login", func(c *gin.Context) { - c.Redirect(http.StatusFound, "/login") - }) - adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin()) - { - adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") }) - adminAuth.GET("/dashboard", adminPending) - adminAuth.GET("/:page", adminPending) - } - } - - // 未迁移公开路径:爬虫可读 HTML / 用户占位(首页与板块已由 routers/web 接管) - r.NoRoute(h.ServePublicSPA) + // 精简机器 API:健康检查已注册;保留只读探测与 OIDC,论坛 UI 不再走 /api + r.NoRoute(webpages.Deps{ + DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret, + Settings: settingsSvc, Auth: authSvc, + Board: boardSvc, Post: postSvc, Comment: commentSvc, + }.NotFound) return r, nil } diff --git a/routers/web/admin.go b/routers/web/admin.go new file mode 100644 index 0000000..5ea7b95 --- /dev/null +++ b/routers/web/admin.go @@ -0,0 +1,500 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "git.iioio.com/freefire/jiang13-forum/models" + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +// AdminChrome 后台布局公共字段 +type AdminChrome struct { + PageChrome + NavActive string +} + +func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome { + site := d.Settings.SiteBranding().Name + if title == "" { + title = "管理后台 · " + site + } else { + title = title + " · " + site + } + base := d.chrome(ctx, title, "", "") + return AdminChrome{PageChrome: base, NavActive: nav} +} + +type adminDashData struct { + AdminChrome + UserCount int64 + PostCount int64 + PendingPosts int64 + PendingComments int64 + BoardCount int64 +} + +// AdminDashboard 概览 +func (d Deps) AdminDashboard(c *gin.Context) { + ctx := d.ctx(c) + var users, posts, boards int64 + _ = models.DB.Model(&models.User{}).Count(&users).Error + _ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error + _ = models.DB.Model(&models.Board{}).Count(&boards).Error + pendingPosts, _ := d.Post.PendingPostCount() + pendingComments, _ := d.Comment.PendingCommentCount() + ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{ + AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"), + UserCount: users, + PostCount: posts, + PendingPosts: pendingPosts, + PendingComments: pendingComments, + BoardCount: boards, + }) +} + +type adminBoardRow struct { + ID uint + Name string + Description string + Icon string + ColorIndex int + SortOrder int + PostCount int +} + +type adminBoardsData struct { + AdminChrome + Boards []adminBoardRow + Form adminBoardForm +} + +type adminBoardForm struct { + ID uint + Name string + Description string + Icon string + ColorIndex int + SortOrder int +} + +// AdminBoardsGet 板块列表 +func (d Deps) AdminBoardsGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderAdminBoards(ctx, "", adminBoardForm{}) +} + +func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) { + list, _ := d.Board.ListWithStats() + rows := make([]adminBoardRow, 0, len(list)) + for _, b := range list { + rows = append(rows, adminBoardRow{ + ID: b.ID, Name: b.Name, Description: b.Description, + Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder, + PostCount: b.PostCount, + }) + } + data := adminBoardsData{ + AdminChrome: d.adminChrome(ctx, "板块", "boards"), + Boards: rows, + Form: form, + } + data.Error = errMsg + ctx.HTML(http.StatusOK, "admin/boards", data) +} + +// AdminBoardCreate 新建板块 +func (d Deps) AdminBoardCreate(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c)) + return + } + form := adminBoardFormFrom(c) + if strings.TrimSpace(form.Name) == "" { + d.renderAdminBoards(ctx, "请填写板块名称", form) + return + } + if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil { + d.renderAdminBoards(ctx, err.Error(), form) + return + } + ctx.SetFlash("板块已创建") + ctx.Redirect("/admin/boards") +} + +// AdminBoardUpdate 更新板块 +func (d Deps) AdminBoardUpdate(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c)) + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + form := adminBoardFormFrom(c) + form.ID = uint(id) + if strings.TrimSpace(form.Name) == "" { + d.renderAdminBoards(ctx, "请填写板块名称", form) + return + } + if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil { + d.renderAdminBoards(ctx, err.Error(), form) + return + } + ctx.SetFlash("板块已更新") + ctx.Redirect("/admin/boards") +} + +// AdminBoardDelete 删除板块 +func (d Deps) AdminBoardDelete(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/boards") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.Board.Delete(uint(id)); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/boards") + return + } + ctx.SetFlash("板块已删除") + ctx.Redirect("/admin/boards") +} + +func adminBoardFormFrom(c *gin.Context) adminBoardForm { + color, _ := strconv.Atoi(c.PostForm("color_index")) + sort, _ := strconv.Atoi(c.PostForm("sort_order")) + return adminBoardForm{ + Name: strings.TrimSpace(c.PostForm("name")), + Description: strings.TrimSpace(c.PostForm("description")), + Icon: strings.TrimSpace(c.PostForm("icon")), + ColorIndex: color, + SortOrder: sort, + } +} + +type adminModPostRow struct { + ID uint + Title string + AuthorName string + BoardName string + CreatedAt string +} + +type adminModCommentRow struct { + ID uint + PostID uint + PostTitle string + Floor int + AuthorName string + Excerpt string + CreatedAt string +} + +type adminModData struct { + AdminChrome + Posts []adminModPostRow + Comments []adminModCommentRow +} + +// AdminModerationGet 待审帖/评 +func (d Deps) AdminModerationGet(c *gin.Context) { + ctx := d.ctx(c) + posts, _, _ := d.Post.List(services.PostListQuery{ + Page: 1, Size: 50, + ViewerIsAdmin: true, + Status: models.ContentStatusPending, + Sort: "latest", + }) + postRows := make([]adminModPostRow, 0, len(posts)) + for _, p := range posts { + author := "" + if p.User.ID > 0 { + author = p.User.Nickname + if author == "" { + author = p.User.Username + } + } + board := "" + if p.Board.ID > 0 { + board = p.Board.Name + } + postRows = append(postRows, adminModPostRow{ + ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board, + CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"), + }) + } + comments, _, _ := d.Comment.ListPending(1, 50) + commentRows := make([]adminModCommentRow, 0, len(comments)) + for _, cm := range comments { + author := "游客" + if cm.UserID > 0 { + author = cm.User.Nickname + if author == "" { + author = cm.User.Username + } + } else if cm.GuestNick != "" { + author = cm.GuestNick + } + excerpt := strings.TrimSpace(stripTagsRough(cm.Content)) + runes := []rune(excerpt) + if len(runes) > 80 { + excerpt = string(runes[:80]) + "…" + } + title := "" + if cm.Post.ID > 0 { + title = cm.Post.Title + } + commentRows = append(commentRows, adminModCommentRow{ + ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor, + AuthorName: author, Excerpt: excerpt, + CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"), + }) + } + ctx.HTML(http.StatusOK, "admin/moderation", adminModData{ + AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"), + Posts: postRows, + Comments: commentRows, + }) +} + +func stripTagsRough(s string) string { + var b strings.Builder + inTag := false + for _, r := range s { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + case !inTag: + b.WriteRune(r) + } + } + return b.String() +} + +// AdminPostApprove 通过帖子 +func (d Deps) AdminPostApprove(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/moderation") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil { + ctx.SetFlash(err.Error()) + } else { + ctx.SetFlash("帖子已通过") + } + ctx.Redirect("/admin/moderation") +} + +// AdminPostReject 拒绝帖子 +func (d Deps) AdminPostReject(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/moderation") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + reason := strings.TrimSpace(c.PostForm("reason")) + if reason == "" { + ctx.SetFlash("请填写拒绝原因") + ctx.Redirect("/admin/moderation") + return + } + post, err := d.Post.FindByID(uint(id)) + if err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/moderation") + return + } + if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/moderation") + return + } + pid := post.ID + if d.Message != nil { + _, _ = d.Message.SendSystem( + post.UserID, + "帖子《"+post.Title+"》未通过审核", + services.FormatRejectContent(post.Title, post.ID, reason), + models.MessageKindReject, + &pid, + nil, + ) + } + ctx.SetFlash("已拒绝该帖") + ctx.Redirect("/admin/moderation") +} + +// AdminCommentApprove 通过评论 +func (d Deps) AdminCommentApprove(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/moderation") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil { + ctx.SetFlash(err.Error()) + } else { + ctx.SetFlash("评论已通过") + } + ctx.Redirect("/admin/moderation") +} + +// AdminCommentReject 拒绝评论 +func (d Deps) AdminCommentReject(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/moderation") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + reason := strings.TrimSpace(c.PostForm("reason")) + if reason == "" { + ctx.SetFlash("请填写拒绝原因") + ctx.Redirect("/admin/moderation") + return + } + cm, err := d.Comment.GetByID(uint(id)) + if err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/moderation") + return + } + if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/moderation") + return + } + if d.Message != nil && cm.UserID > 0 { + pid := cm.PostID + title := cm.Post.Title + _, _ = d.Message.SendSystem( + cm.UserID, + fmt.Sprintf("评论未通过审核(帖 #%d)", cm.PostID), + services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason), + models.MessageKindReject, + &pid, + nil, + ) + } + ctx.SetFlash("已拒绝该评论") + ctx.Redirect("/admin/moderation") +} + +type adminSettingsData struct { + AdminChrome + Brand services.SiteBranding + RatePost int + RateComment int + RateReg int + RateLogin int + RateWindow int + FilterWords string + FilterCount int +} + +// AdminSettingsGet 设置页 +func (d Deps) AdminSettingsGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderAdminSettings(ctx, "") +} + +func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) { + words := d.Settings.FilterWordsContent() + lim := d.Settings.Limits() + data := adminSettingsData{ + AdminChrome: d.adminChrome(ctx, "站点设置", "settings"), + Brand: d.Settings.SiteBranding(), + RatePost: lim.RateLimitPost, + RateComment: lim.RateLimitComment, + RateReg: lim.RateLimitRegister, + RateLogin: lim.RateLimitLogin, + RateWindow: lim.RateLimitWindowSec, + FilterWords: words, + FilterCount: services.CountFilterWords(words), + } + data.Error = errMsg + ctx.HTML(http.StatusOK, "admin/settings", data) +} + +// AdminSettingsBrandPost 品牌 +func (d Deps) AdminSettingsBrandPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + cur := d.Settings.SiteBranding() + in := services.SiteBranding{ + Name: strings.TrimSpace(c.PostForm("name")), + Slogan: strings.TrimSpace(c.PostForm("slogan")), + Description: strings.TrimSpace(c.PostForm("description")), + Keywords: strings.TrimSpace(c.PostForm("keywords")), + LogoMark: strings.TrimSpace(c.PostForm("logo_mark")), + Logo: cur.Logo, + Favicon: cur.Favicon, + OGImage: cur.OGImage, + ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")), + ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")), + FriendLinks: cur.FriendLinks, + } + if err := d.Settings.UpdateSiteBranding(in); err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + ctx.SetFlash("品牌设置已保存") + ctx.Redirect("/admin/settings") +} + +// AdminSettingsLimitsPost 限流 +func (d Deps) AdminSettingsLimitsPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + postN, _ := strconv.Atoi(c.PostForm("rate_limit_post")) + commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment")) + regN, _ := strconv.Atoi(c.PostForm("rate_limit_register")) + loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login")) + windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec")) + if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + ctx.SetFlash("限流设置已保存") + ctx.Redirect("/admin/settings") +} + +// AdminSettingsFilterWordsPost 敏感词 +func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + content := c.PostForm("filter_words") + if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05"))) + ctx.Redirect("/admin/settings") +} diff --git a/routers/web/auth.go b/routers/web/auth.go new file mode 100644 index 0000000..08a9952 --- /dev/null +++ b/routers/web/auth.go @@ -0,0 +1,208 @@ +package web + +import ( + "net/http" + "strings" + + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +type loginData struct { + PageChrome + Username string + Redirect string +} + +type registerData struct { + PageChrome + Username string + Nickname string + Email string + MailReady bool + RequireEmailCode bool +} + +type registerForm struct { + Username string + Nickname string + Email string +} + +// LoginGet 登录页 +func (d Deps) LoginGet(c *gin.Context) { + ctx := d.ctx(c) + if ctx.IsSigned() { + ctx.Redirect("/") + return + } + chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "") + if c.Query("banned") == "1" { + chrome.Error = "账号已被禁言" + } + ctx.HTML(http.StatusOK, "auth/login", loginData{ + PageChrome: chrome, + Redirect: c.Query("redirect"), + }) +} + +// LoginPost 登录提交 +func (d Deps) LoginPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderLogin(ctx, "无效请求,请重试", c.PostForm("username"), c.PostForm("redirect")) + return + } + if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) { + d.renderLogin(ctx, "操作过于频繁,请稍后再试", c.PostForm("username"), c.PostForm("redirect")) + return + } + user := strings.TrimSpace(c.PostForm("username")) + pass := c.PostForm("password") + redir := strings.TrimSpace(c.PostForm("redirect")) + if redir == "" || !strings.HasPrefix(redir, "/") || strings.HasPrefix(redir, "//") { + redir = "/" + } + token, _, err := d.Auth.Login(user, pass, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + msg := "用户名或密码错误" + if err == services.ErrUserBanned { + msg = "账号已被禁言" + } + d.renderLogin(ctx, msg, user, redir) + return + } + ctx.SetLoginCookie(token) + ctx.Redirect(redir) +} + +func (d Deps) renderLogin(ctx *webctx.Context, errMsg, username, redir string) { + chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "") + chrome.Error = errMsg + ctx.HTML(http.StatusOK, "auth/login", loginData{ + PageChrome: chrome, + Username: username, + Redirect: redir, + }) +} + +// LogoutPost 退出 +func (d Deps) LogoutPost(c *gin.Context) { + ctx := d.ctx(c) + if ctx.CheckCSRF() { + ctx.ClearLoginCookie() + } + ctx.Redirect("/") +} + +// RegisterGet 注册页 +func (d Deps) RegisterGet(c *gin.Context) { + ctx := d.ctx(c) + if ctx.IsSigned() { + ctx.Redirect("/") + return + } + d.renderRegister(ctx, "", registerForm{}) +} + +// RegisterSendCode POST 发送注册邮箱验证码 +func (d Deps) RegisterSendCode(c *gin.Context) { + ctx := d.ctx(c) + if ctx.IsSigned() { + ctx.Redirect("/") + return + } + form := registerFormFrom(c) + if !ctx.CheckCSRF() { + d.renderRegister(ctx, "无效请求,请重试", form) + return + } + if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) { + d.renderRegister(ctx, "操作过于频繁,请稍后再试", form) + return + } + if d.EmailCode == nil || !d.Settings.MailReady() { + d.renderRegister(ctx, "邮件服务未配置,无需验证码即可注册", form) + return + } + if err := d.EmailCode.SendRegisterCode(form.Email); err != nil { + d.renderRegister(ctx, err.Error(), form) + return + } + chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "") + chrome.Flash = "验证码已发送,请查收邮箱" + d.renderRegisterWithChrome(ctx, chrome, "", form) +} + +// RegisterPost 注册提交 +func (d Deps) RegisterPost(c *gin.Context) { + ctx := d.ctx(c) + if ctx.IsSigned() { + ctx.Redirect("/") + return + } + form := registerFormFrom(c) + if !ctx.CheckCSRF() { + d.renderRegister(ctx, "无效请求,请重试", form) + return + } + if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) { + d.renderRegister(ctx, "操作过于频繁,请稍后再试", form) + return + } + mailReady := d.Settings.MailReady() + if mailReady { + code := strings.TrimSpace(c.PostForm("email_code")) + if d.EmailCode == nil || !d.EmailCode.Verify(form.Email, code) { + d.renderRegister(ctx, services.ErrEmailCodeInvalid.Error(), form) + return + } + } + pass := c.PostForm("password") + pass2 := c.PostForm("password2") + if pass != pass2 { + d.renderRegister(ctx, "两次密码不一致", form) + return + } + user, err := d.Auth.Register(form.Username, pass, form.Nickname, form.Email) + if err != nil { + d.renderRegister(ctx, err.Error(), form) + return + } + sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + ctx.SetFlash("注册成功,请登录") + ctx.Redirect("/login") + return + } + ctx.SetLoginCookie(sid) + ctx.SetFlash("注册成功,欢迎加入") + ctx.Redirect("/") +} + +func registerFormFrom(c *gin.Context) registerForm { + return registerForm{ + Username: strings.TrimSpace(c.PostForm("username")), + Nickname: strings.TrimSpace(c.PostForm("nickname")), + Email: strings.TrimSpace(c.PostForm("email")), + } +} + +func (d Deps) renderRegister(ctx *webctx.Context, errMsg string, form registerForm) { + chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "") + d.renderRegisterWithChrome(ctx, chrome, errMsg, form) +} + +func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) { + chrome.Error = errMsg + mailReady := d.Settings.MailReady() + ctx.HTML(http.StatusOK, "auth/register", registerData{ + PageChrome: chrome, + Username: form.Username, + Nickname: form.Nickname, + Email: form.Email, + MailReady: mailReady, + RequireEmailCode: mailReady, + }) +} diff --git a/routers/web/chrome.go b/routers/web/chrome.go new file mode 100644 index 0000000..68fb141 --- /dev/null +++ b/routers/web/chrome.go @@ -0,0 +1,110 @@ +package web + +import ( + "strings" + + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +// Deps 页面依赖 +type Deps struct { + DataDir string + JWTSecret string + Settings *services.ForumSettingsService + Auth *services.AuthService + Board *services.BoardService + Post *services.PostService + Comment *services.CommentService + Message *services.MessageService + Filter *services.SensitiveFilter + Limiter *services.RateLimiter + EmailCode *services.EmailCodeService + Store *services.UploadStore +} + +// BoardView 侧栏 +type BoardView struct { + ID uint + Name string +} + +// PageChrome 布局公共字段 +type PageChrome struct { + Title string + Description string + SiteName string + Slogan string + LogoMark string + LoggedIn bool + IsAdmin bool + ViewerName string + Boards []BoardView + ActiveBoard uint + Inner string // 保留字段;入口模板已固定组合,不再动态 template + CSRF string + Flash string + Error string +} + +func (d Deps) ctx(c *gin.Context) *webctx.Context { + return webctx.New(c, d.JWTSecret) +} + +func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome { + brand := d.Settings.SiteBranding() + if title == "" { + title = brand.DocumentTitle() + } + if desc == "" { + desc = brand.MetaDescription() + } + name := "我的" + if ctx.IsSigned() { + name = strings.TrimSpace(ctx.Doer.Nickname) + if name == "" { + name = ctx.Doer.Username + } + } + boards, _ := d.Board.List() + bv := make([]BoardView, 0, len(boards)) + for _, b := range boards { + bv = append(bv, BoardView{ID: b.ID, Name: b.Name}) + } + return PageChrome{ + Title: title, + Description: desc, + SiteName: brand.Name, + Slogan: brand.Slogan, + LogoMark: firstRuneOr(brand.LogoMark, "姜"), + LoggedIn: ctx.IsSigned(), + IsAdmin: ctx.IsAdmin(), + ViewerName: name, + Boards: bv, + Inner: inner, + CSRF: ctx.EnsureCSRF(), + Flash: ctx.TakeFlash(), + } +} + +func firstRuneOr(s, fallback string) string { + s = strings.TrimSpace(s) + if s == "" { + return fallback + } + for _, r := range s { + return string(r) + } + return fallback +} + +func stripIDParam(raw, permalinkExt string) string { + raw = strings.TrimSpace(raw) + if permalinkExt != "" { + raw = strings.TrimSuffix(raw, "."+permalinkExt) + } + raw = strings.TrimSuffix(raw, ".html") + raw = strings.TrimSuffix(raw, ".htm") + return raw +} diff --git a/routers/web/compose.go b/routers/web/compose.go new file mode 100644 index 0000000..d32ff97 --- /dev/null +++ b/routers/web/compose.go @@ -0,0 +1,224 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "git.iioio.com/freefire/jiang13-forum/models" + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +type composeData struct { + PageChrome + IsEdit bool + PostID uint + FormAction string + BoardID uint + Title string + Tags string + Content string + Boards []BoardView + TitleMax int + TagsMax int + ContentMax int +} + +// ComposeGet 发帖页 +func (d Deps) ComposeGet(c *gin.Context) { + ctx := d.ctx(c) + if err := d.ensureCanWrite(ctx); err != "" { + ctx.SetFlash(err) + ctx.Redirect("/") + return + } + boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64) + d.renderCompose(ctx, "", composeForm{ + BoardID: uint(boardID), + }, false, 0) +} + +// ComposePost 发帖提交 +func (d Deps) ComposePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), false, 0) + return + } + if msg := d.ensureCanWrite(ctx); msg != "" { + ctx.SetFlash(msg) + ctx.Redirect("/") + return + } + if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) { + d.renderCompose(ctx, "发帖过于频繁,请稍后再试", composeFormFrom(c), false, 0) + return + } + form := composeFormFrom(c) + htmlBody := services.ComposeBodyToHTML(form.Content) + post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, models.PostTypeNormal, ctx.SkipsModeration()) + if err != nil { + d.renderCompose(ctx, err.Error(), form, false, 0) + return + } + if post.Status == models.ContentStatusPending { + ctx.SetFlash("帖子已提交,等待审核") + } else { + ctx.SetFlash("发帖成功") + } + ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) +} + +// PostEditGet 编辑帖 +func (d Deps) PostEditGet(c *gin.Context) { + ctx := d.ctx(c) + post, errMsg := d.loadEditablePost(ctx, c.Param("id")) + if errMsg != "" { + ctx.SetFlash(errMsg) + ctx.Redirect("/") + return + } + d.renderCompose(ctx, "", composeForm{ + BoardID: post.BoardID, + Title: post.Title, + Tags: post.Tags, + Content: services.HTMLToComposePlain(post.Content), + }, true, post.ID) +} + +// PostEditPost 编辑提交 +func (d Deps) PostEditPost(c *gin.Context) { + ctx := d.ctx(c) + post, errMsg := d.loadEditablePost(ctx, c.Param("id")) + if errMsg != "" { + ctx.SetFlash(errMsg) + ctx.Redirect("/") + return + } + if !ctx.CheckCSRF() { + d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), true, post.ID) + return + } + if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) { + d.renderCompose(ctx, "操作过于频繁,请稍后再试", composeFormFrom(c), true, post.ID) + return + } + form := composeFormFrom(c) + htmlBody := services.ComposeBodyToHTML(form.Content) + if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, models.PostTypeNormal, form.BoardID); err != nil { + d.renderCompose(ctx, err.Error(), form, true, post.ID) + return + } + ctx.SetFlash("已保存") + ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) +} + +// ComposeUpload 帖图上传(JSON,供 compose 页 fetch) +func (d Deps) ComposeUpload(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.IsSigned() { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + if !ctx.CheckCSRF() { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"}) + return + } + if ctx.Doer != nil && ctx.Doer.Banned { + c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"}) + return + } + if d.Store == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"}) + return + } + file, err := c.FormFile("image") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"}) + return + } + url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategoryPosts, fmt.Sprintf("%d", ctx.UserID())) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"url": url}) +} + +type composeForm struct { + BoardID uint + Title string + Tags string + Content string +} + +func composeFormFrom(c *gin.Context) composeForm { + bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64) + return composeForm{ + BoardID: uint(bid), + Title: strings.TrimSpace(c.PostForm("title")), + Tags: strings.TrimSpace(c.PostForm("tags")), + Content: c.PostForm("content"), + } +} + +func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm, isEdit bool, postID uint) { + title := "发帖" + action := "/compose" + if isEdit { + title = "编辑帖子" + action = fmt.Sprintf("/post/%d/edit", postID) + } + chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "") + chrome.Error = errMsg + chrome.ActiveBoard = form.BoardID + ctx.HTML(http.StatusOK, "compose", composeData{ + PageChrome: chrome, + IsEdit: isEdit, + PostID: postID, + FormAction: action, + BoardID: form.BoardID, + Title: form.Title, + Tags: form.Tags, + Content: form.Content, + Boards: chrome.Boards, + TitleMax: d.Settings.PostTitleMax(), + TagsMax: d.Settings.PostTagsMax(), + ContentMax: d.Settings.PostContentMax(), + }) +} + +func (d Deps) ensureCanWrite(ctx *webctx.Context) string { + if !ctx.IsSigned() { + return "请先登录" + } + if ctx.Doer != nil && ctx.Doer.Banned { + return "账号已被禁言,无法发帖" + } + return "" +} + +func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Post, string) { + if msg := d.ensureCanWrite(ctx); msg != "" { + return nil, msg + } + idStr := stripIDParam(idParam, d.Settings.Permalink().Ext) + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + return nil, "帖子不存在" + } + post, err := d.Post.FindByID(uint(id)) + if err != nil { + return nil, "帖子不存在" + } + if !ctx.IsAdmin() && post.UserID != ctx.UserID() { + return nil, "无权编辑此帖" + } + if reason := d.Post.EditBlockReason(post, ctx.IsAdmin()); reason != "" { + return nil, reason + } + return post, "" +} diff --git a/routers/web/home.go b/routers/web/home.go index afe8f37..547e419 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -7,27 +7,74 @@ import ( "time" "git.iioio.com/freefire/jiang13-forum/modules/auth" - "git.iioio.com/freefire/jiang13-forum/models" - "git.iioio.com/freefire/jiang13-forum/modules/webrender" "git.iioio.com/freefire/jiang13-forum/services" "github.com/gin-gonic/gin" ) -// Deps 页面路由依赖(复用现有 service,避免 Phase 1 大搬家) -type Deps struct { - Settings *services.ForumSettingsService - Board *services.BoardService - Post *services.PostService +// Register 注册已安装后的 web 路由 +func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { + g := r.Group("/", authMW.OptionalAuth()) + g.GET("/", deps.Home) + g.GET("/board/:id", deps.Home) + g.GET("/post/:id", deps.PostView) + g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet) + g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost) + g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment) + g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike) + g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite) + g.GET("/login", deps.LoginGet) + g.POST("/login", deps.LoginPost) + g.POST("/logout", deps.LogoutPost) + g.GET("/register", deps.RegisterGet) + g.POST("/register", deps.RegisterPost) + g.POST("/register/send-code", deps.RegisterSendCode) + g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet) + g.POST("/compose", authMW.RequireAuth(), deps.ComposePost) + g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload) + g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") }) + + admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin()) + { + admin.GET("", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") }) + admin.GET("/dashboard", deps.AdminDashboard) + admin.GET("/boards", deps.AdminBoardsGet) + admin.POST("/boards", deps.AdminBoardCreate) + admin.POST("/boards/:id", deps.AdminBoardUpdate) + admin.POST("/boards/:id/delete", deps.AdminBoardDelete) + admin.GET("/moderation", deps.AdminModerationGet) + admin.POST("/posts/:id/approve", deps.AdminPostApprove) + admin.POST("/posts/:id/reject", deps.AdminPostReject) + admin.POST("/comments/:id/approve", deps.AdminCommentApprove) + admin.POST("/comments/:id/reject", deps.AdminCommentReject) + admin.GET("/settings", deps.AdminSettingsGet) + admin.POST("/settings/brand", deps.AdminSettingsBrandPost) + admin.POST("/settings/limits", deps.AdminSettingsLimitsPost) + admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) + } + + g.GET("/profile", deps.PendingPage) + g.GET("/messages", deps.PendingPage) + g.GET("/favorites", deps.PendingPage) + g.GET("/projects", deps.PendingPage) + g.GET("/links", deps.PendingPage) + g.GET("/boards", deps.PendingPage) } -// BoardView 侧栏板块 -type BoardView struct { - ID uint - Name string +// HomePageData Feed +type HomePageData struct { + PageChrome + BoardName string + Sort string + Posts []PostListItem + Page int + PrevPage int + NextPage int + HasPrev bool + HasMore bool } -// PostView 列表项 -type PostView struct { +// PostListItem 列表项 +type PostListItem struct { ID uint Title string AuthorName string @@ -38,39 +85,10 @@ type PostView struct { CreatedLabel string } -// HomePageData 首页 / 板块 Feed -type HomePageData struct { - Title string - Description string - SiteName string - Slogan string - LogoMark string - LoggedIn bool - IsAdmin bool - ViewerName string - Boards []BoardView - ActiveBoard uint - BoardName string - Sort string - Posts []PostView - Page int - PrevPage int - NextPage int - HasPrev bool - HasMore bool -} - -// Register 注册已迁移的 SSR 页面(优先于 SPA) -func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { - g := r.Group("/", authMW.OptionalAuth()) - g.GET("/", deps.Home) - g.GET("/board/:id", deps.Home) -} - -// Home SSR 首页与板块列表 +// Home 首页 / 板块 func (d Deps) Home(c *gin.Context) { - brand := d.Settings.SiteBranding() - sort := c.DefaultQuery("sort", "latest") + ctx := d.ctx(c) + sort := normalizeSort(c.DefaultQuery("sort", "latest")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) if page < 1 { page = 1 @@ -80,54 +98,38 @@ func (d Deps) Home(c *gin.Context) { var boardID uint var boardName string if idStr := c.Param("id"); idStr != "" { - // 兼容伪静态后缀 123.html - idStr = strings.TrimSuffix(idStr, "."+d.Settings.Permalink().Ext) - idStr = strings.TrimSuffix(idStr, ".html") - idStr = strings.TrimSuffix(idStr, ".htm") + idStr = stripIDParam(idStr, d.Settings.Permalink().Ext) if n, err := strconv.ParseUint(idStr, 10, 64); err == nil { boardID = uint(n) } } - boards, _ := d.Board.List() - boardViews := make([]BoardView, 0, len(boards)) - for _, b := range boards { - boardViews = append(boardViews, BoardView{ID: b.ID, Name: b.Name}) + chrome := d.chrome(ctx, "", "", "home/feed") + chrome.ActiveBoard = boardID + for _, b := range chrome.Boards { if b.ID == boardID { boardName = b.Name + break } } - - var uid uint - if v, ok := c.Get(auth.CtxUserID); ok { - uid, _ = v.(uint) + if boardName != "" { + chrome.Title = boardName + " · " + chrome.SiteName } - isAdmin := false - if v, ok := c.Get(auth.CtxRole); ok { - switch r := v.(type) { - case models.Role: - isAdmin = r == models.RoleAdmin - case string: - isAdmin = r == string(models.RoleAdmin) - } - } - username, _ := c.Get(auth.CtxUsername) - q := services.PostListQuery{ + items, total, err := d.Post.ListItems(services.PostListQuery{ BoardID: boardID, Page: page, Size: size, Sort: sort, - ViewerID: uid, - ViewerIsAdmin: isAdmin, - } - items, total, err := d.Post.ListItems(q) + ViewerID: ctx.UserID(), + ViewerIsAdmin: ctx.IsAdmin(), + }) if err != nil { c.String(http.StatusInternalServerError, "加载帖子失败") return } - posts := make([]PostView, 0, len(items)) + posts := make([]PostListItem, 0, len(items)) for _, it := range items { author := strings.TrimSpace(it.User.Nickname) if author == "" { @@ -137,49 +139,24 @@ func (d Deps) Home(c *gin.Context) { if it.Board.ID > 0 { bname = it.Board.Name } - posts = append(posts, PostView{ - ID: it.ID, - Title: it.Title, - AuthorName: author, - BoardName: bname, - Pinned: it.Pinned, - Featured: it.Featured, - CommentCount: it.CommentCount, - CreatedLabel: formatTime(it.CreatedAt), + posts = append(posts, PostListItem{ + ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname, + Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount, + CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"), }) } - title := brand.DocumentTitle() - if boardName != "" { - title = boardName + " · " + brand.Name - } - - data := HomePageData{ - Title: title, - Description: brand.MetaDescription(), - SiteName: brand.Name, - Slogan: brand.Slogan, - LogoMark: firstRuneOr(brand.LogoMark, "姜"), - LoggedIn: uid > 0, - IsAdmin: isAdmin, - ViewerName: fmtViewer(username), - Boards: boardViews, - ActiveBoard: boardID, - BoardName: boardName, - Sort: normalizeSort(sort), - Posts: posts, - Page: page, - PrevPage: page - 1, - NextPage: page + 1, - HasPrev: page > 1, - HasMore: int64(page*size) < total, - } - - c.Header("Content-Type", "text/html; charset=utf-8") - c.Status(http.StatusOK) - if err := webrender.Execute(c.Writer, "home", data); err != nil { - c.String(http.StatusInternalServerError, "模板渲染失败: %v", err) - } + ctx.HTML(http.StatusOK, "home", HomePageData{ + PageChrome: chrome, + BoardName: boardName, + Sort: sort, + Posts: posts, + Page: page, + PrevPage: page - 1, + NextPage: page + 1, + HasPrev: page > 1, + HasMore: int64(page*size) < total, + }) } func normalizeSort(s string) string { @@ -194,26 +171,3 @@ func normalizeSort(s string) string { func formatTime(t time.Time) string { return t.Local().Format("2006-01-02 15:04") } - -func firstRuneOr(s, fallback string) string { - s = strings.TrimSpace(s) - if s == "" { - return fallback - } - for _, r := range s { - return string(r) - } - return fallback -} - -func fmtViewer(v any) string { - if v == nil { - return "我的" - } - s, _ := v.(string) - s = strings.TrimSpace(s) - if s == "" { - return "我的" - } - return s -} diff --git a/routers/web/pending.go b/routers/web/pending.go new file mode 100644 index 0000000..1bd7353 --- /dev/null +++ b/routers/web/pending.go @@ -0,0 +1,35 @@ +package web + +import ( + "net/http" + + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "github.com/gin-gonic/gin" +) + +type pendingData struct { + PageChrome + Heading string + Message string +} + +// PendingPage 未迁移页 +func (d Deps) PendingPage(c *gin.Context) { + ctx := d.ctx(c) + chrome := d.chrome(ctx, "页面准备中 · "+d.Settings.SiteBranding().Name, "", "") + ctx.HTML(http.StatusOK, "status/pending", pendingData{ + PageChrome: chrome, + Heading: "页面准备中", + Message: "该功能尚未用模板实现,请稍后再来。", + }) +} + +func (d Deps) render404(ctx *webctx.Context) { + chrome := d.chrome(ctx, "页面不存在 · "+d.Settings.SiteBranding().Name, "", "") + ctx.HTML(http.StatusNotFound, "status/404", chrome) +} + +// NotFound NoRoute +func (d Deps) NotFound(c *gin.Context) { + d.render404(d.ctx(c)) +} diff --git a/routers/web/post.go b/routers/web/post.go new file mode 100644 index 0000000..ce0c587 --- /dev/null +++ b/routers/web/post.go @@ -0,0 +1,193 @@ +package web + +import ( + "fmt" + "html" + "net/http" + "net/url" + "strconv" + "strings" + + "git.iioio.com/freefire/jiang13-forum/models" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +// PostPageData 帖详情 +type PostPageData struct { + PageChrome + PostID uint + PostPath string + PostTitle string + AuthorName string + BoardID uint + BoardName string + Pinned bool + Featured bool + PostTypeLabel string + CreatedLabel string + ViewCount int + LikeCount int + Liked bool + Favorited bool + BodyHTML string + CommentCount int + Comments []CommentView + CommentsLocked bool + CanEdit bool +} + +// CommentView 评论 +type CommentView struct { + Floor int + AuthorName string + CreatedLabel string + Content string + ContentHidden bool +} + +// PostView GET /post/:id +func (d Deps) PostView(c *gin.Context) { + ctx := d.ctx(c) + idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext) + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + d.render404(ctx) + return + } + post, err := d.Post.FindByID(uint(id)) + if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) { + d.render404(ctx) + return + } + if post.Status == models.ContentStatusPublished { + d.Post.RecordView(uint(id)) + } + + hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID()) + body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied) + + comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil) + cv := make([]CommentView, 0, len(comments)) + for _, cm := range comments { + an := strings.TrimSpace(cm.User.Nickname) + if an == "" { + an = cm.User.Username + } + cv = append(cv, CommentView{ + Floor: cm.Floor, AuthorName: an, CreatedLabel: formatTime(cm.CreatedAt), + Content: cm.Content, ContentHidden: cm.ContentHidden, + }) + } + + author := strings.TrimSpace(post.User.Nickname) + if author == "" { + author = post.User.Username + } + boardName := "" + if post.Board.ID > 0 { + boardName = post.Board.Name + } + + chrome := d.chrome(ctx, post.Title+" · "+d.Settings.SiteBranding().Name, "", "post/body") + chrome.ActiveBoard = post.BoardID + + ctx.HTML(http.StatusOK, "post", PostPageData{ + PageChrome: chrome, PostID: post.ID, + PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), + PostTitle: post.Title, AuthorName: author, BoardID: post.BoardID, BoardName: boardName, + Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured, + PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt), + ViewCount: post.ViewCount, LikeCount: post.LikeCount, + Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID), + BodyHTML: body, CommentCount: len(cv), Comments: cv, + CommentsLocked: post.CommentsLocked, + CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()), + }) +} + +func postTypeLabel(t string) string { + switch t { + case models.PostTypeQuestion: + return "问答" + case models.PostTypePoll: + return "投票" + case models.PostTypeBounty: + return "悬赏" + case models.PostTypeLottery: + return "抽奖" + default: + return "" + } +} + +// PostComment POST 评论 +func (d Deps) PostComment(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/") + return + } + id, err := parsePostID(c, d) + if err != nil { + d.render404(ctx) + return + } + content := strings.TrimSpace(c.PostForm("content")) + if content == "" { + ctx.SetFlash("评论不能为空") + ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) + return + } + safe := "

" + html.EscapeString(content) + "

" + _, err = d.Comment.Create(services.CommentCreateInput{ + PostID: id, UserID: ctx.UserID(), Content: safe, + }) + if err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) + return + } + ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) +} + +// PostLike 赞 +func (d Deps) PostLike(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求") + ctx.Redirect("/") + return + } + id, err := parsePostID(c, d) + if err != nil { + d.render404(ctx) + return + } + _, _ = d.Post.ToggleLike(ctx.UserID(), id) + ctx.Redirect(fmt.Sprintf("/post/%d", id)) +} + +// PostFavorite 收藏 +func (d Deps) PostFavorite(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求") + ctx.Redirect("/") + return + } + id, err := parsePostID(c, d) + if err != nil { + d.render404(ctx) + return + } + _, _ = d.Post.ToggleFavorite(ctx.UserID(), id) + ctx.Redirect(fmt.Sprintf("/post/%d", id)) +} + +func parsePostID(c *gin.Context, d Deps) (uint, error) { + idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext) + id, err := strconv.ParseUint(idStr, 10, 64) + return uint(id), err +} diff --git a/services/auth.go b/services/auth.go index c45733c..9f56b03 100644 --- a/services/auth.go +++ b/services/auth.go @@ -1,38 +1,33 @@ package services import ( - "errors" "sync" "time" - "github.com/golang-jwt/jwt/v5" "git.iioio.com/freefire/jiang13-forum/models" ) -// 最近访问写入节流,避免每次 API 都打库 +// 最近访问写入节流,避免每次请求都打库 const lastAccessTouchInterval = 5 * time.Minute var lastAccessTouchCache sync.Map // userID(uint) -> time.Time -const TokenExpire = 7 * 24 * time.Hour - -type Claims struct { - UserID uint `json:"user_id"` - Username string `json:"username"` - Role models.Role `json:"role"` - jwt.RegisteredClaims -} +// SessionTTL 与浏览器会话对齐(兼容旧常量名) +const TokenExpire = SessionTTL type AuthService struct { - jwtSecret string - filter *SensitiveFilter - settings *ForumSettingsService + hmacSecret string // CSRF 等 HMAC;不再用于浏览器登录 JWT + filter *SensitiveFilter + settings *ForumSettingsService } -func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService { - return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings} +func NewAuthService(hmacSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService { + return &AuthService{hmacSecret: hmacSecret, filter: filter, settings: settings} } +// HMACSecret 供 CSRF 等使用 +func (s *AuthService) HMACSecret() string { return s.hmacSecret } + // UserCount 当前用户数 func (s *AuthService) UserCount() int64 { var n int64 @@ -70,18 +65,12 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod } nickname = s.filter.Filter(nickname) - // 首个注册用户自动成为管理员 - role := models.RoleUser - if s.UserCount() == 0 { - role = models.RoleAdmin - } - user := &models.User{ Username: username, Email: email, Password: hash, Nickname: nickname, - Role: role, + Role: models.RoleUser, } if err := models.DB.Create(user).Error; err != nil { return nil, err @@ -89,24 +78,69 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod return user, nil } -// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录 -func (s *AuthService) Login(username, password, clientIP string) (string, *models.User, error) { - var user models.User - if err := models.DB.Where("username = ?", username).First(&user).Error; err != nil { - return "", nil, ErrInvalidCred +// CreateAdmin 安装向导创建管理员(仅应在未安装时调用) +func (s *AuthService) CreateAdmin(username, password, nickname, email string) (*models.User, error) { + if err := ValidateUsername(username); err != nil { + return nil, err } - if user.Banned { - return "", nil, ErrUserBanned + if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil { + return nil, err } - if !CheckPassword(user.Password, password) { - return "", nil, ErrInvalidCred + email = NormalizeEmail(email) + if err := ValidateEmail(email); err != nil { + return nil, err } - s.recordLogin(&user, clientIP) - token, err := s.GenerateToken(&user) - return token, &user, err + if nickname == "" { + nickname = username + } + nickname = s.filter.Filter(nickname) + hash, err := HashPassword(password) + if err != nil { + return nil, err + } + user := &models.User{ + Username: username, + Email: email, + Password: hash, + Nickname: nickname, + Role: models.RoleAdmin, + Verified: true, + } + if err := models.DB.Create(user).Error; err != nil { + return nil, err + } + return user, nil +} + +// Login 校验密码并创建会话,返回 session id +func (s *AuthService) Login(username, password, clientIP, userAgent string) (sessionID string, user *models.User, err error) { + var u models.User + if err := models.DB.Where("username = ?", username).First(&u).Error; err != nil { + return "", nil, ErrInvalidCred + } + if u.Banned { + return "", nil, ErrUserBanned + } + if !CheckPassword(u.Password, password) { + return "", nil, ErrInvalidCred + } + s.recordLogin(&u, clientIP) + sid, err := CreateSession(u.ID, clientIP, userAgent) + if err != nil { + return "", nil, err + } + return sid, &u, nil +} + +// CreateSessionForUser 已认证用户直接建会话(注册后自动登录) +func (s *AuthService) CreateSessionForUser(user *models.User, clientIP, userAgent string) (string, error) { + if user == nil { + return "", ErrInvalidCred + } + s.recordLogin(user, clientIP) + return CreateSession(user.ID, clientIP, userAgent) } -// recordLogin 记录上次登录时间与 IP;登录同时视为一次访问(失败不影响登录) func (s *AuthService) recordLogin(user *models.User, clientIP string) { now := time.Now() ip := clientIP @@ -138,33 +172,3 @@ func (s *AuthService) TouchLastAccess(userID uint) { lastAccessTouchCache.Store(userID, now) _ = models.DB.Model(&models.User{}).Where("id = ?", userID).Update("last_access_at", now).Error } - -// GenerateToken 生成 JWT -func (s *AuthService) GenerateToken(user *models.User) (string, error) { - claims := Claims{ - UserID: user.ID, - Username: user.Username, - Role: user.Role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpire)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(s.jwtSecret)) -} - -// ParseToken 解析 JWT -func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) { - token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { - return []byte(s.jwtSecret), nil - }) - if err != nil { - return nil, err - } - claims, ok := token.Claims.(*Claims) - if !ok || !token.Valid { - return nil, errors.New("invalid token") - } - return claims, nil -} diff --git a/services/backup.go b/services/backup.go index d02ee21..205330c 100644 --- a/services/backup.go +++ b/services/backup.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "time" + + "git.iioio.com/freefire/jiang13-forum/models" ) type BackupService struct { @@ -17,8 +19,11 @@ func NewBackupService(dbPath, dataDir string) *BackupService { return &BackupService{dbPath: dbPath, dataDir: dataDir} } -// ExportSQLite 导出 SQLite 备份文件到 data 目录 +// ExportSQLite 导出 SQLite 备份文件到 data 目录(仅 sqlite) func (s *BackupService) ExportSQLite() (string, error) { + if models.DialectorName() != "sqlite" { + return "", fmt.Errorf("一键文件备份仅支持 SQLite;当前为 %s,请使用数据库自带备份工具", models.DialectorName()) + } src, err := os.Open(s.dbPath) if err != nil { return "", fmt.Errorf("打开数据库失败: %w", err) @@ -38,15 +43,3 @@ func (s *BackupService) ExportSQLite() (string, error) { } return destPath, nil } - -// WriteDefaultFilterWords 写入默认敏感词配置 -func WriteDefaultFilterWords(path string) error { - if _, err := os.Stat(path); err == nil { - return nil - } - content := `# 姜十三论坛敏感词配置,每行一个词,# 开头为注释 -违禁词示例 -广告刷单 -` - return os.WriteFile(path, []byte(content), 0644) -} diff --git a/services/comment.go b/services/comment.go index 9eaeeb1..d21c987 100644 --- a/services/comment.go +++ b/services/comment.go @@ -345,6 +345,29 @@ func (s *CommentService) PendingCommentCount() (int64, error) { return n, err } +// ListPending 待审评论列表(管理端) +func (s *CommentService) ListPending(page, size int) ([]models.Comment, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 30 + } + if size > 100 { + size = 100 + } + db := models.DB.Model(&models.Comment{}). + Where("status = ?", models.ContentStatusPending). + Preload("User").Preload("Post") + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var list []models.Comment + err := db.Order("id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error + return list, total, err +} + func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error { if !isAdmin { return ErrPermissionDenied diff --git a/services/common.go b/services/common.go index 314b669..366583b 100644 --- a/services/common.go +++ b/services/common.go @@ -115,7 +115,12 @@ func (f *SensitiveFilter) LoadFromFile(path string) { if err != nil { return } - lines := strings.Split(string(data), "\n") + f.LoadFromContent(string(data)) +} + +// LoadFromContent 从文本内容加载敏感词(每行一词,# 注释) +func (f *SensitiveFilter) LoadFromContent(content string) { + lines := strings.Split(content, "\n") var words []string for _, line := range lines { line = strings.TrimSpace(line) @@ -123,11 +128,12 @@ func (f *SensitiveFilter) LoadFromFile(path string) { words = append(words, line) } } - if len(words) > 0 { - f.mu.Lock() - f.words = words - f.mu.Unlock() + if len(words) == 0 { + return } + f.mu.Lock() + f.words = words + f.mu.Unlock() } func (f *SensitiveFilter) Filter(text string) string { diff --git a/services/compose_body.go b/services/compose_body.go new file mode 100644 index 0000000..9831700 --- /dev/null +++ b/services/compose_body.go @@ -0,0 +1,97 @@ +package services + +import ( + "html" + "regexp" + "strings" +) + +var ( + mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`) + mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) +) + +// ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。 +// 若已含块级 HTML 标签则原样交 Sanitize;否则按纯文本/轻量 Markdown 转段落。 +func ComposeBodyToHTML(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + lower := strings.ToLower(raw) + if strings.Contains(lower, "` + alt + `

` + }) + esc = mdLinkRe.ReplaceAllStringFunc(esc, func(m string) string { + sub := mdLinkRe.FindStringSubmatch(m) + if len(sub) != 3 { + return m + } + text, href := sub[1], sub[2] + if !safeComposeURL(href) { + return m + } + return `` + text + `` + }) + + parts := strings.Split(esc, "\n\n") + var b strings.Builder + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + // 已是独立 img 段 + if strings.HasPrefix(p, "

\n") + b.WriteString("

") + b.WriteString(p) + b.WriteString("

\n") + } + return b.String() +} + +func safeComposeURL(u string) bool { + u = strings.TrimSpace(u) + if strings.HasPrefix(u, "/uploads/") || strings.HasPrefix(u, "/media/") { + return true + } + return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") +} + +// HTMLToComposePlain 编辑页回显:去掉简单标签便于 textarea 编辑(尽力而为) +func HTMLToComposePlain(htmlBody string) string { + s := strings.TrimSpace(htmlBody) + if s == "" { + return "" + } + // img → markdown + imgRe := regexp.MustCompile(`(?i)]+src="([^"]+)"[^>]*>`) + s = imgRe.ReplaceAllString(s, "![]($1)") + s = regexp.MustCompile(`(?i)

\s*

`).ReplaceAllString(s, "\n\n") + s = regexp.MustCompile(`(?i)`).ReplaceAllString(s, "\n") + s = regexp.MustCompile(`(?i)]*>`).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}} +
  • +
    + + + + + + +

    已发布帖 {{.PostCount}}

    + +
    +
    + + +
    +
  • + {{end}} +
+ {{else}} +

暂无板块

+ {{end}} +
+{{template "base/footer" .}} +{{end}} diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl new file mode 100644 index 0000000..5f0074f --- /dev/null +++ b/templates/admin/dashboard.tmpl @@ -0,0 +1,18 @@ +{{define "admin/dashboard"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

管理后台

+ {{template "admin/nav" .}} + {{template "base/alert" .}} +
    +
  • {{.UserCount}}用户
  • +
  • {{.PostCount}}已发布帖
  • +
  • {{.BoardCount}}板块
  • +
  • {{.PendingPosts}}待审帖
  • +
  • {{.PendingComments}}待审评
  • +
+

有待审内容时请前往 内容审核

+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/admin/moderation.tmpl b/templates/admin/moderation.tmpl new file mode 100644 index 0000000..e9505b2 --- /dev/null +++ b/templates/admin/moderation.tmpl @@ -0,0 +1,60 @@ +{{define "admin/moderation"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

内容审核

+ {{template "admin/nav" .}} + {{template "base/alert" .}} + +

待审帖子({{len .Posts}})

+ {{if .Posts}} +
    + {{range .Posts}} +
  • +

    {{.Title}}

    +

    {{.AuthorName}} · {{.BoardName}} · {{.CreatedAt}}

    +
    +
    + + +
    +
    + + + +
    +
    +
  • + {{end}} +
+ {{else}} +

无待审帖子

+ {{end}} + +

待审评论({{len .Comments}})

+ {{if .Comments}} +
    + {{range .Comments}} +
  • +

    #{{.Floor}} · {{.AuthorName}} · {{.CreatedAt}} · {{.PostTitle}}

    +

    {{.Excerpt}}

    +
    +
    + + +
    +
    + + + +
    +
    +
  • + {{end}} +
+ {{else}} +

无待审评论

+ {{end}} +
+{{template "base/footer" .}} +{{end}} diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl new file mode 100644 index 0000000..9ec8853 --- /dev/null +++ b/templates/admin/nav.tmpl @@ -0,0 +1,9 @@ +{{define "admin/nav"}} + +{{end}} diff --git a/templates/admin/settings.tmpl b/templates/admin/settings.tmpl new file mode 100644 index 0000000..f3c0b95 --- /dev/null +++ b/templates/admin/settings.tmpl @@ -0,0 +1,43 @@ +{{define "admin/settings"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

站点设置

+ {{template "admin/nav" .}} + {{template "base/alert" .}} + +

品牌

+
+ + + + + + + + + +
+ +

基础限流

+

窗口内允许的操作次数(秒)。

+
+ + + + + + + +
+ +

敏感词(有效 {{.FilterCount}} 个)

+

每行一个词,# 开头为注释。保存后立即热更新。

+
+ + + +
+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/auth/login.tmpl b/templates/auth/login.tmpl new file mode 100644 index 0000000..a9542b1 --- /dev/null +++ b/templates/auth/login.tmpl @@ -0,0 +1,20 @@ +{{define "auth/login"}} +{{template "base/head" .}} +
+

登录

+ {{template "base/alert" .}} +
+ + + + + +
+

没有账号?注册 · 返回首页

+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/auth/register.tmpl b/templates/auth/register.tmpl new file mode 100644 index 0000000..f5abbe9 --- /dev/null +++ b/templates/auth/register.tmpl @@ -0,0 +1,39 @@ +{{define "auth/register"}} +{{template "base/head" .}} +
+

注册

+ {{template "base/alert" .}} +
+ + + + + {{if .RequireEmailCode}} + +

邮件服务已启用,注册需验证邮箱。

+ {{else}} +

邮件服务未配置,可直接注册(管理员可稍后在后台启用 SMTP)。

+ {{end}} + + + +
+

已有账号?登录 · 返回首页

+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/base.tmpl b/templates/base.tmpl deleted file mode 100644 index c0a64c3..0000000 --- a/templates/base.tmpl +++ /dev/null @@ -1,51 +0,0 @@ -{{define "base"}} - - - - - - {{.Title}} - {{if .Description}}{{end}} - - - -
-
- - {{.LogoMark}} - - {{.SiteName}} - {{if .Slogan}}{{.Slogan}}{{end}} - - - -
-
-
- -
- {{template "content" .}} -
-
-
-

{{.SiteName}} · Gitea 式 SSR 骨架

-
- - - -{{end}} diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl new file mode 100644 index 0000000..14a00c4 --- /dev/null +++ b/templates/base/alert.tmpl @@ -0,0 +1,4 @@ +{{define "base/alert"}} +{{if .Error}}{{end}} +{{if .Flash}}
{{.Flash}}
{{end}} +{{end}} diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl new file mode 100644 index 0000000..73cb8ca --- /dev/null +++ b/templates/base/footer.tmpl @@ -0,0 +1,8 @@ +{{define "base/footer"}} +
+

{{.SiteName}}

+
+ + + +{{end}} diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl new file mode 100644 index 0000000..b3ce62c --- /dev/null +++ b/templates/base/head.tmpl @@ -0,0 +1,12 @@ +{{define "base/head"}} + + + + + + {{.Title}} + {{if .Description}}{{end}} + + + +{{end}} diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl new file mode 100644 index 0000000..71e0d20 --- /dev/null +++ b/templates/base/navbar.tmpl @@ -0,0 +1,27 @@ +{{define "base/navbar"}} +
+
+ + {{.LogoMark}} + + {{.SiteName}} + {{if .Slogan}}{{.Slogan}}{{end}} + + + +
+
+{{end}} diff --git a/templates/compose.tmpl b/templates/compose.tmpl new file mode 100644 index 0000000..8bc929c --- /dev/null +++ b/templates/compose.tmpl @@ -0,0 +1,39 @@ +{{define "compose"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +{{if .Flash}}
{{.Flash}}
{{end}} +
+

{{if .IsEdit}}编辑帖子{{else}}发帖{{end}}

+ {{template "base/alert" .}} +
+ + + + + +
+ + +
+

本版仅发普通讨论帖;投票 / 悬赏 / 抽奖后续迭代。

+ +
+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/embed.go b/templates/embed.go index ce11628..c68ff4f 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -2,5 +2,5 @@ package templates import "embed" -//go:embed *.tmpl +//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl var FS embed.FS diff --git a/templates/home.tmpl b/templates/home/feed.tmpl similarity index 87% rename from templates/home.tmpl rename to templates/home/feed.tmpl index 99d7abd..a0bff6e 100644 --- a/templates/home.tmpl +++ b/templates/home/feed.tmpl @@ -1,6 +1,4 @@ -{{define "home"}}{{template "base" .}}{{end}} - -{{define "content"}} +{{define "home/feed"}}

{{if .BoardName}}{{.BoardName}}{{else}}全部帖子{{end}}

@@ -10,14 +8,13 @@ 热门讨论
- {{if not .Posts}} -

暂无帖子。登录后可以发第一帖。

+

暂无帖子。

{{else}}
    {{range .Posts}}
  • - {{.Title}} + {{.Title}}
    {{if .Pinned}}置顶{{end}} {{if .Featured}}精华{{end}} @@ -30,7 +27,6 @@ {{end}}
{{end}} - {{if or .HasPrev .HasMore}}