feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings; 落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 传入。
|
||||
|
||||
93
README.md
93
README.md
@@ -110,12 +110,12 @@
|
||||
- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
|
||||
- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
|
||||
- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
|
||||
- 可选:邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储
|
||||
- 可选:邮件验证码、OIDC Provider、S3 兼容对象存储(Gitea 仓库同步后置)
|
||||
|
||||
### 部署体验
|
||||
|
||||
- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
|
||||
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
|
||||
- **单二进制** — `go:embed` 打包模板与 SSR 资源
|
||||
- **可切换数据库** — 默认 SQLite;可选 PostgreSQL / MySQL(Env 引导)
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||
- **系统服务** — 内置 Linux systemd / Windows Service 注册
|
||||
- **Docker 单容器** — 多阶段镜像,挂载 `data/` 即可持久化
|
||||
@@ -171,7 +171,7 @@ docker compose up -d --build
|
||||
make compose-up
|
||||
```
|
||||
|
||||
浏览器打开 `http://localhost:3000/register` 注册;**首个用户自动成为管理员**。
|
||||
浏览器打开 `http://localhost:3000/install` 完成安装向导(站点名 + 管理员)。
|
||||
|
||||
**拉取已构建镜像(Docker Hub):**
|
||||
|
||||
@@ -184,7 +184,7 @@ docker run -d --name jiang13 \
|
||||
hangzhang714128/jiang13-forum:latest
|
||||
```
|
||||
|
||||
**数据持久化:** 容器内 `/data` 对应 SQLite、上传、日志与 JWT 密钥,与下方「数据目录」结构一致。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户),适配 1Panel 等面板挂载的目录。
|
||||
**数据持久化:** 容器内 `/data` 对应 SQLite(默认)、上传、日志与 JWT/OIDC 密钥。可用 Docker volume 或绑定宿主机目录。镜像启动时会自动将 `/data` 卷属主修正为 uid `1000`(`jiang13` 用户)。
|
||||
|
||||
**若使用旧版镜像仍报 permission denied**,可在宿主机执行:`chown -R 1000:1000 /你的数据目录`
|
||||
|
||||
@@ -194,10 +194,12 @@ docker run -d --name jiang13 \
|
||||
|------|------|
|
||||
| `JIANG13_HTTP_PORT` | HTTP 端口(默认 `3000`) |
|
||||
| `JIANG13_DATA` | 数据目录(默认 `/data`) |
|
||||
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||
| `JIANG13_CONFIG` | 配置文件路径 |
|
||||
| `JIANG13_DB_TYPE` | `sqlite`(默认)\| `postgres` \| `mysql` |
|
||||
| `JIANG13_DB_DSN` | 完整 DSN(非 sqlite 时推荐) |
|
||||
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||
|
||||
JWT 自动写入 `/data/.jwt_secret`,无需 Env。
|
||||
|
||||
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||
|
||||
**发布镜像到 Docker Hub(手动):**
|
||||
@@ -235,90 +237,72 @@ docker push hangzhang714128/jiang13-forum:latest
|
||||
1. 容器镜像填 `hangzhang714128/jiang13-forum:latest`
|
||||
2. 端口映射 `3000:3000`
|
||||
3. 挂载数据卷到容器内 `/data`(镜像会自动修正目录权限)
|
||||
4. 首次访问 `http://服务器IP:3000/register` 注册管理员
|
||||
4. 首次访问 `http://服务器IP:3000/install` 完成安装
|
||||
|
||||
### 3. 直接启动(二进制)
|
||||
|
||||
把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`):
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\dist\jiang13.exe
|
||||
.\dist\jiang13.exe --data .\dist\data
|
||||
|
||||
# Linux / macOS
|
||||
./dist/jiang13
|
||||
./dist/jiang13 --data ./data
|
||||
```
|
||||
|
||||
也可先复制示例配置再改端口 / 数据目录:
|
||||
|
||||
```bash
|
||||
cp app.ini.example /opt/jiang13/app.ini
|
||||
# 编辑 app.ini 后:
|
||||
./jiang13
|
||||
```
|
||||
默认 SQLite,库文件在 `{DATA}/jiang13.db`。无 `app.ini`。
|
||||
|
||||
### 4. 首次使用
|
||||
|
||||
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||
2. **第一个注册的用户自动成为管理员**
|
||||
3. 登录后访问 `http://localhost:3000/admin` 进入后台
|
||||
1. 浏览器打开 `http://localhost:3000/install`
|
||||
2. 填写站点名与管理员账号
|
||||
3. 完成后登录,访问管理后台配置品牌等(热更新,无需重启)
|
||||
|
||||
### 配置文件(`app.ini`)
|
||||
### 配置分层(无 INI)
|
||||
|
||||
默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。
|
||||
| 层 | 内容 | 需重启 |
|
||||
|----|------|--------|
|
||||
| CLI / Env | 端口、数据目录、数据库类型与 DSN | 是 |
|
||||
| `data/.jwt_secret`、`.oidc_rsa.pem` | 密钥 | 换密钥需重启 |
|
||||
| DB `forum_settings` | 品牌、邮件、OIDC 开关、限流、存储… | 否 |
|
||||
|
||||
```ini
|
||||
[server]
|
||||
HTTP_PORT = 3000
|
||||
|
||||
[paths]
|
||||
DATA = data
|
||||
|
||||
[security]
|
||||
JWT_SECRET =
|
||||
```
|
||||
|
||||
完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。
|
||||
|
||||
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
|
||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
||||
|
||||
### 启动参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
|
||||
| `--config` | `{work-path}/app.ini` | 配置文件路径 |
|
||||
| `--port` | (读配置 / `3000`) | HTTP 监听端口 |
|
||||
| `--data` | (读配置 / `data`) | 数据目录 |
|
||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||
| `--work-path` | 可执行文件目录 | 工作目录 |
|
||||
| `--port` | `3000` | HTTP 监听端口 |
|
||||
| `--http-addr` | (空) | 监听地址 |
|
||||
| `--data` | `data` | 数据目录 |
|
||||
| `--db-type` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
||||
| `--db-dsn` | (sqlite 默认 `{data}/jiang13.db`) | 完整 DSN |
|
||||
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
||||
|
||||
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`
|
||||
**环境变量:** `JIANG13_HTTP_PORT`、`JIANG13_HTTP_ADDR`、`JIANG13_DATA`、`JIANG13_WORK_PATH`、`JIANG13_DB_TYPE`、`JIANG13_DB_DSN`、以及 `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE`。
|
||||
|
||||
PostgreSQL / MySQL 示例见 [`docs/rebuild-spec/07-config-ops.md`](docs/rebuild-spec/07-config-ops.md)。
|
||||
|
||||
### 5. 注册为系统服务(可选)
|
||||
|
||||
将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
|
||||
|
||||
**Ubuntu / Linux(systemd,需 root):**
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/jiang13
|
||||
sudo cp jiang13 /opt/jiang13/
|
||||
sudo /opt/jiang13/jiang13 --service install
|
||||
sudo /opt/jiang13/jiang13 --work-path /opt/jiang13 --data /opt/jiang13/data --service install
|
||||
sudo /opt/jiang13/jiang13 --service start
|
||||
sudo systemctl enable jiang13
|
||||
```
|
||||
|
||||
**Windows(Windows Service,需管理员 PowerShell):**
|
||||
**Windows(管理员 PowerShell):**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path C:\jiang13 | Out-Null
|
||||
Copy-Item .\jiang13.exe C:\jiang13\
|
||||
C:\jiang13\jiang13.exe --service install
|
||||
C:\jiang13\jiang13.exe --work-path C:\jiang13 --data C:\jiang13\data --service install
|
||||
C:\jiang13\jiang13.exe --service start
|
||||
```
|
||||
|
||||
> 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。
|
||||
> 改端口或 `DB_*` 后执行 `--service restart`(必要时重装服务以更新参数)。日志:`data/jiang13.log`。
|
||||
|
||||
---
|
||||
|
||||
@@ -326,7 +310,7 @@ C:\jiang13\jiang13.exe --service start
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite · `html/template` |
|
||||
| **后端 / SSR** | Go 1.26 · Gin · GORM · SQLite / PostgreSQL / MySQL · `html/template` |
|
||||
| **渐进资源** | `web_src/`(构建到 `public/assets/`,URL `/ssr-assets/`) |
|
||||
| **构建** | `web_src` → `go:embed` templates + assets,单二进制发布 |
|
||||
| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
|
||||
@@ -357,8 +341,7 @@ make run
|
||||
```
|
||||
jiang13-forum/ # 分支 rebuild/gitea-ssr
|
||||
├── cmd/jiang13/ # 程序入口(含系统服务注册)
|
||||
├── config/ # app.ini 与命令行配置
|
||||
├── app.ini.example
|
||||
├── config/ # CLI / Env 引导配置(无 INI)
|
||||
├── Dockerfile # web_src → Go → Alpine
|
||||
├── docker-compose.yml
|
||||
├── models/ # GORM 模型
|
||||
|
||||
@@ -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 =
|
||||
@@ -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",
|
||||
|
||||
327
config/config.go
327
config/config.go
@@ -1,50 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||
const (
|
||||
defaultPort = 3000
|
||||
defaultDataRel = "data"
|
||||
|
||||
DBTypeSQLite = "sqlite"
|
||||
DBTypePostgres = "postgres"
|
||||
DBTypeMySQL = "mysql"
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端(管理后台运行时配置)
|
||||
StorageTypeLocal = "local"
|
||||
StorageTypeS3 = "s3"
|
||||
)
|
||||
|
||||
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
|
||||
type Config struct {
|
||||
// 工作目录(默认可执行文件所在目录)
|
||||
WorkPath string
|
||||
// 配置文件绝对路径
|
||||
ConfigFile string
|
||||
// 监听端口
|
||||
Port int
|
||||
// 数据目录:SQLite、上传、日志(绝对路径)
|
||||
DataDir string
|
||||
// JWT 签名密钥
|
||||
JWTSecret string
|
||||
// 日志文件路径
|
||||
LogFile string
|
||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||
ServiceAction string
|
||||
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||
DevMode bool
|
||||
// DatabaseConfig 数据库引导配置(需重启)
|
||||
type DatabaseConfig struct {
|
||||
Type string // sqlite | postgres | mysql
|
||||
DSN string // 非空则优先
|
||||
Host string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
SSLMode string // postgres
|
||||
// SQLite 文件路径(Type=sqlite 时由 DataDir 推导或 DSN)
|
||||
SQLitePath string
|
||||
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetimeSec int
|
||||
}
|
||||
|
||||
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
||||
//
|
||||
// 优先级(高 → 低):命令行显式参数 > 环境变量 > app.ini > 内置默认值
|
||||
// Config 进程引导配置:仅 CLI / 环境变量(无 INI)
|
||||
type Config struct {
|
||||
WorkPath string
|
||||
HTTPAddr string // 空表示 0.0.0.0
|
||||
Port int
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
LogFile string
|
||||
ServiceAction string
|
||||
DevMode bool
|
||||
DB DatabaseConfig
|
||||
}
|
||||
|
||||
// Parse 解析命令行与环境变量并准备数据目录
|
||||
// 优先级:命令行显式参数 > 环境变量 > 内置默认
|
||||
func Parse() (*Config, error) {
|
||||
configFlag := flag.String("config", "", "配置文件路径(默认:工作目录/app.ini)")
|
||||
workFlag := flag.String("work-path", "", "工作目录(默认:可执行文件所在目录)")
|
||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(覆盖配置文件;0 表示不覆盖)")
|
||||
dataFlag := flag.String("data", "", "数据存储目录(覆盖配置文件)")
|
||||
jwtFlag := flag.String("jwt-secret", "", "JWT 签名密钥(覆盖配置文件;留空则自动生成)")
|
||||
portFlag := flag.Int("port", 0, "HTTP 监听端口(0 表示用环境变量或默认 3000)")
|
||||
addrFlag := flag.String("http-addr", "", "HTTP 监听地址(默认空=全接口)")
|
||||
dataFlag := flag.String("data", "", "数据存储目录")
|
||||
dbTypeFlag := flag.String("db-type", "", "数据库类型:sqlite|postgres|mysql")
|
||||
dbDSNFlag := flag.String("db-dsn", "", "数据库 DSN(优先于拆分参数)")
|
||||
dbHostFlag := flag.String("db-host", "", "数据库主机")
|
||||
dbUserFlag := flag.String("db-user", "", "数据库用户")
|
||||
dbPassFlag := flag.String("db-pass", "", "数据库密码")
|
||||
dbNameFlag := flag.String("db-name", "", "数据库名")
|
||||
dbSSLFlag := flag.String("db-sslmode", "", "PostgreSQL sslmode")
|
||||
serviceFlag := flag.String("service", "", "系统服务控制:install|uninstall|start|stop|restart|status")
|
||||
devFlag := flag.Bool("dev", false, "开发模式:代理前端到 Vite 开发服务器(默认 http://localhost:5173)")
|
||||
devFlag := flag.Bool("dev", false, "开发模式")
|
||||
_ = flag.String("config", "", "已废弃:不再使用 ini 配置文件")
|
||||
_ = flag.String("jwt-secret", "", "已废弃:JWT 仅使用 data/.jwt_secret")
|
||||
flag.Parse()
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(*serviceFlag))
|
||||
@@ -52,35 +80,13 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("无效的 -service 动作 %q,可选:install|uninstall|start|stop|restart|status", *serviceFlag)
|
||||
}
|
||||
|
||||
workPathInput := strings.TrimSpace(*workFlag)
|
||||
if workPathInput == "" {
|
||||
workPathInput = envOrDefault(envWorkPath)
|
||||
}
|
||||
workPathInput := firstNonEmpty(*workFlag, envOrDefault(envWorkPath))
|
||||
workPath, err := resolveWorkPath(workPathInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configInput := strings.TrimSpace(*configFlag)
|
||||
if configInput == "" {
|
||||
configInput = envOrDefault(envConfig)
|
||||
}
|
||||
configFile, err := resolveConfigPath(workPath, configInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileCfg := defaultFileSettings()
|
||||
configExists := false
|
||||
if st, err := os.Stat(configFile); err == nil && !st.IsDir() {
|
||||
configExists = true
|
||||
fileCfg, err = loadAppINI(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
port := fileCfg.Port
|
||||
port := defaultPort
|
||||
if p := envIntOrZero(envHTTPPort); p > 0 {
|
||||
port = p
|
||||
}
|
||||
@@ -88,65 +94,35 @@ func Parse() (*Config, error) {
|
||||
port = *portFlag
|
||||
}
|
||||
|
||||
dataInput := fileCfg.DataRel
|
||||
if v := envOrDefault(envData); v != "" {
|
||||
dataInput = v
|
||||
}
|
||||
if strings.TrimSpace(*dataFlag) != "" {
|
||||
dataInput = *dataFlag
|
||||
}
|
||||
httpAddr := firstNonEmpty(*addrFlag, envOrDefault(envHTTPAddr))
|
||||
|
||||
dataInput := firstNonEmpty(*dataFlag, envOrDefault(envData), defaultDataRel)
|
||||
absData, err := absPath(workPath, dataInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
jwtSecret := fileCfg.JWTSecret
|
||||
if v := envOrDefault(envJWTSecret); v != "" {
|
||||
jwtSecret = v
|
||||
}
|
||||
if strings.TrimSpace(*jwtFlag) != "" {
|
||||
jwtSecret = strings.TrimSpace(*jwtFlag)
|
||||
dbCfg, err := buildDatabaseConfig(absData, dbFlags{
|
||||
Type: *dbTypeFlag, DSN: *dbDSNFlag, Host: *dbHostFlag,
|
||||
User: *dbUserFlag, Pass: *dbPassFlag, Name: *dbNameFlag, SSL: *dbSSLFlag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
WorkPath: workPath,
|
||||
ConfigFile: configFile,
|
||||
HTTPAddr: httpAddr,
|
||||
Port: port,
|
||||
DataDir: absData,
|
||||
JWTSecret: jwtSecret,
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
DevMode: *devFlag,
|
||||
DB: dbCfg,
|
||||
}
|
||||
|
||||
needDirs := action == "" || action == "install"
|
||||
if needDirs {
|
||||
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||
if !configExists {
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
if err := writeAppINI(configFile, fileSettings{
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
|
||||
} else if action == "install" {
|
||||
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
iniJWT := fileCfg.JWTSecret
|
||||
if strings.TrimSpace(*jwtFlag) != "" {
|
||||
iniJWT = jwtSecret
|
||||
}
|
||||
if err := writeAppINI(configFile, fileSettings{
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
JWTSecret: iniJWT,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("更新配置文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureDataDirs(absData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,6 +134,96 @@ func Parse() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type dbFlags struct {
|
||||
Type, DSN, Host, User, Pass, Name, SSL string
|
||||
}
|
||||
|
||||
func buildDatabaseConfig(dataDir string, f dbFlags) (DatabaseConfig, error) {
|
||||
typ := strings.ToLower(firstNonEmpty(f.Type, envOrDefault(envDBType), DBTypeSQLite))
|
||||
switch typ {
|
||||
case "sqlite", "sqlite3":
|
||||
typ = DBTypeSQLite
|
||||
case "postgres", "postgresql", "pg":
|
||||
typ = DBTypePostgres
|
||||
case "mysql", "mariadb":
|
||||
typ = DBTypeMySQL
|
||||
default:
|
||||
return DatabaseConfig{}, fmt.Errorf("不支持的数据库类型 %q,可选:sqlite|postgres|mysql", typ)
|
||||
}
|
||||
|
||||
out := DatabaseConfig{
|
||||
Type: typ,
|
||||
DSN: firstNonEmpty(f.DSN, envOrDefault(envDBDSN)),
|
||||
Host: firstNonEmpty(f.Host, envOrDefault(envDBHost)),
|
||||
User: firstNonEmpty(f.User, envOrDefault(envDBUser)),
|
||||
Password: firstNonEmpty(f.Pass, envOrDefault(envDBPass)),
|
||||
Name: firstNonEmpty(f.Name, envOrDefault(envDBName)),
|
||||
SSLMode: firstNonEmpty(f.SSL, envOrDefault(envDBSSLMode), "disable"),
|
||||
MaxOpenConns: envIntDefault(envDBMaxOpen, 0),
|
||||
MaxIdleConns: envIntDefault(envDBMaxIdle, 0),
|
||||
ConnMaxLifetimeSec: envIntDefault(envDBConnLife, 0),
|
||||
}
|
||||
|
||||
if typ == DBTypeSQLite {
|
||||
if out.DSN != "" {
|
||||
out.SQLitePath = out.DSN
|
||||
} else {
|
||||
out.SQLitePath = filepath.Join(dataDir, "jiang13.db")
|
||||
out.DSN = out.SQLitePath
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 1
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 1
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if out.DSN == "" {
|
||||
dsn, err := buildDSN(out)
|
||||
if err != nil {
|
||||
return DatabaseConfig{}, err
|
||||
}
|
||||
out.DSN = dsn
|
||||
}
|
||||
if out.MaxOpenConns == 0 {
|
||||
out.MaxOpenConns = 25
|
||||
}
|
||||
if out.MaxIdleConns == 0 {
|
||||
out.MaxIdleConns = 5
|
||||
}
|
||||
if out.ConnMaxLifetimeSec == 0 {
|
||||
out.ConnMaxLifetimeSec = 300
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildDSN(c DatabaseConfig) (string, error) {
|
||||
if c.Host == "" || c.User == "" || c.Name == "" {
|
||||
return "", fmt.Errorf("%s 需要 JIANG13_DB_DSN,或 JIANG13_DB_HOST/USER/NAME(及可选 PASS)", c.Type)
|
||||
}
|
||||
switch c.Type {
|
||||
case DBTypePostgres:
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(c.User, c.Password),
|
||||
Host: c.Host,
|
||||
Path: "/" + c.Name,
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sslmode", c.SSLMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
case DBTypeMySQL:
|
||||
// 特殊字符密码请直接用 JIANG13_DB_DSN;此处为拆分参数简易拼接
|
||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true&loc=Local&charset=utf8mb4",
|
||||
c.User, c.Password, c.Host, c.Name), nil
|
||||
default:
|
||||
return "", fmt.Errorf("无法为 %s 拼接 DSN", c.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWorkPath(flagVal string) (string, error) {
|
||||
if strings.TrimSpace(flagVal) != "" {
|
||||
abs, err := filepath.Abs(flagVal)
|
||||
@@ -169,13 +235,6 @@ func resolveWorkPath(flagVal string) (string, error) {
|
||||
return defaultWorkPath()
|
||||
}
|
||||
|
||||
func resolveConfigPath(workPath, flagVal string) (string, error) {
|
||||
if strings.TrimSpace(flagVal) != "" {
|
||||
return absPath(workPath, flagVal)
|
||||
}
|
||||
return filepath.Join(workPath, defaultConfName), nil
|
||||
}
|
||||
|
||||
func ensureDataDirs(dataDir string) error {
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建数据目录失败: %w", err)
|
||||
@@ -194,21 +253,25 @@ func ensureDataDirs(dataDir string) error {
|
||||
|
||||
func (c *Config) resolveJWT() error {
|
||||
secretFile := filepath.Join(c.DataDir, ".jwt_secret")
|
||||
if c.JWTSecret != "" {
|
||||
_ = os.WriteFile(secretFile, []byte(c.JWTSecret), 0600)
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(bytesTrimSpace(data)) > 0 {
|
||||
c.JWTSecret = string(bytesTrimSpace(data))
|
||||
return nil
|
||||
}
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||
c.JWTSecret = string(data)
|
||||
return nil
|
||||
sec, err := generateRandomSecret(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.JWTSecret = generateRandomSecret(32)
|
||||
c.JWTSecret = sec
|
||||
if err := os.WriteFile(secretFile, []byte(c.JWTSecret), 0600); err != nil {
|
||||
return fmt.Errorf("写入 JWT 密钥失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bytesTrimSpace(b []byte) []byte {
|
||||
return []byte(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func validServiceAction(action string) bool {
|
||||
switch action {
|
||||
case "install", "uninstall", "start", "stop", "restart", "status":
|
||||
@@ -218,36 +281,60 @@ func validServiceAction(action string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath 返回 SQLite 数据库文件路径
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "jiang13.db")
|
||||
// ListenAddr 返回 host:port
|
||||
func (c *Config) ListenAddr() string {
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Sprintf(":%d", c.Port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", c.HTTPAddr, c.Port)
|
||||
}
|
||||
|
||||
// SQLitePath 兼容旧调用:仅 sqlite 有意义
|
||||
func (c *Config) DBPath() string {
|
||||
if c.DB.Type == DBTypeSQLite {
|
||||
return c.DB.SQLitePath
|
||||
}
|
||||
return c.DB.DSN
|
||||
}
|
||||
|
||||
// AvatarUploadDir 返回头像上传目录
|
||||
func (c *Config) AvatarUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||
}
|
||||
|
||||
// PostImageUploadDir 返回帖子正文图片上传目录
|
||||
func (c *Config) PostImageUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||
}
|
||||
|
||||
// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
|
||||
func (c *Config) SiteUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "site")
|
||||
}
|
||||
|
||||
// FilterWordsPath 返回敏感词配置文件路径
|
||||
func (c *Config) FilterWordsPath() string {
|
||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||
}
|
||||
|
||||
func generateRandomSecret(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
func generateRandomSecret(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[i%len(chars)]
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("生成密钥失败: %w", err)
|
||||
}
|
||||
return string(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envIntDefault(key string, def int) int {
|
||||
v := envOrDefault(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
107
config/ini.go
107
config/ini.go
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 均可编译与运行。
|
||||
|
||||
|
||||
@@ -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) 供参考。
|
||||
|
||||
|
||||
@@ -15,53 +15,53 @@
|
||||
- [ ] 浅色 / 暗色主题;跟随系统偏好并本地记忆
|
||||
- [ ] 响应式:平板/手机收起侧栏
|
||||
- [ ] 长列表虚拟滚动或等价流畅方案
|
||||
- [ ] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
|
||||
- [ ] 板块筛选:全部 + 单板块
|
||||
- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
|
||||
- [x] 板块筛选:全部 + 单板块
|
||||
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
||||
- [ ] 搜索:关键词、标签、作者、仅标题(`title_only`)
|
||||
- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)
|
||||
- [ ] 登录用户右栏/侧边:签到与抽奖入口
|
||||
- [ ] 下拉刷新(移动端)
|
||||
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)
|
||||
- [ ] 404 页
|
||||
- [x] 404 页
|
||||
|
||||
---
|
||||
|
||||
## B. 认证与个人中心
|
||||
|
||||
- [ ] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)
|
||||
- [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register`
|
||||
- [ ] 图形验证码接口(注册流程)
|
||||
- [ ] 登录 / 登出(会话 Cookie)
|
||||
- [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销
|
||||
- [ ] 忘记密码:邮箱验证码 + 重置
|
||||
- [ ] 注册配置接口:是否首用户、邮件是否就绪、是否开放注册
|
||||
- [ ] 首个用户自动成为管理员
|
||||
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
||||
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
|
||||
- [ ] 个人中心:改昵称、签名、密码、上传头像(可裁剪)
|
||||
- [ ] 个人活动统计:帖数、评数、收藏数、获赞
|
||||
- [ ] 公开用户主页 `/user/:id`(无邮箱)
|
||||
- [ ] 禁言用户无法使用需登录写接口
|
||||
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
|
||||
|
||||
---
|
||||
|
||||
## C. 板块
|
||||
|
||||
- [ ] 列出板块(含帖数等展示字段)
|
||||
- [ ] 管理员:创建 / 改 / 删板块
|
||||
- [ ] 板块名称、描述、图标、色板索引、排序
|
||||
- [ ] 默认板块保障(空站可引导创建)
|
||||
- [x] 列出板块(含帖数等展示字段)— 前台侧栏 + Admin
|
||||
- [x] 管理员:创建 / 改 / 删板块 — SSR `/admin/boards`
|
||||
- [x] 板块名称、描述、图标、色板索引、排序
|
||||
- [x] 默认板块保障(空站可引导创建)
|
||||
|
||||
---
|
||||
|
||||
## D. 帖子(通用)
|
||||
|
||||
- [ ] 发帖:选板块、标题、标签、正文
|
||||
- [ ] 正文图片上传
|
||||
- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)
|
||||
- [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal)
|
||||
- [x] 正文图片上传 — `/compose/upload` + Markdown 插入
|
||||
- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— 本分支改用 Markdown textarea 渐进增强
|
||||
- [ ] Markdown 编辑模式(与富文本互转/双模)
|
||||
- [ ] 编辑帖子(时限、锁帖约束)
|
||||
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
|
||||
- [ ] 删除帖子 → 软删进回收站
|
||||
- [ ] 修订历史列表与单条详情(可做 diff)
|
||||
- [ ] 点赞切换;收藏切换;收藏列表页
|
||||
- [ ] 浏览量(可 `skip_view=1` 跳过计数)
|
||||
- [x] 点赞切换;收藏切换;(收藏列表页未迁)
|
||||
- [x] 浏览量 — 详情页计数
|
||||
- [ ] 举报帖子
|
||||
- [ ] 内容审核状态展示(作者可见待审/被拒)
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
- [ ] 精华 / 取消
|
||||
- [ ] 禁止编辑(edit lock)
|
||||
- [ ] 禁止评论 / 结贴(comments lock)
|
||||
- [ ] 审核通过 / 拒绝(拒绝可通知作者)
|
||||
- [x] 审核通过 / 拒绝(拒绝可通知作者)— SSR `/admin/moderation`
|
||||
- [ ] 回收站:恢复 / 彻底删除
|
||||
|
||||
---
|
||||
@@ -108,7 +108,7 @@
|
||||
- [ ] @ 提及 → 通知
|
||||
- [ ] 回复提醒(站内信 + 可选邮件)
|
||||
- [ ] 审核中 / 被拒评论可见性规则
|
||||
- [ ] 管理员:通过 / 拒绝 / 回收站 / 查看评论修订
|
||||
- [x] 管理员:通过 / 拒绝待审评论 — SSR `/admin/moderation`(回收站/修订未迁)
|
||||
|
||||
---
|
||||
|
||||
@@ -155,11 +155,13 @@
|
||||
|
||||
---
|
||||
|
||||
## K. Gitea 码桶
|
||||
## K. Gitea 码桶(**后置**)
|
||||
|
||||
- [ ] 后台开关、Base URL、Token、同步间隔
|
||||
- [ ] 手动同步 + 后台定时同步
|
||||
- [ ] 前台 `/projects` 列表与搜索
|
||||
> 本迭代**不做**产品化同步:不启后台定时任务、不挂管理入口。表结构与 settings 键可保留兼容。
|
||||
|
||||
- [ ] (后置)后台开关、Base URL、Token、同步间隔
|
||||
- [ ] (后置)手动同步 + 后台定时同步
|
||||
- [ ] (后置)前台 `/projects` 列表与搜索
|
||||
|
||||
---
|
||||
|
||||
@@ -183,11 +185,11 @@
|
||||
|
||||
## N. 管理后台其它
|
||||
|
||||
- [ ] 仪表盘:计数 + 待审帖/评/举报/友链 + 最近帖
|
||||
- [ ] 敏感词文件读写
|
||||
- [ ] 论坛限流与字数等 Limits
|
||||
- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 — SSR `/admin/dashboard`(举报/友链待迁)
|
||||
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
|
||||
- [x] 基础限流(post/comment/register/login/window)— SSR;完整 Limits 字数等未迁
|
||||
- [ ] SMTP 配置与测试信
|
||||
- [ ] 站点品牌:名称、标语、简介、keywords、Logo、Favicon、OG 图、ICP
|
||||
- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR(Logo/Favicon/OG 上传未迁)
|
||||
- [ ] SQLite 一键备份与下载
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ erDiagram
|
||||
User ||--o{ Media : uploads
|
||||
```
|
||||
|
||||
另有:`ForumSetting`(键值)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。
|
||||
另有:`ForumSetting`(键值)、`Session`(浏览器 opaque 会话)、`OAuthClient` / `OAuthAuthCode`、`GiteaRepo`、`SitePage`。
|
||||
|
||||
---
|
||||
|
||||
@@ -46,6 +46,18 @@ erDiagram
|
||||
|
||||
说明:`json:"-"` 表示默认 API 序列化隐藏;软删列 `deleted_at` 表示 GORM soft delete。
|
||||
|
||||
### 2.0 sessions
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| id | string(64) PK | 密码学随机 opaque id(Cookie `jiang13_session` 的值) |
|
||||
| user_id | uint index | 用户 |
|
||||
| expires_at | time index | 过期;默认 TTL 7 天,滑动续期 |
|
||||
| created_at / last_seen_at | time | |
|
||||
| ip / user_agent | string | 可选审计 |
|
||||
|
||||
登出删单行;禁言 / 改密删该用户全部 session。每次请求以 DB 中 `users.role` / 禁言为准。
|
||||
|
||||
### 2.1 users
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
@@ -391,8 +403,15 @@ Metric:`tenure_days` | `likes_received` | `creator_income`
|
||||
| oidc_group_claim | groups |
|
||||
| oidc_admin_group | gitea-admin |
|
||||
| oidc_user_group | gitea-users |
|
||||
| oidc_rsa_private_pem | (空)启用 OIDC 时懒生成并写入;未启用不落盘 `.oidc_rsa.pem` |
|
||||
|
||||
### 6.5 Gitea 同步
|
||||
### 6.4b 敏感词
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
| filter_words | 默认词表文本;启动时从旧 `filter_words.txt` 导入(若键为空) |
|
||||
|
||||
### 6.5 Gitea 同步(**后置**,键保留兼容)
|
||||
|
||||
| Key | 默认 |
|
||||
|-----|------|
|
||||
@@ -401,6 +420,8 @@ Metric:`tenure_days` | `likes_received` | `creator_income`
|
||||
| gitea_token | |
|
||||
| gitea_sync_interval_min | 60 |
|
||||
|
||||
本迭代不启同步任务;见 [02-features.md](02-features.md) §K。
|
||||
|
||||
### 6.6 存储
|
||||
|
||||
| Key | 默认 |
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
# 04 · HTTP API 合约
|
||||
|
||||
> **读者**:实现后端 / BFF / 前端数据层的 AI
|
||||
> **前置**:[03-data-model.md](03-data-model.md)
|
||||
> **源码**:[`router/router.go`](../../routers/setup.go)、[`frontend/src/api/client.ts`]((仅 main)frontend/src/api/client.ts)、[`frontend/src/api/types.ts`]((仅 main)frontend/src/api/types.ts)、[`middleware/auth.go`](../../modules/auth/auth.go)
|
||||
> **读者**:机器客户端 / 集成方;浏览器 UI **不**使用本文件作为主路径
|
||||
> **前置**:[03-data-model.md](03-data-model.md)、[08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
|
||||
> **源码**:[`routers/setup.go`](../../routers/setup.go)、[`modules/auth/auth.go`](../../modules/auth/auth.go)
|
||||
|
||||
不要求 OpenAPI YAML;以下表格 + JSON 形状即为合约。新站可加 `/v1` 前缀,但**字段名建议保持**以便对照迁移。
|
||||
本分支(`rebuild/gitea-ssr`)浏览器走 **`routers/web` 模板 + 表单**。
|
||||
下列 JSON 合约保留作历史对照与未来机器 API;**当前进程仅注册** health / OIDC / robots / sitemap / media 等机器相关路由,论坛 CRUD 的 `/api/*` 已从路由表移除(handler 源码可删可留,不以 SPA 兼容为目的)。
|
||||
|
||||
`main` 分支 SPA 仍完整实现下表;对照请 checkout `main`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 通用约定
|
||||
## 1. 本分支已注册的机器入口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 探活 |
|
||||
| GET | `/robots.txt` | 抓取规则 |
|
||||
| GET | `/sitemap.xml` | 站点地图 |
|
||||
| GET/POST | `/oauth/*`、`/.well-known/openid-configuration` | OIDC Provider |
|
||||
| GET | `/media/thumb/*`、`/uploads/*` | 媒体 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 历史 JSON 合约(main / 对照,本分支默认不挂载)
|
||||
|
||||
以下章节描述原 SPA 使用的 `/api` 形状,便于迁移业务语义;**实现 UI 时请用 web 表单,勿恢复双轨。**
|
||||
|
||||
### 通用约定(历史)
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| Base | 同源;前端 `credentials: 'same-origin'` |
|
||||
| Base | 同源;`credentials: 'same-origin'` |
|
||||
| 成功 | HTTP 2xx + JSON body |
|
||||
| 失败 | 非 2xx + `{ "error": "人类可读中文或英文消息" }` |
|
||||
| 鉴权 | Cookie `jiang13_token`(HttpOnly);部分也接受 Authorization Bearer(以实现为准) |
|
||||
| 内容类型 | JSON 默认;部分写接口用 `multipart/form-data`(FormData) |
|
||||
| OptionalAuth | 有 cookie 则解析用户,无则游客继续 |
|
||||
| RequireAuth | 必须登录且未禁言 |
|
||||
| RequireAdmin | 必须 `role=admin` |
|
||||
| 失败 | 非 2xx + `{ "error": "..." }` |
|
||||
| 鉴权 | Cookie `jiang13_session`(opaque);机器 OIDC 用 Bearer |
|
||||
|
||||
### 分页形态差异
|
||||
|
||||
@@ -31,11 +46,11 @@
|
||||
|
||||
---
|
||||
|
||||
## 2. 基础设施 / SEO / 静态
|
||||
## 3. 基础设施 / SEO / 静态(节选,仍有效)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/health` | 无 | `{ "status": "ok" }`(DB ping 失败则非 ok,以实现为准) |
|
||||
| GET | `/health` | 无 | `{ "status": "ok" }` |
|
||||
| GET | `/robots.txt` | 无 | 文本 |
|
||||
| GET | `/sitemap.xml` | 无 | XML |
|
||||
| GET | `/media/thumb/*filepath` | 无 | 缩略图 / WebP 等 |
|
||||
@@ -282,8 +297,8 @@
|
||||
| PUT | `/settings/mail` | MailConfig |
|
||||
| POST | `/settings/mail/test` | `{ to }` |
|
||||
| PUT | `/settings/oidc` | OIDCConfig |
|
||||
| PUT | `/settings/gitea` | GiteaSyncConfig |
|
||||
| POST | `/settings/gitea/sync` | 手动同步 |
|
||||
| PUT | `/settings/gitea` | **后置**(501) |
|
||||
| POST | `/settings/gitea/sync` | **后置**(501) |
|
||||
| PUT | `/settings/storage` | StorageConfig |
|
||||
| PUT | `/settings/branding` | SiteBranding |
|
||||
| POST | `/settings/branding/upload` | Form kind=`logo`\|`favicon`\|`og_image`, file |
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
|
||||
## 1. 注册与引导
|
||||
|
||||
源:[`handler/handlers.go`](../../routers/api/handlers.go) `APIRegisterConfig`、[`service/auth.go`](../../services/auth.go)
|
||||
源:[`routers/web/auth.go`](../../routers/web/auth.go)、[`services/auth.go`](../../services/auth.go)
|
||||
|
||||
| 规则 | 细节 |
|
||||
|------|------|
|
||||
| 首用户 = 管理员 | `UserCount() == 0` 时注册的用户 `role=admin` |
|
||||
| 开放注册 | `register_open = (userCount == 0) \|\| mailReady` |
|
||||
| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时首用户仍可无码注册 |
|
||||
| 后续用户 | 邮件未配置则注册关闭,直到管理员配好 SMTP |
|
||||
| 管理员 | **仅** `/install` 向导创建(不再「首注册变管理员」) |
|
||||
| 开放注册 | 安装完成后开放;不依赖 SMTP |
|
||||
| 邮箱验证码 | `require_email_code = mailReady`;邮件未就绪时可无码注册 |
|
||||
| 会话 | Cookie `jiang13_session` = opaque id;表 `sessions`;HttpOnly + SameSite=Lax;HTTPS 时 Secure;TTL 7 天滑动续期 |
|
||||
| 吊销 | 登出删当前 session;禁言 / 重置密码删该用户全部 session |
|
||||
| HMAC 密钥 | `{DATA}/.jwt_secret` 仅 CSRF 等 HMAC(**不是**浏览器登录 JWT) |
|
||||
|
||||
密码:bcrypt;最小长度来自 `password_min_len`(默认 6)。
|
||||
|
||||
@@ -206,8 +208,8 @@ stateDiagram-v2
|
||||
|
||||
## 11. 敏感词与限流
|
||||
|
||||
- 敏感词文件:`data/filter_words.txt`;发帖/评/私信等路径过滤
|
||||
- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings
|
||||
- 敏感词:`forum_settings.filter_words`(Admin SSR 可改并热更);旧文件可导入
|
||||
- 限流动作键:post / comment / register / login / report / message / friend_link 等;窗口秒与次数来自 settings(Admin 可改基础四项+窗口)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,50 +10,46 @@
|
||||
|
||||
## 1. 路由表
|
||||
|
||||
### 1.1 认证(无 MainLayout 壳或独立简洁壳)
|
||||
> **本分支(`rebuild/gitea-ssr`)**:浏览器 UI 走 `routers/web` 模板 + 表单,**不依赖**论坛 JSON `/api`。下表「SSR」列表示是否已迁。
|
||||
|
||||
| 路径 | 页面 | 说明 |
|
||||
|------|------|------|
|
||||
| `/login` | LoginPage | |
|
||||
| `/register` | RegisterPage | 读 register/config;可能关闭 |
|
||||
| `/forgot-password` | ForgotPasswordPage | 依赖邮件 |
|
||||
### 1.1 认证
|
||||
|
||||
### 1.2 前台(MainLayout)
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/login` | 登录 / 登出 | 已迁 |
|
||||
| `/register` | 注册(邮件就绪时要验证码) | 已迁 |
|
||||
| `/forgot-password` | 忘记密码 | 未迁 |
|
||||
|
||||
| 路径 | 页面 |
|
||||
|------|------|
|
||||
| `/` | HomePage(全部 Feed) |
|
||||
| `/board/:id` | HomePage(板块 Feed;id 可带伪静态后缀) |
|
||||
| `/post/:id` | PostDetailPage |
|
||||
| `/compose` | ComposePage 发帖 |
|
||||
| `/post/:id/edit` | ComposePage 编辑 |
|
||||
| `/profile` | ProfilePage(需登录) |
|
||||
| `/user/:id` | UserProfilePage |
|
||||
| `/favorites` | FavoritesPage |
|
||||
| `/projects` | ProjectsPage(Gitea 码桶) |
|
||||
| `/links` | LinksPage |
|
||||
| `/messages` | MessagesPage |
|
||||
| `/page/:slug` | SitePageView |
|
||||
| `*` | NotFoundPage |
|
||||
### 1.2 前台
|
||||
|
||||
重定向:`/boards` → `/admin/boards`。
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/` | Feed | 已迁 |
|
||||
| `/board/:id` | 板块 Feed | 已迁 |
|
||||
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
|
||||
| `/compose` | 发帖(normal;Markdown textarea + 图片上传) | 已迁 |
|
||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||
| `/profile` | 个人中心 | pending |
|
||||
| `/user/:id` | 公开用户页 | 未注册 |
|
||||
| `/favorites` | 收藏 | pending |
|
||||
| `/projects` | Gitea 码桶 | 后置 |
|
||||
| `/links` | 友链 | pending |
|
||||
| `/messages` | 私信 | pending |
|
||||
| `/page/:slug` | 站点单页 | 未注册 |
|
||||
| `*` | 404 / pending | 已迁 |
|
||||
|
||||
### 1.3 后台(AdminLayout,需管理员)
|
||||
### 1.3 后台(Admin SSR,表单 + CSRF,不挂管理 JSON `/api`)
|
||||
|
||||
| 路径 | 页面 |
|
||||
|------|------|
|
||||
| `/admin` → `/admin/dashboard` | 仪表盘 |
|
||||
| `/admin/boards` | 板块管理 |
|
||||
| `/admin/pages` | 单页列表 |
|
||||
| `/admin/pages/new`、`/admin/pages/:id/edit` | 单页编辑 |
|
||||
| `/admin/links` | 友链与申请 |
|
||||
| `/admin/posts` | 帖子审核/运营 |
|
||||
| `/admin/comments` | 评论 |
|
||||
| `/admin/reports` | 举报 |
|
||||
| `/admin/users` | 用户 |
|
||||
| `/admin/badges` | 徽章定义 |
|
||||
| `/admin/media` | 媒体 |
|
||||
| `/admin/settings` | 系统设置(多 Tab) |
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/admin` | 重定向 dashboard | 已迁 |
|
||||
| `/admin/dashboard` | 概览计数 | 已迁 |
|
||||
| `/admin/boards` | 板块 CRUD | 已迁 |
|
||||
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
|
||||
| `/admin/settings` | 品牌 + 基础限流 + 敏感词 | 已迁 |
|
||||
| `/admin/login` | 重定向前台登录 | 已迁 |
|
||||
|
||||
未迁(原 SPA):reports / users / badges / media / pages / links / SMTP / 完整 Limits 等。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,26 +2,106 @@
|
||||
|
||||
> **读者**:部署与运维、以及实现配置层的 AI
|
||||
> **前置**:[README.md](README.md)
|
||||
> **源码**:[`app.ini.example`](../../app.ini.example)、[`config/`](../../config/)、[`README.md`](../../README.md)、[`handler/seo.go`](../../routers/api/seo.go)、[`handler/seo_bot.go`](../../routers/api/seo_bot.go)、[`embed_static/`]((仅 main 分支)embed_static/)
|
||||
> **源码**:[`config/`](../../config/)、[`README.md`](../../README.md)、[`routers/api/seo.go`](../../routers/api/seo.go)、[`routers/install/`](../../routers/install/)
|
||||
|
||||
运维形态可改;下列描述**现网**行为,便于迁移数据与对齐环境变量语义。
|
||||
运维形态可改;下列描述**本分支**行为。
|
||||
|
||||
---
|
||||
|
||||
## 1. 进程配置优先级
|
||||
## 0. 首次安装(Gitea 式)
|
||||
|
||||
**命令行显式参数 > 环境变量 > `app.ini` > 内置默认**
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 锁文件 | `data/install.lock` |
|
||||
| 向导 | `GET/POST /install`(`templates/install.tmpl`) |
|
||||
| 未锁定 | 除 `/install`、`/ssr-assets/*`、`/health` 外重定向到向导 |
|
||||
| 管理员 | 仅安装向导创建;不再「首个注册用户变管理员」 |
|
||||
| 向导内容 | 站点名 + 管理员账号(数据库已在进程启动时连上) |
|
||||
| 旧数据 | 启动时若已有用户且无锁,自动补写锁 |
|
||||
|
||||
| CLI | 环境变量 | INI | 默认 | 说明 |
|
||||
|-----|----------|-----|------|------|
|
||||
| `--port` | `JIANG13_HTTP_PORT` | `[server] HTTP_PORT` | 3000 | 监听端口 |
|
||||
| `--data` | `JIANG13_DATA` | `[paths] DATA` | `data` | 数据目录 |
|
||||
| `--jwt-secret` | `JIANG13_JWT_SECRET` | `[security] JWT_SECRET` | 自动生成 | JWT 密钥 |
|
||||
| `--config` | `JIANG13_CONFIG` | | `{work}/app.ini` | 配置文件路径 |
|
||||
| `--work-path` | `JIANG13_WORK_PATH` | | 可执行文件目录 | 工作目录 |
|
||||
| `--service` | | | | install/uninstall/start/stop/restart/status |
|
||||
**无 `app.ini`。** 引导仅 CLI / Env。
|
||||
|
||||
`app.ini` 示例见 [`app.ini.example`](../../app.ini.example)。业务配置(邮件、OIDC、Gitea、存储、品牌等)在 **DB `forum_settings`**,管理后台热更新,不必写进 ini。
|
||||
---
|
||||
|
||||
## 1. 配置分层与重启边界
|
||||
|
||||
| 层 | 存什么 | 变更方式 | 需重启 |
|
||||
|----|--------|----------|--------|
|
||||
| **Bootstrap** | `DATA`、`HTTP_PORT`/`ADDR`、`DB_TYPE` + DSN/连接参数、工作目录 | CLI / Env | **是** |
|
||||
| **密钥文件** | App HMAC(`data/.jwt_secret`,文件名历史遗留);OIDC RSA **仅启用时**写入 settings(可选遗留文件迁移) | 自动生成 | HMAC 换钥需重启 |
|
||||
| **站点运行时** | 品牌、邮件、OIDC 开关、限流、敏感词、存储、伪静态… | DB `forum_settings` | **否**(热更) |
|
||||
|
||||
**优先级:** 命令行显式参数 > 环境变量 > 内置默认。
|
||||
|
||||
### 进程引导
|
||||
|
||||
| CLI | 环境变量 | 默认 | 说明 |
|
||||
|-----|----------|------|------|
|
||||
| `--port` | `JIANG13_HTTP_PORT` | 3000 | 监听端口 |
|
||||
| `--http-addr` | `JIANG13_HTTP_ADDR` | (空=全接口) | 监听地址 |
|
||||
| `--data` | `JIANG13_DATA` | `data` | 数据目录 |
|
||||
| `--work-path` | `JIANG13_WORK_PATH` | 可执行文件目录 | 工作目录 |
|
||||
| `--db-type` | `JIANG13_DB_TYPE` | `sqlite` | `sqlite` \| `postgres` \| `mysql` |
|
||||
| `--db-dsn` | `JIANG13_DB_DSN` | (sqlite 默认 `{DATA}/jiang13.db`) | 完整 DSN,优先 |
|
||||
| `--db-host` 等 | `JIANG13_DB_HOST` / `USER` / `PASS` / `NAME` / `SSLMODE` | | DSN 为空时拼接(pg/mysql) |
|
||||
| `--service` | | | install/uninstall/start/stop/restart/status |
|
||||
|
||||
`{DATA}/.jwt_secret`:**App HMAC 密钥**(CSRF 双提交等),启动时自动生成。**不是**浏览器登录 JWT。`--config` / `--jwt-secret` / `JIANG13_JWT_SECRET` 已废弃。
|
||||
|
||||
浏览器登录:DB `sessions` + Cookie `jiang13_session`。OIDC 对外 token 仍为 JWT,私钥在 `forum_settings.oidc_rsa_private_pem`(启用时懒加载;未启用不生成 `.oidc_rsa.pem`)。
|
||||
|
||||
业务配置(邮件、OIDC、存储、品牌、敏感词等)在 **DB `forum_settings`**,管理后台热更新。
|
||||
|
||||
### 数据库 Env 示例
|
||||
|
||||
**SQLite(默认):**
|
||||
|
||||
```bash
|
||||
JIANG13_DATA=/data
|
||||
# 可不设 DB_*;库文件 = $JIANG13_DATA/jiang13.db
|
||||
```
|
||||
|
||||
**PostgreSQL:**
|
||||
|
||||
```bash
|
||||
JIANG13_DB_TYPE=postgres
|
||||
JIANG13_DB_DSN="postgres://forum:secret@db:5432/jiang13?sslmode=disable"
|
||||
# 或拆分:
|
||||
# JIANG13_DB_HOST=db:5432
|
||||
# JIANG13_DB_USER=forum
|
||||
# JIANG13_DB_PASS=secret
|
||||
# JIANG13_DB_NAME=jiang13
|
||||
# JIANG13_DB_SSLMODE=disable
|
||||
```
|
||||
|
||||
**MySQL / MariaDB:**
|
||||
|
||||
```bash
|
||||
JIANG13_DB_TYPE=mysql
|
||||
JIANG13_DB_DSN="forum:secret@tcp(db:3306)/jiang13?parseTime=true&loc=Local&charset=utf8mb4"
|
||||
```
|
||||
|
||||
连库失败时进程**退出并打印 Env 提示**,不会静默回落 sqlite。
|
||||
|
||||
### Docker Compose 多库示意
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jiang13:
|
||||
image: hangzhang714128/jiang13-forum:latest
|
||||
environment:
|
||||
JIANG13_DB_TYPE: postgres
|
||||
JIANG13_DB_DSN: postgres://forum:secret@postgres:5432/jiang13?sslmode=disable
|
||||
volumes:
|
||||
- jiang13-data:/data
|
||||
depends_on: [postgres]
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: forum
|
||||
POSTGRES_PASSWORD: secret
|
||||
POSTGRES_DB: jiang13
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -29,19 +109,19 @@
|
||||
|
||||
```text
|
||||
data/
|
||||
├── jiang13.db # SQLite 主库
|
||||
├── install.lock # 安装完成锁(与 DB 引擎无关)
|
||||
├── jiang13.db # 仅 SQLite 时的主库文件(含 sessions / forum_settings)
|
||||
├── jiang13.log # 运行日志
|
||||
├── filter_words.txt # 敏感词
|
||||
├── .jwt_secret # 自动生成的 JWT 密钥(勿提交仓库)
|
||||
├── filter_words.txt # 遗留:启动时可导入 settings;新源以 DB 为准
|
||||
├── .jwt_secret # App HMAC(CSRF 等;勿提交;非登录 JWT)
|
||||
├── .oidc_rsa.pem # 遗留:仅启用 OIDC 且从文件迁移时可能存在;新站优先 DB
|
||||
├── uploads/
|
||||
│ ├── avatars/
|
||||
│ ├── posts/
|
||||
│ └── site/ # 品牌资源等
|
||||
└── jiang13_backup_*.db # 后台导出备份
|
||||
│ └── site/
|
||||
└── jiang13_backup_*.db # SQLite 一键备份(其它引擎请用库方工具)
|
||||
```
|
||||
|
||||
开发时后端常与 `dist/data` 共用,避免 dev 与产物数据分裂(见根 README)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署方式(现网)
|
||||
@@ -55,8 +135,6 @@ data/
|
||||
|
||||
构建约定见 [`.cursor/rules/build-scripts.mdc`](../../.cursor/rules/build-scripts.mdc):Windows 用 `build.bat`,勿直接 `make` / `.\build.ps1`。
|
||||
|
||||
容器常用环境变量与上表 `JIANG13_*` 一致。旧镜像权限问题:数据目录属主 uid 1000。
|
||||
|
||||
---
|
||||
|
||||
## 4. 存储后端
|
||||
@@ -66,7 +144,7 @@ data/
|
||||
| `local` | 文件落在 `data/uploads`;URL 通常 `/uploads/...` |
|
||||
| `s3` | S3 兼容;endpoint、bucket、密钥、public_base_url、prefix、force_path_style |
|
||||
|
||||
`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。上传始终可保留原图策略以实现为准。
|
||||
`image_delivery`:`webp`(默认,经 `/media/thumb`)或 `original`。
|
||||
|
||||
媒体索引表 `media` 供后台列表;启动时可后台 SyncMediaIndex。
|
||||
|
||||
@@ -82,20 +160,15 @@ data/
|
||||
| meta description | 站点简介优先,否则标语;帖文则摘要 |
|
||||
| meta keywords | 站点 keywords |
|
||||
| canonical | 绝对 URL |
|
||||
| og:type / site_name / locale / title / description / url / image | |
|
||||
| twitter:card / title / description / image | |
|
||||
| JSON-LD | 结构化数据(站点或 Article) |
|
||||
| robots | 个别页可 noindex(以实现为准) |
|
||||
|
||||
### 现网额外机制(可废弃)
|
||||
| og:* / twitter:* | |
|
||||
| JSON-LD | 结构化数据 |
|
||||
| robots | 个别页可 noindex |
|
||||
|
||||
| 机制 | 说明 |
|
||||
|------|------|
|
||||
| SPA 壳注入 | `embed_static`(仅 `main` 分支) 注入 title / branding JSON,**无帖文 DOM** |
|
||||
| 爬虫 HTML | User-Agent 命中时 [`seo_bot.go`](../../routers/api/seo_bot.go) 返回简易 HTML |
|
||||
| robots.txt / sitemap.xml | 动态生成 |
|
||||
|
||||
重构验收:用普通浏览器「查看网页源代码」应能看到帖文正文,而不仅是空 div + script。
|
||||
重构验收:普通浏览器「查看网页源代码」应能看到帖文正文。
|
||||
|
||||
---
|
||||
|
||||
@@ -103,43 +176,33 @@ data/
|
||||
|
||||
设置:`permalink_enabled`、`permalink_ext`(默认 `html`)。
|
||||
|
||||
规范路径示例:
|
||||
|
||||
- `/post/123.html`
|
||||
- `/user/1.html`
|
||||
- `/board/2.html`
|
||||
- `/page/about.html`
|
||||
|
||||
路由应同时接受无后缀与有后缀形式。解析逻辑见 [`service/permalink.go`](../../services/permalink.go)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全相关运维注意
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| JWT 密钥 | 生产必须固定且保密;勿提交 `.jwt_secret` |
|
||||
| Cookie | `jiang13_token` HttpOnly;生产应 Secure + 合适 SameSite |
|
||||
| 上传 | 类型/大小限制(头像 MB、帖图策略) |
|
||||
| 敏感词 | 后台可改;影响发帖评论私信等 |
|
||||
| OAuth 密钥 | 仅存 bcrypt 哈希;创建时明文只回显一次 |
|
||||
| 备份 | 含用户哈希与私信,下载需管理员权限、传输加密 |
|
||||
| HMAC / OIDC | 勿提交 `.jwt_secret`;OIDC PEM 优先在 DB;遗留 `.oidc_rsa.pem` 亦勿提交 |
|
||||
| Cookie | `jiang13_session` HttpOnly + SameSite=Lax;生产 HTTPS 下 Secure |
|
||||
| 上传 | 类型/大小限制 |
|
||||
| 敏感词 | `forum_settings.filter_words`,后台可改热更 |
|
||||
| 备份 | SQLite 文件备份含哈希与私信;PG/MySQL 用官方工具 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 健康检查
|
||||
|
||||
`GET /health` → JSON `status`。Docker / 负载均衡探活依赖此接口;实现应在 DB 不可用时返回非 200。
|
||||
`GET /health` → JSON `status`。DB 不可用时非 200。
|
||||
|
||||
---
|
||||
|
||||
## 9. 从旧站迁数据建议
|
||||
|
||||
1. 导出 / 复制 `jiang13.db`(或 dump 到新库并映射表)
|
||||
2. 复制 `uploads/` 与 `filter_words.txt`
|
||||
3. 迁移 `forum_settings` 键值(或后台重新配置)
|
||||
4. 会话:旧 JWT 密钥兼容一阶段,或强制全员重登
|
||||
5. OIDC 客户端:`oauth_clients` 表 + 重新下发密钥(若无法迁移哈希)
|
||||
1. SQLite:复制 `jiang13.db`;或 dump 到 PG/MySQL 并映射表
|
||||
2. 复制 `uploads/`、`.jwt_secret`(HMAC 兼容);敏感词若仍在文件可启动导入
|
||||
3. 迁移 `forum_settings` 或后台重配(含 `filter_words`)
|
||||
4. OIDC:`oauth_clients` + settings 中 PEM(或遗留 `.oidc_rsa.pem` 一次迁移)
|
||||
5. 旧站 JWT Cookie 无效;用户需重新登录(opaque session)
|
||||
|
||||
表语义以 [03-data-model.md](03-data-model.md) 为准。
|
||||
|
||||
|
||||
@@ -10,59 +10,77 @@
|
||||
|
||||
| 层 | 选择 |
|
||||
|----|------|
|
||||
| 公开页渲染 | Go `html/template` **真 SSR**(完整 HTML,含帖文/列表 DOM) |
|
||||
| 渐进增强 | `web_src/` 少量 CSS/JS,构建后嵌入 |
|
||||
| 公开页渲染 | Go `html/template` **真 SSR** |
|
||||
| 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG |
|
||||
| JSON `/api` | **仅机器客户端**(OIDC 等);不服务已迁页面 UI |
|
||||
| 渐进增强 | `web_src/` → `public/assets/` → `/ssr-assets/` |
|
||||
| 发布 | 单二进制 + `go:embed` |
|
||||
| 业务语义 | 仍以本目录 `01`–`07` 为准 |
|
||||
| 不做 | React/Next 公开页 SPA;用户与爬虫双轨 HTML |
|
||||
| 业务语义 | `01`–`07`;冲突时改代码并回写规格 |
|
||||
| 不做 | React SPA、爬虫/用户双轨 HTML、为旧 SPA 保留死代码 |
|
||||
|
||||
---
|
||||
|
||||
## 开发分支与对照
|
||||
## 分支
|
||||
|
||||
| 分支 | 用途 |
|
||||
|------|------|
|
||||
| `main` | 现网 **React SPA** 对照,勿在此做破坏性 SSR 替换 |
|
||||
| `rebuild/gitea-ssr` | **唯一** Gitea 式重构开发分支 |
|
||||
|
||||
对照运行:
|
||||
|
||||
```bash
|
||||
git checkout main # 旧 SPA
|
||||
# 或
|
||||
git worktree add ../jiang13-spa main
|
||||
```
|
||||
| `main` | React SPA 对照(git checkout / worktree) |
|
||||
| `rebuild/gitea-ssr` | 唯一重建分支 |
|
||||
|
||||
---
|
||||
|
||||
## 目录职责(本分支已落地)
|
||||
## 目录
|
||||
|
||||
```text
|
||||
cmd/jiang13/ # 入口
|
||||
config/ # 配置
|
||||
models/ # GORM 模型(原 model/)
|
||||
services/ # 业务逻辑(原 service/)
|
||||
routers/
|
||||
setup.go # 路由总装(原 router/)
|
||||
web/ # HTML SSR
|
||||
api/ # JSON API(原 handler/)
|
||||
setup.go
|
||||
install/ # INSTALL_LOCK 未置位时的安装向导
|
||||
web/ # HTML + 表单
|
||||
api/ # 精简机器接口(health / OIDC / robots / sitemap / media)
|
||||
modules/
|
||||
auth/ # JWT / 限流等(原 middleware/)
|
||||
webrender/ # 模板渲染
|
||||
seo/ # PageMeta 等
|
||||
templates/ # Go 模板(embed)
|
||||
web_src/ # CSS/JS 源码
|
||||
public/assets/ # 构建产物(URL 前缀 `/ssr-assets/`)
|
||||
docs/rebuild-spec/ # 产品规格
|
||||
.cursor/rules/ # AI 开发规则
|
||||
webctx/ # Doer / CSRF / Flash / HTML / Redirect
|
||||
auth/
|
||||
webrender/
|
||||
seo/
|
||||
templates/
|
||||
install.tmpl
|
||||
post-install.tmpl
|
||||
base/ home/ post/ shared/ status/ auth/ admin/
|
||||
services/
|
||||
web_src/ → public/assets/
|
||||
```
|
||||
|
||||
**已删除(勿恢复):** `frontend/`、`embed_static/`。SPA 对照仅看 `main`。
|
||||
**已删除:** `frontend/`、`embed_static/`、`ServePublicSPA`、爬虫双轨 HTML、首注册变管理员 bootstrap、`app.ini`、浏览器 JWT Cookie 登录。
|
||||
|
||||
**配置:** 引导 = CLI/Env(含 `DB_*`);运行时 = `forum_settings` 热更;`.jwt_secret` = App HMAC;OIDC PEM 启用时进 settings。详见 [07-config-ops.md](07-config-ops.md)。
|
||||
|
||||
**会话:** Cookie `jiang13_session` → 表 `sessions`;可吊销。
|
||||
|
||||
**数据库:** GORM 方言 `sqlite`(默认)| `postgres` | `mysql`;连库失败不回落。
|
||||
|
||||
**后置:** Gitea 仓库同步(不启后台任务)。
|
||||
|
||||
---
|
||||
|
||||
## 渲染原则
|
||||
## 安装
|
||||
|
||||
1. 用户访问已迁移路径时,「查看源代码」须可见内容 DOM,而非空壳。
|
||||
2. JSON `/api` 留给交互增强与后台;**不得**作为公开页首屏唯一数据来源。
|
||||
3. 模板默认 HTML 转义;可信 HTML(已消毒正文)用明确的安全管道,禁止随意 `| safe`。
|
||||
- 锁文件:`data/install.lock`
|
||||
- 未安装:除 `/install`、`/ssr-assets/*`、`/health` 外一律重定向到安装向导
|
||||
- 管理员仅由安装向导创建;已有用户数据启动时会自动补写锁
|
||||
- 向导不选库:库由启动 Env 决定
|
||||
|
||||
---
|
||||
|
||||
## 渲染与交互原则
|
||||
|
||||
1. 已迁路径「查看源代码」须含内容 DOM。
|
||||
2. UI 读写不依赖 `/api` 灌首屏或写操作(`/compose/upload` 为同站表单辅助 JSON,带 CSRF)。
|
||||
3. 模板默认转义;`safeHTML` 仅用于消毒 + 门控后正文/评论 HTML。
|
||||
4. 未迁路径用 `status/pending.tmpl` 或 404,不维护 SPA 占位语义。
|
||||
5. 会话 Cookie:`jiang13_session`;`SameSite=Lax`;HTTPS 下 `Secure`。
|
||||
|
||||
### 已迁路径(摘要)
|
||||
|
||||
公开写:`/install`、`/login`、`/logout`、`/register`、`/compose`、`/post/:id/edit`、帖详情评论/赞/藏。
|
||||
|
||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。
|
||||
@@ -37,7 +37,7 @@
|
||||
| 后端 | Go · Gin · GORM · SQLite |
|
||||
| 前端 | React 18 SPA · TipTap · Tailwind · TanStack Virtual |
|
||||
| 发布 | Vite 构建 → `go:embed` 打进单二进制 |
|
||||
| 认证 | bcrypt + JWT Cookie(`jiang13_token`) |
|
||||
| 认证 | bcrypt + DB opaque session Cookie(`jiang13_session`) |
|
||||
|
||||
演示站:https://bbs.iioio.com/
|
||||
|
||||
@@ -64,7 +64,7 @@ flowchart LR
|
||||
| 非真 SSR | 生产入口(`main` 的 `embed_static`)只注入 title / branding / Open Graph,**不渲染帖文 DOM** | 刷新先出壳再灌数据,体验不如 SSR |
|
||||
| 爬虫双轨 | [`routers/api/seo_bot.go`](../../routers/api/seo_bot.go) 对爬虫返回独立 HTML | 用户与爬虫看到的不是同一套渲染路径 |
|
||||
| 无正式 migration | Schema 靠 GORM `AutoMigrate`([`models/db.go`](../../models/db.go)) | 升级靠「加字段」,难做破坏性迁移与审计 |
|
||||
| Cookie JWT | 无 session 表,密钥在 `data/.jwt_secret` | 可保留语义,实现可换成更好的会话方案 |
|
||||
| Cookie JWT(旧) | 浏览器登录曾用 JWT Cookie | **本分支已改为** DB `sessions` + opaque Cookie `jiang13_session`;`.jwt_secret` 仅 CSRF/HMAC |
|
||||
|
||||
**新站目标**:用户首屏即可看到帖文 / 列表的服务端渲染(SSR)HTML;SEO meta 与正文同源。技术选型自定(Next.js / Nuxt / Remix / 其它均可)。
|
||||
|
||||
@@ -77,13 +77,13 @@ flowchart LR
|
||||
- [02-features.md](02-features.md) 中列出的功能能力
|
||||
- [03-data-model.md](03-data-model.md) 中的实体关系与枚举含义(表名可改,语义对齐)
|
||||
- [05-business-rules.md](05-business-rules.md) 中的数值与状态机(积分、审核、门控、悬赏分成等)
|
||||
- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;首个注册用户为管理员
|
||||
- 角色模型:游客 / 用户 / 认证用户(`verified` 免审)/ 管理员;**管理员仅由 `/install` 创建**(不再首注册变管理员)
|
||||
|
||||
### 建议兼容(降低迁移成本)
|
||||
|
||||
- [04-api.md](04-api.md) 的 JSON 字段命名与路径形状(可做版本前缀,但旧字段名便于对照)
|
||||
- Cookie 名 `jiang13_token` 或提供清晰的会话迁移方案
|
||||
- 数据目录语义:`jiang13.db`、`uploads/`、`filter_words.txt`
|
||||
- Cookie 名 `jiang13_session`(opaque session id;重建分支不做 `jiang13_token` 双读)
|
||||
- 数据目录语义:`jiang13.db`、`uploads/`;敏感词在 `forum_settings.filter_words`(旧 `filter_words.txt` 可导入)
|
||||
|
||||
### 可以彻底改
|
||||
|
||||
@@ -102,7 +102,8 @@ flowchart LR
|
||||
| SSR | 服务端渲染 | 首屏 HTML 含正文,非纯客户端壳 |
|
||||
| SPA | 单页应用 | 当前前台实现形态 |
|
||||
| OIDC | 开放身份连接 | 本站可作 Provider,供 Gitea 等 SSO |
|
||||
| JWT | JSON Web Token | 当前登录凭证,存 Cookie |
|
||||
| JWT | JSON Web Token | OIDC 对外 `id_token`/`access_token` 仍用;**浏览器登录不用 JWT** |
|
||||
| Opaque session | 不透明会话 | Cookie 只存随机 id,服务端 `sessions` 表可吊销 |
|
||||
| Feed | 信息流 | 首页 / 板块帖列表 |
|
||||
| 门控 | Content gate | 登录可见 / 回复可见 / 积分可见区块 |
|
||||
| 伪静态 | Permalink | 如 `/post/123.html` 的可选后缀 |
|
||||
|
||||
@@ -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
12
go.mod
@@ -12,7 +12,8 @@ require (
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.44.0
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
@@ -30,15 +31,21 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
@@ -48,13 +55,16 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
|
||||
29
go.sum
29
go.sum
@@ -10,6 +10,7 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -35,6 +36,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
@@ -48,6 +51,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -65,6 +76,10 @@ github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQe
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -91,6 +106,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -123,6 +140,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
@@ -133,13 +152,17 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1N
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
|
||||
100
models/db.go
100
models/db.go
@@ -5,33 +5,60 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite" // 纯 Go,支持 CGO_ENABLED=0 交叉编译
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// InitDB 初始化 SQLite 并自动迁移
|
||||
func InitDB(dbPath string) error {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建数据库目录失败: %w", err)
|
||||
// DatabaseConfig 与 config.DatabaseConfig 对齐的精简结构(避免 models→config 循环依赖)
|
||||
type DatabaseConfig struct {
|
||||
Type string
|
||||
DSN string
|
||||
SQLitePath string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetimeSec int
|
||||
}
|
||||
|
||||
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
14
models/session.go
Normal 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"`
|
||||
}
|
||||
@@ -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/RequireAuth,role 来自 DB)
|
||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, ok := c.Get(CtxUserID)
|
||||
if !ok || uid == nil {
|
||||
respondAuthRequired(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
role, exists := c.Get(CtxRole)
|
||||
if !exists || role != models.RoleAdmin {
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
@@ -94,14 +110,13 @@ func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
if auth := c.GetHeader("Authorization"); auth != "" {
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
func extractSessionID(c *gin.Context) string {
|
||||
if sid, err := c.Cookie(CookieName); err == nil && sid != "" {
|
||||
return sid
|
||||
}
|
||||
}
|
||||
if token, err := c.Cookie(CookieName); err == nil {
|
||||
return token
|
||||
// 兼容清理旧 Cookie 名(一次性)
|
||||
if old, err := c.Cookie("jiang13_token"); err == nil && old != "" {
|
||||
ClearNamedCookie(c, "jiang13_token")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -112,7 +127,7 @@ func isAPI(c *gin.Context) bool {
|
||||
|
||||
func adminLoginPath(c *gin.Context) string {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
return "/admin/login"
|
||||
return "/login?redirect=" + url.QueryEscape("/admin/dashboard")
|
||||
}
|
||||
return "/login"
|
||||
}
|
||||
@@ -122,11 +137,15 @@ func respondAuthRequired(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, adminLoginPath(c))
|
||||
redir := c.Request.URL.RequestURI()
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
redir = "/admin/dashboard"
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login?redirect="+url.QueryEscape(redir))
|
||||
}
|
||||
|
||||
func respondAuthExpired(c *gin.Context) {
|
||||
c.SetCookie(CookieName, "", -1, "/", "", false, true)
|
||||
ClearAuthCookie(c)
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"})
|
||||
return
|
||||
@@ -139,10 +158,6 @@ func respondBanned(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
c.Redirect(http.StatusFound, "/admin/login?banned=1")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login?banned=1")
|
||||
}
|
||||
|
||||
|
||||
43
modules/auth/cookie.go
Normal file
43
modules/auth/cookie.go
Normal 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
181
modules/webctx/context.go
Normal 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 见其它 URI(PRG)
|
||||
func (ctx *Context) Redirect(url string) {
|
||||
ctx.C.Redirect(http.StatusSeeOther, url)
|
||||
}
|
||||
|
||||
// SetFlash 一次性提示(下一请求读取)
|
||||
func (ctx *Context) SetFlash(msg string) {
|
||||
v := base64.RawURLEncoding.EncodeToString([]byte(msg))
|
||||
ctx.writeCookie(flashCookie, v, 120, true)
|
||||
}
|
||||
|
||||
// TakeFlash 读取并清除
|
||||
func (ctx *Context) TakeFlash() string {
|
||||
v, err := ctx.C.Cookie(flashCookie)
|
||||
if err != nil || v == "" {
|
||||
return ""
|
||||
}
|
||||
ctx.writeCookie(flashCookie, "", -1, true)
|
||||
b, err := base64.RawURLEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// EnsureCSRF 保证 CSRF cookie,并返回表单 token
|
||||
func (ctx *Context) EnsureCSRF() string {
|
||||
if t, err := ctx.C.Cookie(csrfCookie); err == nil && t != "" && ctx.validCSRF(t) {
|
||||
return t
|
||||
}
|
||||
t := ctx.newCSRF()
|
||||
ctx.writeCookie(csrfCookie, t, int((12 * time.Hour).Seconds()), true)
|
||||
return t
|
||||
}
|
||||
|
||||
// CheckCSRF 校验表单 _csrf(或请求头 X-CSRF-Token,供上传 fetch)
|
||||
func (ctx *Context) CheckCSRF() bool {
|
||||
form := strings.TrimSpace(ctx.C.PostForm("_csrf"))
|
||||
if form == "" {
|
||||
form = strings.TrimSpace(ctx.C.GetHeader("X-CSRF-Token"))
|
||||
}
|
||||
cookie, _ := ctx.C.Cookie(csrfCookie)
|
||||
if form == "" || cookie == "" || form != cookie {
|
||||
return false
|
||||
}
|
||||
return ctx.validCSRF(form)
|
||||
}
|
||||
|
||||
func (ctx *Context) newCSRF() string {
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(ctx.Secret))
|
||||
_, _ = mac.Write([]byte(ts))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
return ts + "." + sig
|
||||
}
|
||||
|
||||
func (ctx *Context) validCSRF(token string) bool {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
ts, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if time.Since(time.Unix(ts, 0)) > 12*time.Hour {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(ctx.Secret))
|
||||
_, _ = mac.Write([]byte(parts[0]))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
return hmac.Equal([]byte(sig), []byte(parts[1]))
|
||||
}
|
||||
|
||||
// SetLoginCookie 写入 opaque 会话 Cookie
|
||||
func (ctx *Context) SetLoginCookie(sessionID string) {
|
||||
auth.SetSessionCookie(ctx.C, sessionID)
|
||||
}
|
||||
|
||||
// ClearLoginCookie 退出并删 Cookie;若有 session id 则吊销
|
||||
func (ctx *Context) ClearLoginCookie() {
|
||||
if sid, err := ctx.C.Cookie(auth.CookieName); err == nil && sid != "" {
|
||||
services.DeleteSession(sid)
|
||||
}
|
||||
auth.ClearAuthCookie(ctx.C)
|
||||
}
|
||||
|
||||
func (ctx *Context) writeCookie(name, value string, maxAge int, httpOnly bool) {
|
||||
secure := requestIsHTTPS(ctx.C)
|
||||
http.SetCookie(ctx.C.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: httpOnly,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// requestIsHTTPS 直连 TLS 或反代 X-Forwarded-Proto
|
||||
func requestIsHTTPS(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
// SafeHTML 供模板使用的类型别名说明(实际转换在 webrender FuncMap)
|
||||
type SafeHTML = template.HTML
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,46 @@
|
||||
// 姜十三论坛 SSR 渐进增强入口(骨架阶段仅占位)
|
||||
// 姜十三论坛 SSR 渐进增强
|
||||
document.documentElement.dataset.j13Ssr = "1";
|
||||
|
||||
(function () {
|
||||
const form = document.getElementById("compose-form");
|
||||
const fileInput = document.getElementById("compose-image");
|
||||
const textarea = document.getElementById("compose-content");
|
||||
const statusEl = document.getElementById("compose-upload-status");
|
||||
if (!form || !fileInput || !textarea) return;
|
||||
|
||||
const csrf = form.getAttribute("data-csrf") || "";
|
||||
const uploadURL = form.getAttribute("data-upload") || "/compose/upload";
|
||||
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.value = "";
|
||||
if (!file) return;
|
||||
if (statusEl) statusEl.textContent = "上传中…";
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
fd.append("_csrf", csrf);
|
||||
try {
|
||||
const res = await fetch(uploadURL, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf },
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "上传失败");
|
||||
}
|
||||
const url = data.url;
|
||||
if (!url) throw new Error("未返回图片地址");
|
||||
const md = `\n\n\n\n`;
|
||||
const start = textarea.selectionStart || textarea.value.length;
|
||||
const end = textarea.selectionEnd || start;
|
||||
textarea.value =
|
||||
textarea.value.slice(0, start) + md + textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
if (statusEl) statusEl.textContent = "已插入图片";
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = e.message || "上传失败";
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 应用
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
`&`, "&",
|
||||
`<`, "<",
|
||||
`>`, ">",
|
||||
`"`, """,
|
||||
`'`, "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -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
124
routers/install/install.go
Normal 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()
|
||||
}
|
||||
}
|
||||
220
routers/setup.go
220
routers/setup.go
@@ -11,6 +11,7 @@ import (
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
webpublic "git.iioio.com/freefire/jiang13-forum/public"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers/api"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers/install"
|
||||
webpages "git.iioio.com/freefire/jiang13-forum/routers/web"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -22,7 +23,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// SSR 静态资源
|
||||
if err := services.EnsureInstallLockFromExistingData(cfg.DataDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 安装锁检查失败: %v\n", err)
|
||||
}
|
||||
|
||||
if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil {
|
||||
ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub)))
|
||||
r.GET("/ssr-assets/*filepath", func(c *gin.Context) {
|
||||
@@ -30,15 +34,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
ssrFiles.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
if cfg.DevMode {
|
||||
fmt.Fprintf(os.Stderr, "[dev] SSR 请访问 http://localhost:%d (对照 SPA 请 checkout main)\n", cfg.Port)
|
||||
}
|
||||
|
||||
r.Use(install.Guard(cfg.DataDir))
|
||||
|
||||
filter := services.NewSensitiveFilter()
|
||||
_ = services.WriteDefaultFilterWords(cfg.FilterWordsPath())
|
||||
filter.LoadFromFile(cfg.FilterWordsPath())
|
||||
|
||||
settingsSvc := services.NewForumSettingsService()
|
||||
services.EnsureFilterWordsInSettings(settingsSvc, cfg.FilterWordsPath(), filter)
|
||||
|
||||
authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
|
||||
userSvc := services.NewUserService(filter, settingsSvc)
|
||||
boardSvc := services.NewBoardService()
|
||||
@@ -58,16 +60,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Gitea 仓库同步后置:本阶段不启动后台同步,亦不挂管理入口
|
||||
giteaSvc := services.NewGiteaService(settingsSvc)
|
||||
giteaSvc.StartBackgroundSync()
|
||||
|
||||
uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
|
||||
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
|
||||
// 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换
|
||||
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
|
||||
_ = uploadStore.Apply(services.StorageConfig{Type: "local"})
|
||||
}
|
||||
// 后台同步存量文件到媒体索引,避免列表依赖实时扫盘
|
||||
go func() {
|
||||
if n, err := uploadStore.SyncMediaIndex(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 媒体索引同步失败: %v\n", err)
|
||||
@@ -89,25 +89,26 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
}
|
||||
authMW := auth.NewAuthMiddleware(authSvc)
|
||||
|
||||
// Gitea 式 SSR 公开页(优先于 SPA)
|
||||
install.Register(r, install.Deps{
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Auth: authSvc, Settings: settingsSvc,
|
||||
})
|
||||
|
||||
webpages.Register(r, webpages.Deps{
|
||||
Settings: settingsSvc,
|
||||
Board: boardSvc,
|
||||
Post: postSvc,
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Settings: settingsSvc, Auth: authSvc,
|
||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||
Message: messageSvc, Filter: filter,
|
||||
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
||||
}, authMW)
|
||||
|
||||
// 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突
|
||||
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
|
||||
// 健康检查(容器 / 负载均衡探活)
|
||||
r.GET("/health", h.APIHealth)
|
||||
|
||||
// SEO:抓取规则与站点地图
|
||||
r.GET("/robots.txt", h.RobotsTxt)
|
||||
r.GET("/sitemap.xml", h.SitemapXML)
|
||||
|
||||
// OIDC Provider(Gitea 等外部站点 SSO)
|
||||
// OIDC Provider(外部机器 / Gitea SSO)
|
||||
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
|
||||
r.GET("/oauth/jwks", h.OIDCJWKS)
|
||||
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
|
||||
@@ -117,179 +118,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
r.GET("/oauth/logout", h.OIDCLogout)
|
||||
r.POST("/oauth/logout", h.OIDCLogout)
|
||||
|
||||
// 公开 JSON API(可选登录)
|
||||
pubAPI := r.Group("/api", authMW.OptionalAuth())
|
||||
{
|
||||
pubAPI.GET("/me", h.APIMe)
|
||||
pubAPI.GET("/boards", h.APIBoards)
|
||||
pubAPI.GET("/stats", h.APIStats)
|
||||
pubAPI.GET("/forum-limits", h.APIForumLimits)
|
||||
pubAPI.GET("/site-branding", h.APISiteBranding)
|
||||
pubAPI.GET("/pages", h.APIPages)
|
||||
pubAPI.GET("/pages/:slug", h.APIPageDetail)
|
||||
pubAPI.GET("/captcha", h.APICaptcha)
|
||||
pubAPI.GET("/register/config", h.APIRegisterConfig)
|
||||
pubAPI.POST("/register/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
|
||||
pubAPI.POST("/password-reset/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
|
||||
pubAPI.POST("/password-reset", auth.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
|
||||
pubAPI.GET("/posts", h.APIPosts)
|
||||
pubAPI.GET("/posts/hot", h.APIHotPosts)
|
||||
pubAPI.GET("/tags", h.APITags)
|
||||
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
||||
// search / recent 须在 :id 之前
|
||||
pubAPI.GET("/users/search", h.APISearchUsers)
|
||||
pubAPI.GET("/users/recent", h.APIRecentUsers)
|
||||
pubAPI.GET("/users/:id", h.APIUserPublic)
|
||||
pubAPI.GET("/posts/:id", h.APIPostDetail)
|
||||
pubAPI.GET("/posts/:id/comments", h.APIPostComments)
|
||||
pubAPI.POST("/posts/:id/comments", auth.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
|
||||
pubAPI.GET("/projects", h.APIProjects)
|
||||
pubAPI.POST("/register", auth.RateLimitMiddleware(limiter, "register"), h.APIRegister)
|
||||
pubAPI.POST("/login", auth.RateLimitMiddleware(limiter, "login"), h.APILogin)
|
||||
}
|
||||
|
||||
// 需登录 API
|
||||
api := r.Group("/api", authMW.RequireAuth())
|
||||
{
|
||||
api.POST("/logout", h.APILogout)
|
||||
api.GET("/favorites", h.APIFavorites)
|
||||
api.GET("/profile/stats", h.APIProfileStats)
|
||||
api.POST("/profile/nickname", h.APIUpdateProfile)
|
||||
api.POST("/profile/signature", h.APIUpdateSignature)
|
||||
api.POST("/profile/password", h.APIUpdatePassword)
|
||||
api.POST("/profile/avatar", h.APIUploadAvatar)
|
||||
api.POST("/uploads/image", h.APIUploadPostImage)
|
||||
api.POST("/posts", auth.RateLimitMiddleware(limiter, "post"), h.APICreatePost)
|
||||
api.PUT("/posts/:id", h.APIUpdatePost)
|
||||
api.DELETE("/posts/:id", h.APIDeletePost)
|
||||
api.GET("/posts/:id/revisions", h.APIPostRevisions)
|
||||
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
|
||||
api.POST("/posts/:id/like", h.APIToggleLike)
|
||||
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
||||
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
||||
api.POST("/posts/:id/poll/vote", h.APIPollVote)
|
||||
api.POST("/posts/:id/poll/close", h.APIPollClose)
|
||||
api.POST("/posts/:id/bounty/award", h.APIBountyAward)
|
||||
api.POST("/posts/:id/bounty/refund", h.APIBountyRefund)
|
||||
api.POST("/posts/:id/lottery/draw", h.APILotteryDraw)
|
||||
api.POST("/posts/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
|
||||
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
|
||||
api.GET("/messages/notifications", h.APIMessageNotifications)
|
||||
api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
|
||||
api.GET("/messages/conversations", h.APIMessageConversations)
|
||||
api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
|
||||
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
|
||||
api.POST("/messages", auth.RateLimitMiddleware(limiter, "message"), h.APISendMessage)
|
||||
api.POST("/messages/read-all", h.APIMarkAllMessagesRead)
|
||||
api.POST("/comments/:id/like", h.APIToggleCommentLike)
|
||||
api.POST("/comments/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport)
|
||||
api.DELETE("/comments/:id", h.APIDeleteComment)
|
||||
api.PUT("/comments/:id", h.APIUpdateComment)
|
||||
api.GET("/me/points", h.APIMePoints)
|
||||
api.GET("/me/check-in", h.APIMeCheckInGet)
|
||||
api.POST("/me/check-in", h.APIMeCheckIn)
|
||||
api.GET("/me/lottery", h.APIMeLotteryGet)
|
||||
api.POST("/me/lottery", h.APIMeLotteryDraw)
|
||||
api.POST("/posts/:id/unlock", auth.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
|
||||
api.POST("/friend-links/apply", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
|
||||
api.POST("/friend-links/logo", auth.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
|
||||
api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies)
|
||||
api.PUT("/friend-links/applies/:id", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply)
|
||||
api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply)
|
||||
}
|
||||
|
||||
// 管理员 API(React SPA 后台统一使用 JSON)
|
||||
adminAPI := r.Group("/api/admin", authMW.RequireAuth(), authMW.RequireAdmin())
|
||||
{
|
||||
adminAPI.GET("/dashboard", h.APIAdminDashboard)
|
||||
adminAPI.GET("/settings", h.APIAdminSettings)
|
||||
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
|
||||
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
|
||||
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
|
||||
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
|
||||
adminAPI.PUT("/settings/gitea", h.APIAdminUpdateGiteaSettings)
|
||||
adminAPI.POST("/settings/gitea/sync", h.APIAdminSyncGitea)
|
||||
adminAPI.PUT("/settings/storage", h.APIAdminUpdateStorageSettings)
|
||||
adminAPI.PUT("/settings/branding", h.APIAdminUpdateBranding)
|
||||
adminAPI.POST("/settings/branding/upload", h.APIAdminUploadBrandingAsset)
|
||||
adminAPI.POST("/settings/branding/clear", h.APIAdminClearBrandingAsset)
|
||||
adminAPI.GET("/oauth/clients", h.APIAdminListOAuthClients)
|
||||
adminAPI.POST("/oauth/clients", h.APIAdminCreateOAuthClient)
|
||||
adminAPI.PUT("/oauth/clients/:id", h.APIAdminUpdateOAuthClient)
|
||||
adminAPI.DELETE("/oauth/clients/:id", h.APIAdminDeleteOAuthClient)
|
||||
adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords)
|
||||
adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
|
||||
adminAPI.POST("/boards", h.APIAdminCreateBoard)
|
||||
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
|
||||
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
|
||||
adminAPI.GET("/pages", h.APIAdminPages)
|
||||
adminAPI.GET("/pages/:id", h.APIAdminGetPage)
|
||||
adminAPI.POST("/pages", h.APIAdminCreatePage)
|
||||
adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage)
|
||||
adminAPI.PUT("/pages/:id/published", h.APIAdminSetPagePublished)
|
||||
adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage)
|
||||
adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies)
|
||||
adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings)
|
||||
adminAPI.POST("/friend-link-applies/:id/approve", h.APIAdminApproveFriendLinkApply)
|
||||
adminAPI.POST("/friend-link-applies/:id/reject", h.APIAdminRejectFriendLinkApply)
|
||||
adminAPI.POST("/friend-link-applies/:id/recheck", h.APIAdminRecheckFriendLinkApply)
|
||||
adminAPI.GET("/posts", h.APIAdminPosts)
|
||||
adminAPI.GET("/posts/trash", h.APIAdminTrashPosts)
|
||||
adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
|
||||
adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
|
||||
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
|
||||
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
|
||||
adminAPI.POST("/posts/:id/comments-lock", h.APIAdminCommentsLockPost)
|
||||
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
|
||||
adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost)
|
||||
adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost)
|
||||
adminAPI.DELETE("/posts/:id/purge", h.APIAdminPurgePost)
|
||||
adminAPI.DELETE("/posts/:id", h.APIAdminDeletePost)
|
||||
adminAPI.GET("/reports", h.APIAdminReports)
|
||||
adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport)
|
||||
adminAPI.GET("/comments", h.APIAdminComments)
|
||||
adminAPI.GET("/comments/trash", h.APIAdminTrashComments)
|
||||
adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions)
|
||||
adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment)
|
||||
adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment)
|
||||
adminAPI.POST("/comments/:id/restore", h.APIAdminRestoreComment)
|
||||
adminAPI.DELETE("/comments/:id/purge", h.APIAdminPurgeComment)
|
||||
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
|
||||
adminAPI.GET("/users", h.APIAdminUsers)
|
||||
adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
|
||||
adminAPI.POST("/users/:id/verify", h.APIAdminVerifyUser)
|
||||
adminAPI.POST("/users/:id/level", h.APIAdminSetUserLevel)
|
||||
adminAPI.POST("/users/:id/points", h.APIAdminAdjustPoints)
|
||||
adminAPI.POST("/users/:id/badges", h.APIAdminAwardBadge)
|
||||
adminAPI.GET("/badges", h.APIAdminListBadges)
|
||||
adminAPI.POST("/badges", h.APIAdminUpsertBadge)
|
||||
adminAPI.GET("/media", h.APIAdminMedia)
|
||||
adminAPI.POST("/media/delete", h.APIAdminDeleteMedia)
|
||||
adminAPI.POST("/backup", h.APIAdminBackup)
|
||||
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
|
||||
}
|
||||
|
||||
// 管理后台 HTML:SSR 尚未迁移;勿用 /*filepath(与 /admin/login 冲突)
|
||||
adminPendingHTML := `<!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
500
routers/web/admin.go
Normal 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
208
routers/web/auth.go
Normal 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
110
routers/web/chrome.go
Normal 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
224
routers/web/compose.go
Normal 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, ""
|
||||
}
|
||||
@@ -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
35
routers/web/pending.go
Normal 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
193
routers/web/post.go
Normal 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
|
||||
}
|
||||
132
services/auth.go
132
services/auth.go
@@ -1,38 +1,33 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
)
|
||||
|
||||
// 最近访问写入节流,避免每次 API 都打库
|
||||
// 最近访问写入节流,避免每次请求都打库
|
||||
const lastAccessTouchInterval = 5 * time.Minute
|
||||
|
||||
var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
|
||||
|
||||
const TokenExpire = 7 * 24 * time.Hour
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role models.Role `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
// SessionTTL 与浏览器会话对齐(兼容旧常量名)
|
||||
const TokenExpire = SessionTTL
|
||||
|
||||
type AuthService struct {
|
||||
jwtSecret string
|
||||
hmacSecret string // CSRF 等 HMAC;不再用于浏览器登录 JWT
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
|
||||
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
|
||||
func NewAuthService(hmacSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
|
||||
return &AuthService{hmacSecret: hmacSecret, filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
// HMACSecret 供 CSRF 等使用
|
||||
func (s *AuthService) HMACSecret() string { return s.hmacSecret }
|
||||
|
||||
// UserCount 当前用户数
|
||||
func (s *AuthService) UserCount() int64 {
|
||||
var n int64
|
||||
@@ -70,18 +65,12 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
|
||||
}
|
||||
nickname = s.filter.Filter(nickname)
|
||||
|
||||
// 首个注册用户自动成为管理员
|
||||
role := models.RoleUser
|
||||
if s.UserCount() == 0 {
|
||||
role = models.RoleAdmin
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: role,
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
if err := models.DB.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -89,24 +78,69 @@ func (s *AuthService) Register(username, password, nickname, email string) (*mod
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录
|
||||
func (s *AuthService) Login(username, password, clientIP string) (string, *models.User, error) {
|
||||
var user models.User
|
||||
if err := models.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return "", nil, ErrInvalidCred
|
||||
// CreateAdmin 安装向导创建管理员(仅应在未安装时调用)
|
||||
func (s *AuthService) CreateAdmin(username, password, nickname, email string) (*models.User, error) {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Banned {
|
||||
return "", nil, ErrUserBanned
|
||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !CheckPassword(user.Password, password) {
|
||||
return "", nil, ErrInvalidCred
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordLogin(&user, clientIP)
|
||||
token, err := s.GenerateToken(&user)
|
||||
return token, &user, err
|
||||
if nickname == "" {
|
||||
nickname = username
|
||||
}
|
||||
nickname = s.filter.Filter(nickname)
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: models.RoleAdmin,
|
||||
Verified: true,
|
||||
}
|
||||
if err := models.DB.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 校验密码并创建会话,返回 session id
|
||||
func (s *AuthService) Login(username, password, clientIP, userAgent string) (sessionID string, user *models.User, err error) {
|
||||
var u models.User
|
||||
if err := models.DB.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
if u.Banned {
|
||||
return "", nil, ErrUserBanned
|
||||
}
|
||||
if !CheckPassword(u.Password, password) {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(&u, clientIP)
|
||||
sid, err := CreateSession(u.ID, clientIP, userAgent)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return sid, &u, nil
|
||||
}
|
||||
|
||||
// CreateSessionForUser 已认证用户直接建会话(注册后自动登录)
|
||||
func (s *AuthService) CreateSessionForUser(user *models.User, clientIP, userAgent string) (string, error) {
|
||||
if user == nil {
|
||||
return "", ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(user, clientIP)
|
||||
return CreateSession(user.ID, clientIP, userAgent)
|
||||
}
|
||||
|
||||
// recordLogin 记录上次登录时间与 IP;登录同时视为一次访问(失败不影响登录)
|
||||
func (s *AuthService) recordLogin(user *models.User, clientIP string) {
|
||||
now := time.Now()
|
||||
ip := clientIP
|
||||
@@ -138,33 +172,3 @@ func (s *AuthService) TouchLastAccess(userID uint) {
|
||||
lastAccessTouchCache.Store(userID, now)
|
||||
_ = models.DB.Model(&models.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT
|
||||
func (s *AuthService) GenerateToken(user *models.User) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpire)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.jwtSecret))
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT
|
||||
func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
97
services/compose_body.go
Normal 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, "")
|
||||
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
21
services/content_gate.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
48
services/install.go
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -338,10 +338,9 @@ func (s *PostService) List(q PostListQuery) ([]models.Post, int64, error) {
|
||||
}
|
||||
}
|
||||
if tag := strings.TrimSpace(q.Tag); tag != "" {
|
||||
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
|
||||
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感(跨 sqlite/postgres/mysql)
|
||||
escaped := escapeLikePattern(strings.ToLower(tag))
|
||||
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), ',', ','), ', ', ','), ' ,', ',') || ',')"
|
||||
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
|
||||
db = db.Where(tagsNormalizedExpr()+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
@@ -390,6 +389,18 @@ func escapeLikePattern(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// tagsNormalizedExpr 标签列规范化表达式(跨方言)
|
||||
// 方言:sqlite / postgres 用 ||;mysql 用 CONCAT;COALESCE 三库通用
|
||||
func tagsNormalizedExpr() string {
|
||||
inner := "REPLACE(REPLACE(REPLACE(COALESCE(tags,''), ',', ','), ', ', ','), ' ,', ',')"
|
||||
switch models.DialectorName() {
|
||||
case "mysql":
|
||||
return "LOWER(CONCAT(',', " + inner + ", ','))"
|
||||
default:
|
||||
return "LOWER(',' || " + inner + " || ',')"
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAuthorUserID 按用户名精确匹配,否则按昵称精确匹配(优先用户名)
|
||||
func resolveAuthorUserID(author string) (uint, bool) {
|
||||
author = strings.TrimSpace(author)
|
||||
|
||||
129
services/session.go
Normal file
129
services/session.go
Normal 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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 站点地图用的轻量用户字段
|
||||
|
||||
47
templates/admin/boards.tmpl
Normal file
47
templates/admin/boards.tmpl
Normal 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}}
|
||||
18
templates/admin/dashboard.tmpl
Normal file
18
templates/admin/dashboard.tmpl
Normal 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}}
|
||||
60
templates/admin/moderation.tmpl
Normal file
60
templates/admin/moderation.tmpl
Normal 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
9
templates/admin/nav.tmpl
Normal 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}}
|
||||
43
templates/admin/settings.tmpl
Normal file
43
templates/admin/settings.tmpl
Normal 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
20
templates/auth/login.tmpl
Normal 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}}
|
||||
39
templates/auth/register.tmpl
Normal file
39
templates/auth/register.tmpl
Normal 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}}
|
||||
@@ -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}}
|
||||
4
templates/base/alert.tmpl
Normal file
4
templates/base/alert.tmpl
Normal 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}}
|
||||
8
templates/base/footer.tmpl
Normal file
8
templates/base/footer.tmpl
Normal 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
12
templates/base/head.tmpl
Normal 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}}
|
||||
27
templates/base/navbar.tmpl
Normal file
27
templates/base/navbar.tmpl
Normal 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
39
templates/compose.tmpl
Normal 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}}
|
||||
@@ -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
|
||||
|
||||
@@ -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
12
templates/home/home.tmpl
Normal 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
37
templates/install.tmpl
Normal 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}}
|
||||
10
templates/post-install.tmpl
Normal file
10
templates/post-install.tmpl
Normal 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
69
templates/post/body.tmpl
Normal 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
12
templates/post/view.tmpl
Normal 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}}
|
||||
8
templates/shared/board_aside.tmpl
Normal file
8
templates/shared/board_aside.tmpl
Normal 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
21
templates/status/404.tmpl
Normal 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}}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,46 @@
|
||||
// 姜十三论坛 SSR 渐进增强入口(骨架阶段仅占位)
|
||||
// 姜十三论坛 SSR 渐进增强
|
||||
document.documentElement.dataset.j13Ssr = "1";
|
||||
|
||||
(function () {
|
||||
const form = document.getElementById("compose-form");
|
||||
const fileInput = document.getElementById("compose-image");
|
||||
const textarea = document.getElementById("compose-content");
|
||||
const statusEl = document.getElementById("compose-upload-status");
|
||||
if (!form || !fileInput || !textarea) return;
|
||||
|
||||
const csrf = form.getAttribute("data-csrf") || "";
|
||||
const uploadURL = form.getAttribute("data-upload") || "/compose/upload";
|
||||
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.value = "";
|
||||
if (!file) return;
|
||||
if (statusEl) statusEl.textContent = "上传中…";
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
fd.append("_csrf", csrf);
|
||||
try {
|
||||
const res = await fetch(uploadURL, {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrf },
|
||||
body: fd,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "上传失败");
|
||||
}
|
||||
const url = data.url;
|
||||
if (!url) throw new Error("未返回图片地址");
|
||||
const md = `\n\n\n\n`;
|
||||
const start = textarea.selectionStart || textarea.value.length;
|
||||
const end = textarea.selectionEnd || start;
|
||||
textarea.value =
|
||||
textarea.value.slice(0, start) + md + textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
if (statusEl) statusEl.textContent = "已插入图片";
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = e.message || "上传失败";
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user