feat: opaque session、安装/发帖 SSR 与最小 Admin 后台

浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:44:16 +08:00
parent 3f50316ad0
commit fde5f628ec
80 changed files with 4148 additions and 1849 deletions

View File

@@ -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 传入。

View File

@@ -110,12 +110,12 @@
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
- 可选邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储
- 可选邮件验证码、OIDC Provider、S3 兼容对象存储Gitea 仓库同步后置)
### 部署体验
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
- **单二进制** — `go:embed` 打包模板与 SSR 资源
- **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQLEnv 引导)
- **跨平台** — 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 / Linuxsystemd需 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
```
**WindowsWindows 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 模型

View File

@@ -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 =

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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:

View File

@@ -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 均可编译与运行。

View File

@@ -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) 供参考。

View File

@@ -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`)— SSRSameSite=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、LogoFaviconOG 图、ICP
- [x] 站点品牌文案名称、标语、简介、keywords、Logo 字标、ICP — SSRLogo/Favicon/OG 上传未迁)
- [ ] SQLite 一键备份与下载
---

View File

@@ -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 idCookie `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 | 默认 |

View File

@@ -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`]((仅 mainfrontend/src/api/client.ts)、[`frontend/src/api/types.ts`]((仅 mainfrontend/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 |

View File

@@ -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=LaxHTTPS 时 SecureTTL 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 等;窗口秒与次数来自 settingsAdmin 可改基础四项+窗口)
---

View File

@@ -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板块 Feedid 可带伪静态后缀) |
| `/post/:id` | PostDetailPage |
| `/compose` | ComposePage 发帖 |
| `/post/:id/edit` | ComposePage 编辑 |
| `/profile` | ProfilePage需登录 |
| `/user/:id` | UserProfilePage |
| `/favorites` | FavoritesPage |
| `/projects` | ProjectsPageGitea 码桶) |
| `/links` | LinksPage |
| `/messages` | MessagesPage |
| `/page/:slug` | SitePageView |
| `*` | NotFoundPage |
### 1.2 前台
重定向:`/boards``/admin/boards`
| 路径 | 说明 | SSR |
|------|------|-----|
| `/` | Feed | 已迁 |
| `/board/:id` | 板块 Feed | 已迁 |
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
| `/compose` | 发帖normalMarkdown 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` | 重定向前台登录 | 已迁 |
未迁(原 SPAreports / users / badges / media / pages / links / SMTP / 完整 Limits 等。
---

View File

@@ -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 HMACCSRF 等;勿提交;非登录 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) 为准。

View File

@@ -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 HMACOIDC 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`(品牌/限流/敏感词)。

View File

@@ -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 |
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染SSRHTMLSEO 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` 的可选后缀 |

View File

@@ -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 后重启;业务项在管理后台改。引导面保持精简,热更新走数据库
---

12
go.mod
View File

@@ -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

29
go.sum
View File

@@ -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=

View File

@@ -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
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
// InitDB 按方言初始化数据库并自动迁移
func InitDB(cfg DatabaseConfig) error {
typ := strings.ToLower(strings.TrimSpace(cfg.Type))
if typ == "" {
typ = "sqlite"
}
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 {

14
models/session.go Normal file
View File

@@ -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"`
}

View File

@@ -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"
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 {
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/RequireAuthrole 来自 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")
}

43
modules/auth/cookie.go Normal file
View File

@@ -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,
})
}

181
modules/webctx/context.go Normal file
View File

@@ -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 见其它 URIPRG
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

View File

@@ -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
}

View File

@@ -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; }

View File

@@ -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 || "上传失败";
}
});
})();

View File

@@ -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 应用

View File

@@ -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

View File

@@ -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(`<?xml version="1.0" encoding="UTF-8"?>`)
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
for _, u := range urls {
b.WriteString("<url>")
b.WriteString("<loc>")
b.WriteString("<url><loc>")
b.WriteString(xmlEscape(u.Loc))
b.WriteString("</loc>")
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 + <head> 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, `<p>板块页 SSR 迁移中,请先从 <a href="/">首页</a> 浏览。</p>`)
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), `<p>帖子详情 SSR 迁移中,请先从 <a href="/">首页</a> 返回。</p>`)
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), `<p>用户主页 SSR 迁移中,请先从 <a href="/">首页</a> 返回。</p>`)
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(`<h1>%s</h1><div>%s</div>`, 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, `<p>该页面 SSR 迁移中。<a href="/">返回首页</a></p>`)
}
func servePendingSSR(c *gin.Context, title, bodyHTML string) {
if strings.TrimSpace(title) == "" {
title = "姜十三论坛"
}
page := fmt.Sprintf(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>%s</title><link rel="stylesheet" href="/ssr-assets/site.css"/></head><body class="j13-body"><main class="j13-main" style="max-width:800px;margin:2rem auto;padding:1rem">%s</main></body></html>`,
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(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><title>%s</title></head><body><h1>404</h1><p>页面不存在。</p><p><a href="/">返回首页</a></p></body></html>`,
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(
`&`, "&amp;",
`<`, "&lt;",
`>`, "&gt;",
`"`, "&quot;",
`'`, "&apos;",
)
return r.Replace(s)
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -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("<!DOCTYPE html><html lang=\"zh-CN\"><head>")
b.WriteString("<meta charset=\"UTF-8\"/>")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>")
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(`<link rel="canonical" href="` + html.EscapeString(meta.Canonical) + `"/>`)
}
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(`<script type="application/ld+json">`)
b.WriteString(meta.JSONLD)
b.WriteString(`</script>`)
}
b.WriteString(`<style>
body{font-family:system-ui,sans-serif;line-height:1.6;max-width:800px;margin:24px auto;padding:0 16px;color:#222}
a{color:#2d6a4f}img{max-width:100%;height:auto}
.meta{color:#666;font-size:14px;margin:8px 0 20px}
.nav{margin:32px 0;font-size:14px}
</style>`)
b.WriteString("</head><body>")
b.WriteString(bodyInner)
b.WriteString(`<p class="nav"><a href="/">← 返回首页</a></p>`)
b.WriteString("</body></html>")
return b.String()
}
func writeEscapedTag(b *strings.Builder, tag, text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
b.WriteString("<" + tag + ">" + html.EscapeString(text) + "</" + tag + ">")
}
func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
b.WriteString("<meta " + attr + "=\"" + html.EscapeString(key) + "\" content=\"" + html.EscapeString(content) + "\"/>")
}
func (h *Handlers) botBoardHTML(meta *seo.PageMeta, board models.Board) string {
desc := strings.TrimSpace(board.Description)
if desc == "" {
desc = meta.Description
}
body := fmt.Sprintf(`<h1>%s</h1><p class="meta">%s</p>`,
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("<h1>" + html.EscapeString(name) + "</h1>")
if intro != "" {
body.WriteString("<p>" + html.EscapeString(intro) + "</p>")
}
body.WriteString(`<p><a href="/projects">浏览项目</a></p>`)
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("<article>")
body.WriteString("<h1>" + html.EscapeString(post.Title) + "</h1>")
body.WriteString(`<p class="meta">`)
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("</p>")
body.WriteString(content)
body.WriteString("</article>")
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("<h1>" + html.EscapeString(name) + " 的主页</h1>")
if sig != "" {
body.WriteString("<p>" + html.EscapeString(sig) + "</p>")
}
body.WriteString(fmt.Sprintf(`<p class="meta">加入于 %s</p>`, 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 := `<h1>页面不存在</h1><p>您访问的页面不存在或已删除。</p>`
return renderBotHTML(meta, body)
}

124
routers/install/install.go Normal file
View File

@@ -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()
}
}

View File

@@ -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 ProviderGitea 等外部站点 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)
}
// 管理员 APIReact 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)
}
// 管理后台 HTMLSSR 尚未迁移;勿用 /*filepath与 /admin/login 冲突)
adminPendingHTML := `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><title>管理后台</title></head><body><h1>管理后台 SSR 迁移中</h1><p>API 仍可用UI 请暂时对照 <code>main</code> 分支 SPA或等待后续模板页。</p><p><a href="/">返回首页</a></p></body></html>`
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
}

500
routers/web/admin.go Normal file
View File

@@ -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")
}

208
routers/web/auth.go Normal file
View File

@@ -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,
})
}

110
routers/web/chrome.go Normal file
View File

@@ -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
}

224
routers/web/compose.go Normal file
View File

@@ -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, ""
}

View File

@@ -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)
}
// BoardView 侧栏板块
type BoardView struct {
ID uint
Name string
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)
}
// PostView 列表项
type PostView struct {
// HomePageData Feed
type HomePageData struct {
PageChrome
BoardName string
Sort string
Posts []PostListItem
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
// 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
}
}
if boardName != "" {
chrome.Title = boardName + " · " + chrome.SiteName
}
var uid uint
if v, ok := c.Get(auth.CtxUserID); ok {
uid, _ = v.(uint)
}
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,
ctx.HTML(http.StatusOK, "home", HomePageData{
PageChrome: chrome,
BoardName: boardName,
Sort: normalizeSort(sort),
Sort: 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)
}
})
}
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
}

35
routers/web/pending.go Normal file
View File

@@ -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))
}

193
routers/web/post.go Normal file
View File

@@ -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 := "<p>" + html.EscapeString(content) + "</p>"
_, 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
}

View File

@@ -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
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 tokenclientIP 写入上次登录记录
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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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,12 +128,13 @@ func (f *SensitiveFilter) LoadFromFile(path string) {
words = append(words, line)
}
}
if len(words) > 0 {
if len(words) == 0 {
return
}
f.mu.Lock()
f.words = words
f.mu.Unlock()
}
}
func (f *SensitiveFilter) Filter(text string) string {
f.mu.RLock()

97
services/compose_body.go Normal file
View File

@@ -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, "<p") || strings.Contains(lower, "<div") ||
strings.Contains(lower, "<h1") || strings.Contains(lower, "<ul") ||
strings.Contains(lower, "<ol") || strings.Contains(lower, "<pre") ||
strings.Contains(lower, "<blockquote") {
return raw
}
// 先转义,再恢复轻量 md 图片/链接
esc := html.EscapeString(raw)
esc = mdImageRe.ReplaceAllStringFunc(esc, func(m string) string {
sub := mdImageRe.FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
alt, src := sub[1], sub[2]
// 仅允许站内 uploads 或 http(s)
if !safeComposeURL(src) {
return m
}
return `<p><img src="` + html.EscapeString(src) + `" alt="` + alt + `"></p>`
})
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 `<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer">` + text + `</a>`
})
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, "<p><img ") {
b.WriteString(p)
continue
}
p = strings.ReplaceAll(p, "\n", "<br>\n")
b.WriteString("<p>")
b.WriteString(p)
b.WriteString("</p>\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)<img[^>]+src="([^"]+)"[^>]*>`)
s = imgRe.ReplaceAllString(s, "![]($1)")
s = regexp.MustCompile(`(?i)</p>\s*<p>`).ReplaceAllString(s, "\n\n")
s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)</?p[^>]*>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}

21
services/content_gate.go Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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 停止后台同步

48
services/install.go Normal file
View File

@@ -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")

View File

@@ -52,12 +52,14 @@ 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 {
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 密钥失败")
// 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
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if s.settings != nil {
if pemStr := strings.TrimSpace(s.settings.getString(SettingOIDCRSAPrivatePEM, "")); pemStr != "" {
key, err := parseOIDCRSAPrivateKey([]byte(pemStr))
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")
}
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)

View File

@@ -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 用 CONCATCOALESCE 三库通用
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)

129
services/session.go Normal file
View File

@@ -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())
}

View File

@@ -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,

View File

@@ -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 站点地图用的轻量用户字段

View File

@@ -0,0 +1,47 @@
{{define "admin/boards"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-admin">
<h1>板块管理</h1>
{{template "admin/nav" .}}
{{template "base/alert" .}}
<h2>新建板块</h2>
<form method="post" action="/admin/boards" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>名称 <input name="name" required value="{{.Form.Name}}"/></label>
<label>简介 <input name="description" value="{{.Form.Description}}"/></label>
<label>图标(可选) <input name="icon" value="{{.Form.Icon}}"/></label>
<label>色号 <input name="color_index" type="number" value="{{.Form.ColorIndex}}"/></label>
<label>排序 <input name="sort_order" type="number" value="{{.Form.SortOrder}}"/></label>
<button type="submit">创建</button>
</form>
<h2>已有板块</h2>
{{if .Boards}}
<ul class="j13-admin-list">
{{range .Boards}}
<li class="j13-admin-card">
<form method="post" action="/admin/boards/{{.ID}}" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<label>名称 <input name="name" required value="{{.Name}}"/></label>
<label>简介 <input name="description" value="{{.Description}}"/></label>
<label>图标 <input name="icon" value="{{.Icon}}"/></label>
<label>色号 <input name="color_index" type="number" value="{{.ColorIndex}}"/></label>
<label>排序 <input name="sort_order" type="number" value="{{.SortOrder}}"/></label>
<p class="j13-muted">已发布帖 {{.PostCount}}</p>
<button type="submit">保存</button>
</form>
<form method="post" action="/admin/boards/{{.ID}}/delete" class="j13-inline-form" onsubmit="return confirm('确认删除该板块?');">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<button type="submit" class="j13-linkbtn">删除</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="j13-empty">暂无板块</p>
{{end}}
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -0,0 +1,18 @@
{{define "admin/dashboard"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-admin">
<h1>管理后台</h1>
{{template "admin/nav" .}}
{{template "base/alert" .}}
<ul class="j13-admin-stats">
<li><strong>{{.UserCount}}</strong><span>用户</span></li>
<li><strong>{{.PostCount}}</strong><span>已发布帖</span></li>
<li><strong>{{.BoardCount}}</strong><span>板块</span></li>
<li><strong>{{.PendingPosts}}</strong><span>待审帖</span></li>
<li><strong>{{.PendingComments}}</strong><span>待审评</span></li>
</ul>
<p class="j13-muted">有待审内容时请前往 <a href="/admin/moderation">内容审核</a>。</p>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -0,0 +1,60 @@
{{define "admin/moderation"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-admin">
<h1>内容审核</h1>
{{template "admin/nav" .}}
{{template "base/alert" .}}
<h2>待审帖子({{len .Posts}}</h2>
{{if .Posts}}
<ul class="j13-admin-list">
{{range .Posts}}
<li class="j13-admin-card">
<p><a href="/post/{{.ID}}">{{.Title}}</a></p>
<p class="j13-muted">{{.AuthorName}} · {{.BoardName}} · {{.CreatedAt}}</p>
<div class="j13-admin-actions">
<form method="post" action="/admin/posts/{{.ID}}/approve" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<button type="submit">通过</button>
</form>
<form method="post" action="/admin/posts/{{.ID}}/reject" class="j13-form j13-admin-reject">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<input name="reason" required placeholder="拒绝原因" />
<button type="submit" class="j13-btn-secondary">拒绝</button>
</form>
</div>
</li>
{{end}}
</ul>
{{else}}
<p class="j13-empty">无待审帖子</p>
{{end}}
<h2>待审评论({{len .Comments}}</h2>
{{if .Comments}}
<ul class="j13-admin-list">
{{range .Comments}}
<li class="j13-admin-card">
<p class="j13-muted">#{{.Floor}} · {{.AuthorName}} · {{.CreatedAt}} · <a href="/post/{{.PostID}}">{{.PostTitle}}</a></p>
<p>{{.Excerpt}}</p>
<div class="j13-admin-actions">
<form method="post" action="/admin/comments/{{.ID}}/approve" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<button type="submit">通过</button>
</form>
<form method="post" action="/admin/comments/{{.ID}}/reject" class="j13-form j13-admin-reject">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<input name="reason" required placeholder="拒绝原因" />
<button type="submit" class="j13-btn-secondary">拒绝</button>
</form>
</div>
</li>
{{end}}
</ul>
{{else}}
<p class="j13-empty">无待审评论</p>
{{end}}
</main>
{{template "base/footer" .}}
{{end}}

9
templates/admin/nav.tmpl Normal file
View File

@@ -0,0 +1,9 @@
{{define "admin/nav"}}
<nav class="j13-admin-nav">
<a href="/admin/dashboard"{{if eq .NavActive "dashboard"}} class="is-active"{{end}}>仪表盘</a>
<a href="/admin/boards"{{if eq .NavActive "boards"}} class="is-active"{{end}}>板块</a>
<a href="/admin/moderation"{{if eq .NavActive "moderation"}} class="is-active"{{end}}>审核</a>
<a href="/admin/settings"{{if eq .NavActive "settings"}} class="is-active"{{end}}>设置</a>
<a href="/">返回前台</a>
</nav>
{{end}}

View File

@@ -0,0 +1,43 @@
{{define "admin/settings"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-admin">
<h1>站点设置</h1>
{{template "admin/nav" .}}
{{template "base/alert" .}}
<h2>品牌</h2>
<form method="post" action="/admin/settings/brand" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>站点名称 <input name="name" required value="{{.Brand.Name}}"/></label>
<label>标语 <input name="slogan" value="{{.Brand.Slogan}}"/></label>
<label>简介 <textarea name="description" rows="3">{{.Brand.Description}}</textarea></label>
<label>关键词 <input name="keywords" value="{{.Brand.Keywords}}"/></label>
<label>Logo 字标(单字) <input name="logo_mark" maxlength="4" value="{{.Brand.LogoMark}}"/></label>
<label>ICP 备案号 <input name="icp_beian" value="{{.Brand.ICPBeian}}"/></label>
<label>ICP 链接 <input name="icp_beian_url" value="{{.Brand.ICPBeianURL}}"/></label>
<button type="submit">保存品牌</button>
</form>
<h2>基础限流</h2>
<p class="j13-muted">窗口内允许的操作次数(秒)。</p>
<form method="post" action="/admin/settings/limits" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>发帖 <input name="rate_limit_post" type="number" min="1" value="{{.RatePost}}"/></label>
<label>评论 <input name="rate_limit_comment" type="number" min="1" value="{{.RateComment}}"/></label>
<label>注册 <input name="rate_limit_register" type="number" min="1" value="{{.RateReg}}"/></label>
<label>登录 <input name="rate_limit_login" type="number" min="1" value="{{.RateLogin}}"/></label>
<label>窗口秒数 <input name="rate_limit_window_sec" type="number" min="10" value="{{.RateWindow}}"/></label>
<button type="submit">保存限流</button>
</form>
<h2>敏感词(有效 {{.FilterCount}} 个)</h2>
<p class="j13-muted">每行一个词,# 开头为注释。保存后立即热更新。</p>
<form method="post" action="/admin/settings/filter-words" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>词表 <textarea name="filter_words" rows="12">{{.FilterWords}}</textarea></label>
<button type="submit">保存敏感词</button>
</form>
</main>
{{template "base/footer" .}}
{{end}}

20
templates/auth/login.tmpl Normal file
View File

@@ -0,0 +1,20 @@
{{define "auth/login"}}
{{template "base/head" .}}
<main class="j13-main j13-main--solo j13-auth">
<h1>登录</h1>
{{template "base/alert" .}}
<form method="post" action="/login" class="j13-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="redirect" value="{{.Redirect}}"/>
<label>用户名
<input name="username" required autocomplete="username" value="{{.Username}}"/>
</label>
<label>密码
<input type="password" name="password" required autocomplete="current-password"/>
</label>
<button type="submit">登录</button>
</form>
<p><a href="/register">没有账号?注册</a> · <a href="/">返回首页</a></p>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -0,0 +1,39 @@
{{define "auth/register"}}
{{template "base/head" .}}
<main class="j13-main j13-main--solo j13-auth">
<h1>注册</h1>
{{template "base/alert" .}}
<form method="post" action="/register" class="j13-form" id="register-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>用户名
<input name="username" required autocomplete="username" value="{{.Username}}" maxlength="32"/>
</label>
<label>昵称
<input name="nickname" autocomplete="nickname" value="{{.Nickname}}" maxlength="32" placeholder="可选,默认与用户名相同"/>
</label>
<label>邮箱
<input type="email" name="email" required autocomplete="email" value="{{.Email}}"/>
</label>
{{if .RequireEmailCode}}
<label>邮箱验证码
<div class="j13-form__row">
<input name="email_code" required inputmode="numeric" autocomplete="one-time-code" maxlength="8"/>
<button type="submit" formaction="/register/send-code" formnovalidate class="j13-btn-secondary">发送验证码</button>
</div>
</label>
<p class="j13-muted">邮件服务已启用,注册需验证邮箱。</p>
{{else}}
<p class="j13-muted">邮件服务未配置,可直接注册(管理员可稍后在后台启用 SMTP。</p>
{{end}}
<label>密码
<input type="password" name="password" required autocomplete="new-password" minlength="6"/>
</label>
<label>确认密码
<input type="password" name="password2" required autocomplete="new-password" minlength="6"/>
</label>
<button type="submit">注册</button>
</form>
<p><a href="/login">已有账号?登录</a> · <a href="/">返回首页</a></p>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -1,51 +0,0 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{{.Title}}</title>
{{if .Description}}<meta name="description" content="{{.Description}}"/>{{end}}
<link rel="stylesheet" href="/ssr-assets/site.css"/>
</head>
<body class="j13-body">
<header class="j13-header">
<div class="j13-header__inner">
<a class="j13-brand" href="/">
<span class="j13-brand__mark">{{.LogoMark}}</span>
<span class="j13-brand__text">
<strong>{{.SiteName}}</strong>
{{if .Slogan}}<span class="j13-brand__slogan">{{.Slogan}}</span>{{end}}
</span>
</a>
<nav class="j13-nav">
{{if .LoggedIn}}
<a href="/compose">发帖</a>
<a href="/messages">消息</a>
<a href="/profile">{{.ViewerName}}</a>
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}
{{else}}
<a href="/login">登录</a>
<a href="/register">注册</a>
{{end}}
</nav>
</div>
</header>
<div class="j13-layout">
<aside class="j13-aside j13-aside--left">
<a class="j13-aside__link{{if eq .ActiveBoard 0}} is-active{{end}}" href="/">全部帖子</a>
{{range .Boards}}
<a class="j13-aside__link{{if eq $.ActiveBoard .ID}} is-active{{end}}" href="/board/{{.ID}}">{{.Name}}</a>
{{end}}
</aside>
<main class="j13-main">
{{template "content" .}}
</main>
</div>
<footer class="j13-footer">
<p>{{.SiteName}} · Gitea 式 SSR 骨架</p>
</footer>
<script src="/ssr-assets/site.js" defer></script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,4 @@
{{define "base/alert"}}
{{if .Error}}<div class="j13-alert j13-alert--error" role="alert">{{.Error}}</div>{{end}}
{{if .Flash}}<div class="j13-flash" role="status">{{.Flash}}</div>{{end}}
{{end}}

View File

@@ -0,0 +1,8 @@
{{define "base/footer"}}
<footer class="j13-footer">
<p>{{.SiteName}}</p>
</footer>
<script src="/ssr-assets/site.js" defer></script>
</body>
</html>
{{end}}

12
templates/base/head.tmpl Normal file
View File

@@ -0,0 +1,12 @@
{{define "base/head"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{{.Title}}</title>
{{if .Description}}<meta name="description" content="{{.Description}}"/>{{end}}
<link rel="stylesheet" href="/ssr-assets/site.css"/>
</head>
<body class="j13-body">
{{end}}

View File

@@ -0,0 +1,27 @@
{{define "base/navbar"}}
<header class="j13-header">
<div class="j13-header__inner">
<a class="j13-brand" href="/">
<span class="j13-brand__mark">{{.LogoMark}}</span>
<span class="j13-brand__text">
<strong>{{.SiteName}}</strong>
{{if .Slogan}}<span class="j13-brand__slogan">{{.Slogan}}</span>{{end}}
</span>
</a>
<nav class="j13-nav">
{{if .LoggedIn}}
<a href="/compose">发帖</a>
<a href="/profile">{{.ViewerName}}</a>
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}
<form class="j13-inline-form" method="post" action="/logout">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit" class="j13-linkbtn">退出</button>
</form>
{{else}}
<a href="/register">注册</a>
<a href="/login">登录</a>
{{end}}
</nav>
</div>
</header>
{{end}}

39
templates/compose.tmpl Normal file
View File

@@ -0,0 +1,39 @@
{{define "compose"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
{{if .Flash}}<div class="j13-flash" role="status">{{.Flash}}</div>{{end}}
<main class="j13-main j13-main--solo j13-compose">
<h1>{{if .IsEdit}}编辑帖子{{else}}发帖{{end}}</h1>
{{template "base/alert" .}}
<form method="post" action="{{.FormAction}}" class="j13-form" id="compose-form" data-csrf="{{.CSRF}}" data-upload="/compose/upload">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>板块
<select name="board_id" required>
<option value="">请选择</option>
{{range .Boards}}
<option value="{{.ID}}" {{if eq $.BoardID .ID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label>标题
<input name="title" required maxlength="{{.TitleMax}}" value="{{.Title}}"/>
</label>
<label>标签
<input name="tags" maxlength="{{.TagsMax}}" value="{{.Tags}}" placeholder="逗号分隔,可选"/>
</label>
<label>正文
<textarea name="content" id="compose-content" rows="16" required maxlength="{{.ContentMax}}" placeholder="支持纯文本;图片上传后插入 Markdown 图片语法">{{.Content}}</textarea>
</label>
<div class="j13-compose__tools">
<label class="j13-btn-secondary j13-filebtn">
插入图片
<input type="file" id="compose-image" accept="image/*" hidden/>
</label>
<span class="j13-muted" id="compose-upload-status"></span>
</div>
<p class="j13-muted">本版仅发普通讨论帖;投票 / 悬赏 / 抽奖后续迭代。</p>
<button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button>
</form>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -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

View File

@@ -1,6 +1,4 @@
{{define "home"}}{{template "base" .}}{{end}}
{{define "content"}}
{{define "home/feed"}}
<section class="j13-feed">
<header class="j13-feed__header">
<h1 class="j13-feed__title">{{if .BoardName}}{{.BoardName}}{{else}}全部帖子{{end}}</h1>
@@ -10,14 +8,13 @@
<a class="{{if eq .Sort "hot"}}is-active{{end}}" href="{{sortURL .ActiveBoard "hot"}}">热门讨论</a>
</div>
</header>
{{if not .Posts}}
<p class="j13-empty">暂无帖子。登录后可以发第一帖。</p>
<p class="j13-empty">暂无帖子。</p>
{{else}}
<ul class="j13-post-list">
{{range .Posts}}
<li class="j13-post-item">
<a class="j13-post-item__title" href="/post/{{.ID}}">{{.Title}}</a>
<a class="j13-post-item__title" href="{{postURL .ID}}">{{.Title}}</a>
<div class="j13-post-item__meta">
{{if .Pinned}}<span class="j13-tag j13-tag--pin">置顶</span>{{end}}
{{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}}
@@ -30,7 +27,6 @@
{{end}}
</ul>
{{end}}
{{if or .HasPrev .HasMore}}
<nav class="j13-pager">
{{if .HasPrev}}<a href="{{pageURL .ActiveBoard .Sort .PrevPage}}">上一页</a>{{end}}

12
templates/home/home.tmpl Normal file
View File

@@ -0,0 +1,12 @@
{{define "home"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
{{if .Flash}}<div class="j13-flash" role="status">{{.Flash}}</div>{{end}}
<div class="j13-layout">
{{template "shared/board_aside" .}}
<main class="j13-main">
{{template "home/feed" .}}
</main>
</div>
{{template "base/footer" .}}
{{end}}

37
templates/install.tmpl Normal file
View File

@@ -0,0 +1,37 @@
{{define "install"}}
{{template "base/head" .}}
<main class="j13-main j13-main--solo j13-install">
<h1>安装姜十三论坛</h1>
<p class="j13-muted">首次运行配置站点与管理员账号。</p>
{{template "base/alert" .}}
<form method="post" action="/install" class="j13-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<fieldset>
<legend>站点</legend>
<label>站点名称
<input name="site_name" required value="{{.SiteName}}" maxlength="64"/>
</label>
</fieldset>
<fieldset>
<legend>管理员</legend>
<label>用户名
<input name="admin_username" required autocomplete="username" value="{{.AdminUsername}}" pattern="[a-zA-Z0-9_]{3,32}"/>
</label>
<label>邮箱
<input type="email" name="admin_email" required autocomplete="email" value="{{.AdminEmail}}"/>
</label>
<label>昵称
<input name="admin_nickname" value="{{.AdminNickname}}" maxlength="32"/>
</label>
<label>密码
<input type="password" name="admin_password" required autocomplete="new-password" minlength="6"/>
</label>
<label>确认密码
<input type="password" name="admin_password2" required autocomplete="new-password" minlength="6"/>
</label>
</fieldset>
<button type="submit">安装</button>
</form>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -0,0 +1,10 @@
{{define "post-install"}}
{{template "base/head" .}}
<main class="j13-main j13-main--solo j13-install">
<h1>安装完成</h1>
<p>站点已就绪,正在进入论坛…</p>
<p><a href="/">立即进入</a></p>
<meta http-equiv="refresh" content="2;url=/"/>
</main>
{{template "base/footer" .}}
{{end}}

69
templates/post/body.tmpl Normal file
View File

@@ -0,0 +1,69 @@
{{define "post/body"}}
<article class="j13-post">
<header class="j13-post__header">
<h1 class="j13-post__title">{{.PostTitle}}</h1>
<div class="j13-post__meta">
{{if .Pinned}}<span class="j13-tag j13-tag--pin">置顶</span>{{end}}
{{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}}
{{if .PostTypeLabel}}<span class="j13-tag">{{.PostTypeLabel}}</span>{{end}}
{{if .BoardName}}<a class="j13-tag" href="/board/{{.BoardID}}">{{.BoardName}}</a>{{end}}
<span>{{.AuthorName}}</span>
<span>{{.CreatedLabel}}</span>
<span>{{.ViewCount}} 阅读</span>
</div>
{{if .LoggedIn}}
<div class="j13-post__actions">
<form method="post" action="/post/{{.PostID}}/like" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit">{{if .Liked}}取消赞{{else}}赞{{end}} ({{.LikeCount}})</button>
</form>
<form method="post" action="/post/{{.PostID}}/favorite" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit">{{if .Favorited}}取消收藏{{else}}收藏{{end}}</button>
</form>
{{if .CanEdit}}<a class="j13-linkbtn" href="/post/{{.PostID}}/edit">编辑</a>{{end}}
</div>
{{end}}
</header>
<div class="j13-post__content post-detail-content">{{safeHTML .BodyHTML}}</div>
</article>
<section class="j13-comments" id="comments">
<h2>评论 ({{.CommentCount}})</h2>
{{if not .Comments}}
<p class="j13-empty">暂无评论。</p>
{{else}}
<ul class="j13-comment-list">
{{range .Comments}}
<li class="j13-comment" id="floor-{{.Floor}}">
<div class="j13-comment__meta">
<span class="j13-comment__floor">#{{.Floor}}</span>
<span>{{.AuthorName}}</span>
<span>{{.CreatedLabel}}</span>
</div>
{{if .ContentHidden}}
<p class="j13-muted">(私密评论不可见)</p>
{{else}}
<div class="j13-comment__body">{{safeHTML .Content}}</div>
{{end}}
</li>
{{end}}
</ul>
{{end}}
{{if .LoggedIn}}
{{if .CommentsLocked}}
<p class="j13-muted">评论已锁定。</p>
{{else}}
<form class="j13-comment-form" method="post" action="/post/{{.PostID}}/comments">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label for="comment-content">发表评论</label>
<textarea id="comment-content" name="content" rows="4" required maxlength="8000"></textarea>
<button type="submit">提交</button>
</form>
{{end}}
{{else}}
<p><a href="/login?redirect={{.PostPath}}">登录</a> 后回复。</p>
{{end}}
</section>
{{end}}

12
templates/post/view.tmpl Normal file
View File

@@ -0,0 +1,12 @@
{{define "post"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
{{if .Flash}}<div class="j13-flash" role="status">{{.Flash}}</div>{{end}}
<div class="j13-layout">
{{template "shared/board_aside" .}}
<main class="j13-main">
{{template "post/body" .}}
</main>
</div>
{{template "base/footer" .}}
{{end}}

View File

@@ -0,0 +1,8 @@
{{define "shared/board_aside"}}
<aside class="j13-aside j13-aside--left">
<a class="j13-aside__link{{if eq .ActiveBoard 0}} is-active{{end}}" href="/">全部帖子</a>
{{range .Boards}}
<a class="j13-aside__link{{if eq $.ActiveBoard .ID}} is-active{{end}}" href="/board/{{.ID}}">{{.Name}}</a>
{{end}}
</aside>
{{end}}

21
templates/status/404.tmpl Normal file
View File

@@ -0,0 +1,21 @@
{{define "status/404"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-main--solo">
<h1>页面不存在</h1>
<p>您访问的页面不存在或已删除。</p>
<p><a href="/">返回首页</a></p>
</main>
{{template "base/footer" .}}
{{end}}
{{define "status/pending"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-main--solo">
<h1>{{.Heading}}</h1>
<p>{{.Message}}</p>
<p><a href="/">返回首页</a></p>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -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; }

View File

@@ -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 || "上传失败";
}
});
})();