完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
11
.env.example
@@ -12,3 +12,14 @@ HTTP_PORT=3001
|
|||||||
|
|
||||||
# 开发模式
|
# 开发模式
|
||||||
DEV_MODE=true
|
DEV_MODE=true
|
||||||
|
|
||||||
|
# 正式站点 origin,生产使用 HTTPS;邮件 / Canonical / Sitemap 共用
|
||||||
|
SITE_URL=http://localhost:3000
|
||||||
|
# 32 字节随机值的标准 Base64;不填写时禁止保存邮件/S3凭据。独立备份,禁止提交真实密钥。
|
||||||
|
SETTINGS_MASTER_KEY=
|
||||||
|
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
|
||||||
|
SERVICE_PRIVATE_HOSTS=
|
||||||
|
# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头
|
||||||
|
TRUSTED_PROXIES=
|
||||||
|
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
|
||||||
|
MAINTENANCE_RECOVERY=0
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
|
|||||||
[security]
|
[security]
|
||||||
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
|
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
|
||||||
JWT_SECRET =
|
JWT_SECRET =
|
||||||
|
; 32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。
|
||||||
|
; 环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。
|
||||||
|
SETTINGS_MASTER_KEY =
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
DATA = data
|
DATA = data
|
||||||
|
|
||||||
[app]
|
[app]
|
||||||
DEV_MODE = true
|
DEV_MODE = true
|
||||||
|
; 对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。
|
||||||
|
; 本地:http://localhost:3000;生产:https://bbs.example.com。环境变量 SITE_URL 优先。
|
||||||
|
SITE_URL = http://localhost:3000
|
||||||
|
; 额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。
|
||||||
|
CORS_ORIGINS =
|
||||||
|
|||||||
@@ -16,15 +16,37 @@ type Config struct {
|
|||||||
Port int
|
Port int
|
||||||
DataDir string
|
DataDir string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
SettingsMasterKey string // 32 字节标准 Base64;加密邮件/存储凭据。留空则禁止保存凭据
|
||||||
DBDSN string
|
DBDSN string
|
||||||
DevMode bool
|
DevMode bool
|
||||||
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
|
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
|
||||||
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
|
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。
|
||||||
|
type appIniKey struct {
|
||||||
|
section string
|
||||||
|
key string
|
||||||
|
value string // 缺项时写入的缺省值(空串表示键存在但待填写)
|
||||||
|
comment string // 写在键上方的注释(不含前导分号外的格式由 ini 库处理)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appIniSchema 是本地配置清单;启动时把缺失键补进现有 app.ini,避免升级后站长看不到新字段。
|
||||||
|
var appIniSchema = []appIniKey{
|
||||||
|
{section: "server", key: "HTTP_PORT", value: "3001"},
|
||||||
|
{section: "database", key: "DSN", value: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", comment: "PostgreSQL 连接串(部署时请修改账号密码)"},
|
||||||
|
{section: "security", key: "JWT_SECRET", value: "", comment: "留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值"},
|
||||||
|
{section: "security", key: "SETTINGS_MASTER_KEY", value: "", comment: "32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。\n环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。"},
|
||||||
|
{section: "paths", key: "DATA", value: "data"},
|
||||||
|
{section: "app", key: "DEV_MODE", value: "true"},
|
||||||
|
{section: "app", key: "SITE_URL", value: "", comment: "对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。\n本地可填 http://localhost:3000;生产填 https://bbs.example.com。环境变量 SITE_URL 优先。"},
|
||||||
|
{section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。"},
|
||||||
|
}
|
||||||
|
|
||||||
// Parse 解析配置:环境变量 > app.ini > 默认值。
|
// Parse 解析配置:环境变量 > app.ini > 默认值。
|
||||||
// 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记),
|
// 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记),
|
||||||
// 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。
|
// 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。
|
||||||
|
// 若 app.ini 缺少 schema 中的键,会就地补全缺省行(不覆盖已有值)。
|
||||||
func Parse() (*Config, error) {
|
func Parse() (*Config, error) {
|
||||||
workPath, err := resolveWorkPath()
|
workPath, err := resolveWorkPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -40,35 +62,13 @@ func Parse() (*Config, error) {
|
|||||||
DevMode: true,
|
DevMode: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试加载 app.ini
|
|
||||||
iniPath := filepath.Join(workPath, "app.ini")
|
iniPath := filepath.Join(workPath, "app.ini")
|
||||||
if _, err := os.Stat(iniPath); err == nil {
|
f, err := loadAndEnsureAppIni(iniPath)
|
||||||
f, err := ini.Load(iniPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
if s := f.Section("server"); s.HasKey("HTTP_PORT") {
|
|
||||||
if v, err := s.Key("HTTP_PORT").Int(); err == nil {
|
|
||||||
cfg.Port = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := f.Section("database"); s.HasKey("DSN") {
|
|
||||||
cfg.DBDSN = s.Key("DSN").String()
|
|
||||||
}
|
|
||||||
if s := f.Section("security"); s.HasKey("JWT_SECRET") {
|
|
||||||
cfg.JWTSecret = s.Key("JWT_SECRET").String()
|
|
||||||
}
|
|
||||||
if s := f.Section("paths"); s.HasKey("DATA") {
|
|
||||||
dataRel := s.Key("DATA").String()
|
|
||||||
if !filepath.IsAbs(dataRel) {
|
|
||||||
cfg.DataDir = filepath.Join(workPath, dataRel)
|
|
||||||
} else {
|
|
||||||
cfg.DataDir = dataRel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := f.Section("app"); s.HasKey("DEV_MODE") {
|
|
||||||
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
|
|
||||||
}
|
}
|
||||||
|
if f != nil {
|
||||||
|
applyIni(cfg, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 环境变量覆盖
|
// 环境变量覆盖
|
||||||
@@ -83,6 +83,9 @@ func Parse() (*Config, error) {
|
|||||||
if v := os.Getenv("JWT_SECRET"); v != "" {
|
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||||
cfg.JWTSecret = v
|
cfg.JWTSecret = v
|
||||||
}
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("SETTINGS_MASTER_KEY")); v != "" {
|
||||||
|
cfg.SettingsMasterKey = v
|
||||||
|
}
|
||||||
if v := os.Getenv("DEV_MODE"); v != "" {
|
if v := os.Getenv("DEV_MODE"); v != "" {
|
||||||
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
|
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
|
||||||
}
|
}
|
||||||
@@ -123,6 +126,90 @@ func Parse() (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyIni(cfg *Config, f *ini.File) {
|
||||||
|
if s := f.Section("server"); s.HasKey("HTTP_PORT") {
|
||||||
|
if v, err := s.Key("HTTP_PORT").Int(); err == nil {
|
||||||
|
cfg.Port = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := f.Section("database"); s.HasKey("DSN") {
|
||||||
|
cfg.DBDSN = s.Key("DSN").String()
|
||||||
|
}
|
||||||
|
if s := f.Section("security"); s.HasKey("JWT_SECRET") {
|
||||||
|
cfg.JWTSecret = s.Key("JWT_SECRET").String()
|
||||||
|
}
|
||||||
|
if s := f.Section("security"); s.HasKey("SETTINGS_MASTER_KEY") {
|
||||||
|
cfg.SettingsMasterKey = strings.TrimSpace(s.Key("SETTINGS_MASTER_KEY").String())
|
||||||
|
}
|
||||||
|
if s := f.Section("paths"); s.HasKey("DATA") {
|
||||||
|
dataRel := s.Key("DATA").String()
|
||||||
|
if !filepath.IsAbs(dataRel) {
|
||||||
|
cfg.DataDir = filepath.Join(cfg.WorkPath, dataRel)
|
||||||
|
} else {
|
||||||
|
cfg.DataDir = dataRel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := f.Section("app"); s.HasKey("DEV_MODE") {
|
||||||
|
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
|
||||||
|
}
|
||||||
|
if s := f.Section("app"); s.HasKey("SITE_URL") {
|
||||||
|
if v := strings.TrimSpace(s.Key("SITE_URL").String()); v != "" {
|
||||||
|
cfg.SiteURL = strings.TrimRight(v, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := f.Section("app"); s.HasKey("CORS_ORIGINS") {
|
||||||
|
if v := strings.TrimSpace(s.Key("CORS_ORIGINS").String()); v != "" {
|
||||||
|
cfg.CORSOrigins = splitCSVOrigins(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAndEnsureAppIni 加载 app.ini;不存在则按 schema 新建;已存在则只补缺失键。
|
||||||
|
func loadAndEnsureAppIni(path string) (*ini.File, error) {
|
||||||
|
_, statErr := os.Stat(path)
|
||||||
|
missingFile := os.IsNotExist(statErr)
|
||||||
|
if statErr != nil && !missingFile {
|
||||||
|
return nil, fmt.Errorf("读取 app.ini 失败: %w", statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var f *ini.File
|
||||||
|
var err error
|
||||||
|
if missingFile {
|
||||||
|
f = ini.Empty()
|
||||||
|
} else {
|
||||||
|
f, err = ini.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := missingFile
|
||||||
|
for _, spec := range appIniSchema {
|
||||||
|
sec := f.Section(spec.section)
|
||||||
|
if !sec.HasKey(spec.key) {
|
||||||
|
val := spec.value
|
||||||
|
// 开发态缺 SITE_URL 时写入本机前端 origin,生产(DEV_MODE=false)只留空键提示填写
|
||||||
|
if spec.section == "app" && spec.key == "SITE_URL" && val == "" && sec.Key("DEV_MODE").MustBool(true) {
|
||||||
|
val = "http://localhost:3000"
|
||||||
|
}
|
||||||
|
k, kerr := sec.NewKey(spec.key, val)
|
||||||
|
if kerr != nil {
|
||||||
|
return nil, fmt.Errorf("补全 app.ini %s.%s 失败: %w", spec.section, spec.key, kerr)
|
||||||
|
}
|
||||||
|
if spec.comment != "" {
|
||||||
|
k.Comment = spec.comment
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := f.SaveTo(path); err != nil {
|
||||||
|
return nil, fmt.Errorf("写回 app.ini 失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AllowOrigin 供 CORS 中间件判断浏览器 Origin。
|
// AllowOrigin 供 CORS 中间件判断浏览器 Origin。
|
||||||
// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
|
// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
|
||||||
// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。
|
// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。
|
||||||
|
|||||||
@@ -114,6 +114,33 @@ func TestAllowOriginDevAndProd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSettingsMasterKeyFromIni(t *testing.T) {
|
||||||
|
work := t.TempDir()
|
||||||
|
key := "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||||
|
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = test-secret-not-for-prod\nSETTINGS_MASTER_KEY = "+key+"\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("JIANG13_WORK_PATH", work)
|
||||||
|
t.Setenv("SETTINGS_MASTER_KEY", "")
|
||||||
|
cfg, err := Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.SettingsMasterKey != key {
|
||||||
|
t.Fatalf("未从 app.ini 读取主密钥: %q", cfg.SettingsMasterKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||||
|
t.Setenv("SETTINGS_MASTER_KEY", override)
|
||||||
|
cfg, err = Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.SettingsMasterKey != override {
|
||||||
|
t.Fatalf("环境变量应覆盖 app.ini: %q", cfg.SettingsMasterKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSiteURLAndDataDir(t *testing.T) {
|
func TestParseSiteURLAndDataDir(t *testing.T) {
|
||||||
work := t.TempDir()
|
work := t.TempDir()
|
||||||
data := t.TempDir()
|
data := t.TempDir()
|
||||||
@@ -139,3 +166,94 @@ func TestParseSiteURLAndDataDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertPath(t, cfg.DataDir, data)
|
assertPath(t, cfg.DataDir, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
|
||||||
|
work := t.TempDir()
|
||||||
|
iniPath := filepath.Join(work, "app.ini")
|
||||||
|
// 旧版精简配置:缺 SITE_URL / CORS_ORIGINS / SETTINGS_MASTER_KEY
|
||||||
|
old := "[server]\nHTTP_PORT = 3001\n\n[database]\nDSN = postgres://u:p@localhost/db\n\n[security]\nJWT_SECRET = keep-me\n\n[paths]\nDATA = data\n\n[app]\nDEV_MODE = true\n"
|
||||||
|
if err := os.WriteFile(iniPath, []byte(old), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := loadAndEnsureAppIni(iniPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !f.Section("app").HasKey("SITE_URL") || !f.Section("app").HasKey("CORS_ORIGINS") {
|
||||||
|
t.Fatal("应补全 SITE_URL / CORS_ORIGINS")
|
||||||
|
}
|
||||||
|
if got := f.Section("app").Key("SITE_URL").String(); got != "http://localhost:3000" {
|
||||||
|
t.Fatalf("开发态缺 SITE_URL 应补本机 origin,got %q", got)
|
||||||
|
}
|
||||||
|
if got := f.Section("security").Key("JWT_SECRET").String(); got != "keep-me" {
|
||||||
|
t.Fatalf("已有值被改写: %q", got)
|
||||||
|
}
|
||||||
|
if !f.Section("security").HasKey("SETTINGS_MASTER_KEY") {
|
||||||
|
t.Fatal("应补全 SETTINGS_MASTER_KEY 空键")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再生产态缺项:只留空键,不写 localhost
|
||||||
|
prod := t.TempDir()
|
||||||
|
prodIni := filepath.Join(prod, "app.ini")
|
||||||
|
if err := os.WriteFile(prodIni, []byte("[app]\nDEV_MODE = false\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pf, err := loadAndEnsureAppIni(prodIni)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := pf.Section("app").Key("SITE_URL").String(); got != "" {
|
||||||
|
t.Fatalf("生产态缺 SITE_URL 应留空提示填写,got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSiteURLFromIni(t *testing.T) {
|
||||||
|
work := t.TempDir()
|
||||||
|
body := "[security]\nJWT_SECRET = test-secret-not-for-prod\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("JIANG13_WORK_PATH", work)
|
||||||
|
t.Setenv("SITE_URL", "")
|
||||||
|
t.Setenv("CORS_ORIGINS", "")
|
||||||
|
t.Setenv("DEV_MODE", "")
|
||||||
|
t.Setenv("JWT_SECRET", "")
|
||||||
|
|
||||||
|
cfg, err := Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.SiteURL != "https://forum.example.com" {
|
||||||
|
t.Fatalf("未从 app.ini 读取 SITE_URL: %q", cfg.SiteURL)
|
||||||
|
}
|
||||||
|
if len(cfg.CORSOrigins) != 2 || cfg.CORSOrigins[0] != "https://a.example.com" {
|
||||||
|
t.Fatalf("未从 app.ini 读取 CORS_ORIGINS: %#v", cfg.CORSOrigins)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("SITE_URL", "https://env.example.com")
|
||||||
|
cfg, err = Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.SiteURL != "https://env.example.com" {
|
||||||
|
t.Fatalf("环境变量应覆盖 app.ini SITE_URL: %q", cfg.SiteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAppIniCreatesWhenMissing(t *testing.T) {
|
||||||
|
work := t.TempDir()
|
||||||
|
iniPath := filepath.Join(work, "app.ini")
|
||||||
|
f, err := loadAndEnsureAppIni(iniPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(iniPath); err != nil {
|
||||||
|
t.Fatal("应创建 app.ini")
|
||||||
|
}
|
||||||
|
for _, spec := range appIniSchema {
|
||||||
|
if !f.Section(spec.section).HasKey(spec.key) {
|
||||||
|
t.Fatalf("新建文件缺少 %s.%s", spec.section, spec.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/minio/minio-go/v7 v7.0.83
|
||||||
|
github.com/yuin/goldmark v1.7.8
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
golang.org/x/image v0.46.0
|
golang.org/x/image v0.46.0
|
||||||
|
golang.org/x/text v0.42.0
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.0
|
||||||
gorm.io/driver/postgres v1.5.9
|
gorm.io/driver/postgres v1.5.9
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
@@ -19,12 +22,15 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.4 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
@@ -32,20 +38,22 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/net v0.45.0 // indirect
|
golang.org/x/net v0.45.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||||
@@ -18,6 +20,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
|||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -26,13 +30,15 @@ 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/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 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
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/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -49,9 +55,12 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
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 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
@@ -61,6 +70,10 @@ 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/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=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -72,6 +85,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
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=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -88,6 +103,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
@@ -99,7 +116,6 @@ golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
|||||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func clearAuthCookies(c *gin.Context, secure bool) {
|
|||||||
|
|
||||||
// RegisterRequest 注册请求
|
// RegisterRequest 注册请求
|
||||||
type RegisterRequest struct {
|
type RegisterRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||||
Email string `json:"email" binding:"omitempty,email"`
|
Email string `json:"email" binding:"omitempty,email"`
|
||||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||||
@@ -92,8 +93,8 @@ type RegisterRequest struct {
|
|||||||
|
|
||||||
// LoginRequest 登录请求
|
// LoginRequest 登录请求
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required,max=128"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required,max=128"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
|
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
|
||||||
@@ -110,6 +111,17 @@ func (h *Handlers) Register(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
security, err := h.Ops.Security()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "注册暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if security.VerifyEmail {
|
||||||
|
if err = h.Ops.ConsumeCode(req.Email, "register", req.Code); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "请先完成邮箱验证"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
|
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -146,8 +158,17 @@ func (h *Handlers) Login(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cfg, e := h.Ops.Security()
|
||||||
|
if !h.quotaResponse(c, 0, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wait, e := h.Ops.FailureWait(req.Username, c.ClientIP(), cfg)
|
||||||
|
if !h.quotaResponse(c, wait, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.Ops.RecordFailure(req.Username, c.ClientIP(), cfg)
|
||||||
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
|
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
|
||||||
h.AdminUser.RecordLogin(
|
h.AdminUser.RecordLogin(
|
||||||
h.Auth.GetUserIDByUsername(req.Username), req.Username,
|
h.Auth.GetUserIDByUsername(req.Username), req.Username,
|
||||||
@@ -164,6 +185,7 @@ func (h *Handlers) Login(c *gin.Context) {
|
|||||||
// 登录成功留痕(IP/UA/时间)
|
// 登录成功留痕(IP/UA/时间)
|
||||||
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
|
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
|
||||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||||
|
h.Ops.ClearFailure(req.Username)
|
||||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": gin.H{
|
"user": gin.H{
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if requireLogin, err := h.Setting.CommentsRequireLogin(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||||||
|
return
|
||||||
|
} else if requireLogin && viewerID == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"comments": []any{},
|
||||||
|
"total": 0,
|
||||||
|
"total_comments": 0,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
"require_login": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boardID, _ := h.Post.GetBoardID(uint(id))
|
boardID, _ := h.Post.GetBoardID(uint(id))
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
// Handlers 聚合所有服务引用
|
// Handlers 聚合所有服务引用
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
|
Ops *service.Operations
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Hub *realtime.Hub
|
Hub *realtime.Hub
|
||||||
Auth *service.AuthService
|
Auth *service.AuthService
|
||||||
|
|||||||
355
backend/handler/operations.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type moduleRequest struct {
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Clear []string `json:"clear"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) ReadModule(c *gin.Context) {
|
||||||
|
v, e := h.Ops.Read(c.Param("module"))
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "配置读取失败,请稍后重试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.JSON(200, v)
|
||||||
|
}
|
||||||
|
func (h *Handlers) SaveModule(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||||
|
var req moduleRequest
|
||||||
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "表单格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := c.Param("module")
|
||||||
|
actor := middleware.CurrentUser(c).ID
|
||||||
|
if e := h.Ops.ProbeBeforeSave(c.Request.Context(), name, req.Data, req.Clear); e != nil {
|
||||||
|
h.Ops.Audit(actor, name, "save", "服务验证失败")
|
||||||
|
c.JSON(400, gin.H{"error": safeConfigError(e)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := h.Ops.Save(name, req.Version, req.Data, req.Clear, actor)
|
||||||
|
if e != nil {
|
||||||
|
status := 400
|
||||||
|
if errors.Is(e, service.ErrConfigConflict) {
|
||||||
|
status = 409
|
||||||
|
}
|
||||||
|
h.Ops.Audit(actor, name, "save", "失败")
|
||||||
|
c.JSON(status, gin.H{"error": safeConfigError(e)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ReadModule(c)
|
||||||
|
}
|
||||||
|
func safeConfigError(e error) string {
|
||||||
|
s := e.Error()
|
||||||
|
if strings.Contains(s, "SQLSTATE") || strings.Contains(s, "sql:") || strings.Contains(s, "failed to connect") {
|
||||||
|
return "数据库暂不可用,配置未保存"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func (h *Handlers) TestModule(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||||
|
var req moduleRequest
|
||||||
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "表单格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := middleware.CurrentUser(c).ID
|
||||||
|
if wait, e := h.Ops.Quota("admin-test:"+strconv.Itoa(int(actor)), 6, 60); !h.quotaResponse(c, wait, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch c.Param("module") {
|
||||||
|
case "mail":
|
||||||
|
if req.Action != "connection" && req.Action != "send" {
|
||||||
|
c.JSON(400, gin.H{"error": "测试类型无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := h.Ops.TestMail(c.Request.Context(), req.Data, req.Clear, req.Action == "send", req.Recipient, actor)
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(400, gin.H{"error": e.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := "连接、TLS 与认证通过"
|
||||||
|
if req.Action == "send" {
|
||||||
|
message = "服务器已接受测试邮件,不代表最终送达"
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"message": message, "tested_at": time.Now()})
|
||||||
|
case "storage":
|
||||||
|
e := h.Ops.TestStorage(c.Request.Context(), req.Data, req.Clear)
|
||||||
|
if e != nil {
|
||||||
|
h.Ops.Audit(actor, "storage", "test", "失败")
|
||||||
|
c.JSON(400, gin.H{"error": e.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Ops.Audit(actor, "storage", "test", "读写清理通过")
|
||||||
|
c.JSON(200, gin.H{"message": "读写与清理测试通过", "tested_at": time.Now()})
|
||||||
|
case "filter":
|
||||||
|
result, e := h.Ops.TestFilter(req.Data, req.Scope, req.Text)
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(400, gin.H{"error": e.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, result)
|
||||||
|
default:
|
||||||
|
c.JSON(404, gin.H{"error": "该模块没有测试操作"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *Handlers) ModuleRecords(c *gin.Context) {
|
||||||
|
var data any
|
||||||
|
var e error
|
||||||
|
switch c.Param("module") {
|
||||||
|
case "mail":
|
||||||
|
data, e = h.Ops.MailRows()
|
||||||
|
case "storage":
|
||||||
|
data, e = h.Ops.StorageReferences()
|
||||||
|
case "security", "filter", "maintenance":
|
||||||
|
data, e = h.Ops.AuditRows(c.Param("module"))
|
||||||
|
default:
|
||||||
|
c.JSON(404, gin.H{"error": "该模块没有记录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "记录读取失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.JSON(200, gin.H{"records": data})
|
||||||
|
}
|
||||||
|
func (h *Handlers) quotaResponse(c *gin.Context, wait int, e error) bool {
|
||||||
|
if e != nil {
|
||||||
|
c.AbortWithStatusJSON(503, gin.H{"error": "安全检查暂不可用,请稍后重试"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if wait > 0 {
|
||||||
|
c.Header("Retry-After", strconv.Itoa(wait))
|
||||||
|
c.AbortWithStatusJSON(429, gin.H{"error": "操作频繁,请 " + strconv.Itoa(wait) + " 秒后重试", "retry_after": wait})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func (h *Handlers) administrator(c *gin.Context) bool {
|
||||||
|
user := middleware.CurrentUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a, e := h.Auth.LoadActor(user.ID)
|
||||||
|
return e == nil && a.HasPerm(service.PermSettings)
|
||||||
|
}
|
||||||
|
func authRecoveryPath(p string) bool {
|
||||||
|
switch p {
|
||||||
|
case "/api/login", "/api/logout", "/api/auth/refresh", "/api/me", "/api/settings", "/api/site-state", "/api/auth/code", "/api/auth/reset-password":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(p, "/api/admin/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registered after OptionalAuth so bypass always depends on validated live permissions.
|
||||||
|
func (h *Handlers) RuntimeGuard(c *gin.Context) {
|
||||||
|
p := c.Request.URL.Path
|
||||||
|
if p == "/health" || strings.HasPrefix(p, "/uploads/") || authRecoveryPath(p) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.administrator(c) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode, e := h.Ops.Maintenance()
|
||||||
|
if e != nil {
|
||||||
|
c.AbortWithStatusJSON(503, gin.H{"error": "站点状态暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
safe := authRecoveryPath(p)
|
||||||
|
if !safe && mode.Mode == "paused" {
|
||||||
|
c.Header("Retry-After", strconv.Itoa(mode.RetryAfter))
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.AbortWithStatusJSON(503, gin.H{"error": mode.Title, "maintenance": mode})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !safe && mode.Mode == "readonly" && c.Request.Method != "GET" && c.Request.Method != "HEAD" && c.Request.Method != "OPTIONS" {
|
||||||
|
c.AbortWithStatusJSON(503, gin.H{"error": "站点处于只读模式,暂不能提交修改"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
func (h *Handlers) BusinessQuota(c *gin.Context) {
|
||||||
|
if c.FullPath() != "/api/posts" && c.FullPath() != "/api/posts/:id/comments" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, e := h.Ops.Security()
|
||||||
|
if !h.quotaResponse(c, 0, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := c.FullPath()
|
||||||
|
user := middleware.CurrentUser(c)
|
||||||
|
identity := "ip:" + c.ClientIP()
|
||||||
|
if user != nil {
|
||||||
|
identity = "user:" + strconv.Itoa(int(user.ID))
|
||||||
|
}
|
||||||
|
seconds, limit, kind := 0, 1, ""
|
||||||
|
if c.Request.Method == "GET" && p == "/api/posts" && c.Query("q") != "" {
|
||||||
|
seconds = 60
|
||||||
|
limit = cfg.SearchMinute
|
||||||
|
kind = "search"
|
||||||
|
}
|
||||||
|
if c.Request.Method == "POST" && user != nil {
|
||||||
|
switch p {
|
||||||
|
case "/api/posts":
|
||||||
|
seconds = cfg.PostInterval
|
||||||
|
kind = "post"
|
||||||
|
case "/api/posts/:id/comments":
|
||||||
|
seconds = cfg.CommentInterval
|
||||||
|
kind = "comment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seconds > 0 {
|
||||||
|
if wait, e := h.Ops.Quota(kind+":"+identity, limit, seconds); !h.quotaResponse(c, wait, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
func (h *Handlers) SiteState(c *gin.Context) {
|
||||||
|
cfg, e := h.Ops.Security()
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "状态暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, e := h.Ops.Maintenance()
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "状态暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL})
|
||||||
|
}
|
||||||
|
func (h *Handlers) SendEmailCode(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "请求格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
||||||
|
if !h.quotaResponse(c, wait, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"message": "如果该邮箱可用于此操作,验证邮件将进入发送队列"})
|
||||||
|
}
|
||||||
|
func (h *Handlers) ResetPassword(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "请求格式无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wait, e := h.Ops.Quota("reset:"+c.ClientIP(), 10, 600); !h.quotaResponse(c, wait, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := h.Ops.ResetPassword(req.Email, req.Code, req.Password); e != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"message": "密码已更新,请重新登录"})
|
||||||
|
}
|
||||||
|
func (h *Handlers) PublicObject(c *gin.Context) {
|
||||||
|
location, err := h.Ops.PublicObjectLocation(c.Param("object"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(404, gin.H{"error": "文件不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if location != "" {
|
||||||
|
c.Header("Cache-Control", "private, no-store")
|
||||||
|
c.Redirect(302, location)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, m, e := h.Ops.OpenObject(ctx, c.Param("object"), true)
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(404, gin.H{"error": "文件暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
c.Header("Content-Type", m)
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("Cache-Control", "private, no-store")
|
||||||
|
c.Status(200)
|
||||||
|
_, _ = io.Copy(c.Writer, r)
|
||||||
|
}
|
||||||
|
func (h *Handlers) Diagnostics(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.JSON(200, h.Ops.Diagnostics(c.Request.Context()))
|
||||||
|
}
|
||||||
|
func (h *Handlers) MaintenanceAction(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "请求无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := middleware.CurrentUser(c).ID
|
||||||
|
switch req.Action {
|
||||||
|
case "scan":
|
||||||
|
r, e := h.Ops.ScanTemporary()
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "扫描失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, r)
|
||||||
|
case "clean-temporary":
|
||||||
|
if !req.Confirm {
|
||||||
|
c.JSON(400, gin.H{"error": "请先扫描并确认清理范围"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r, e := h.Ops.CleanTemporary(req.IDs)
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "清理失败,请重新扫描"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Ops.Audit(actor, "maintenance", "clean-temporary", "完成")
|
||||||
|
c.JSON(200, r)
|
||||||
|
case "clear-mail-logs":
|
||||||
|
if !req.Confirm {
|
||||||
|
c.JSON(400, gin.H{"error": "请确认仅清理过期发送记录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, e := h.Ops.ClearMailLogs()
|
||||||
|
if e != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "清理失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Ops.Audit(actor, "maintenance", req.Action, "完成")
|
||||||
|
c.JSON(200, gin.H{"message": "已清理过期终态发送记录", "count": n})
|
||||||
|
default:
|
||||||
|
c.JSON(400, gin.H{"error": "不支持该维护操作"})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -446,6 +449,20 @@ func (h *Handlers) DownloadPostAttachment(c *gin.Context) {
|
|||||||
ct = "application/octet-stream"
|
ct = "application/octet-stream"
|
||||||
}
|
}
|
||||||
c.Header("Content-Type", ct)
|
c.Header("Content-Type", ct)
|
||||||
|
if att.ObjectID != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, _, err := h.Ops.OpenObject(ctx, att.ObjectID, false)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(503, gin.H{"error": "文件暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
c.Header("Cache-Control", "private, no-store")
|
||||||
|
c.Status(200)
|
||||||
|
_, _ = io.Copy(c.Writer, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.File(path)
|
c.File(path)
|
||||||
_ = filepath.Base(path)
|
_ = filepath.Base(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type updateSettingsRequest struct {
|
|||||||
SiteDescription *string `json:"site_description"`
|
SiteDescription *string `json:"site_description"`
|
||||||
AllowRegister *bool `json:"allow_register"`
|
AllowRegister *bool `json:"allow_register"`
|
||||||
AllowComments *bool `json:"allow_comments"`
|
AllowComments *bool `json:"allow_comments"`
|
||||||
|
CommentsRequireLogin *bool `json:"comments_require_login"`
|
||||||
AllowMessages *bool `json:"allow_messages"`
|
AllowMessages *bool `json:"allow_messages"`
|
||||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||||
@@ -55,7 +56,6 @@ type updateSettingsRequest struct {
|
|||||||
BrandLogoSize *string `json:"brand_logo_size"`
|
BrandLogoSize *string `json:"brand_logo_size"`
|
||||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||||
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||||
@@ -66,6 +66,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"site_description": saved.SiteDescription,
|
"site_description": saved.SiteDescription,
|
||||||
"allow_register": saved.AllowRegister,
|
"allow_register": saved.AllowRegister,
|
||||||
"allow_comments": saved.AllowComments,
|
"allow_comments": saved.AllowComments,
|
||||||
|
"comments_require_login": saved.CommentsRequireLogin,
|
||||||
"allow_messages": saved.AllowMessages,
|
"allow_messages": saved.AllowMessages,
|
||||||
"post_cooldown_hours": saved.PostCooldownHours,
|
"post_cooldown_hours": saved.PostCooldownHours,
|
||||||
"code_block_auto_fold": saved.CodeBlockAutoFold,
|
"code_block_auto_fold": saved.CodeBlockAutoFold,
|
||||||
@@ -100,7 +101,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
func (req *updateSettingsRequest) hasAny() bool {
|
func (req *updateSettingsRequest) hasAny() bool {
|
||||||
return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil ||
|
return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil ||
|
||||||
req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil ||
|
req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil ||
|
||||||
req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
|
req.CommentsRequireLogin != nil || req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
|
||||||
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
|
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
|
||||||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
|
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
|
||||||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
|
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
|
||||||
@@ -110,24 +111,17 @@ func (req *updateSettingsRequest) hasAny() bool {
|
|||||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||||
req.FooterLinks != nil || req.TimelineGitImport != nil
|
req.FooterLinks != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
|
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
||||||
func (h *Handlers) AdminGetSettings(c *gin.Context) {
|
func (h *Handlers) AdminGetSettings(c *gin.Context) {
|
||||||
saved, err := h.Setting.Public()
|
saved, err := h.Setting.Public()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
adapter, err := h.Setting.TimelineGitAdapterJSON()
|
c.JSON(http.StatusOK, settingsPayload(saved))
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提交导入配置失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := settingsPayload(saved)
|
|
||||||
out["timeline_git_import"] = adapter
|
|
||||||
c.JSON(http.StatusOK, out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/admin/settings
|
// PUT /api/admin/settings
|
||||||
@@ -142,6 +136,10 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.AllowRegister != nil || req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || req.AttachmentMaxCount != nil || req.ImageMaxMB != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "请在访问与安全或文件与存储页通过版本校验保存这些设置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.Accent != nil {
|
if req.Accent != nil {
|
||||||
if err := h.Setting.SetAccent(*req.Accent); err != nil {
|
if err := h.Setting.SetAccent(*req.Accent); err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidAccent) {
|
if errors.Is(err, service.ErrInvalidAccent) {
|
||||||
@@ -190,6 +188,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.CommentsRequireLogin != nil {
|
||||||
|
if err := h.Setting.SetCommentsRequireLogin(*req.CommentsRequireLogin); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.AllowMessages != nil {
|
if req.AllowMessages != nil {
|
||||||
if err := h.Setting.SetAllowMessages(*req.AllowMessages); err != nil {
|
if err := h.Setting.SetAllowMessages(*req.AllowMessages); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
@@ -419,16 +423,6 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var savedAdapter string
|
|
||||||
if req.TimelineGitImport != nil {
|
|
||||||
normalized, err := h.Setting.SetTimelineGitAdapterJSON(*req.TimelineGitImport)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
savedAdapter = normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
saved, err := h.Setting.Public()
|
saved, err := h.Setting.Public()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||||
@@ -442,9 +436,6 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
out := settingsPayload(saved)
|
out := settingsPayload(saved)
|
||||||
out["ok"] = true
|
out["ok"] = true
|
||||||
if req.TimelineGitImport != nil {
|
|
||||||
out["timeline_git_import"] = savedAdapter
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,21 @@ func (h *Handlers) UserComments(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if requireLogin, err := h.Setting.CommentsRequireLogin(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||||||
|
return
|
||||||
|
} else if requireLogin && middleware.CurrentUser(c) == nil {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"comments": []any{},
|
||||||
|
"total": 0,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
"require_login": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFu
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
key := rateType + ":" + c.ClientIP()
|
key := rateType + ":" + c.ClientIP()
|
||||||
if !rl.Allow(key) {
|
if !rl.Allow(key) {
|
||||||
|
c.Header("Retry-After", "60")
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ func RateLimitUserMiddleware(rl *service.RateLimiter, rateType string) gin.Handl
|
|||||||
}
|
}
|
||||||
key := rateType + ":u:" + id
|
key := rateType + ":u:" + id
|
||||||
if !rl.Allow(key) {
|
if !rl.Allow(key) {
|
||||||
|
c.Header("Retry-After", "60")
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func InitDB(dsn string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
|
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
||||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ type PostContentUnlock struct {
|
|||||||
|
|
||||||
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
||||||
type PostAttachment struct {
|
type PostAttachment struct {
|
||||||
|
ObjectID string `gorm:"size:64;index" json:"-"`
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
|||||||
66
backend/model/operations.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ModuleConfig is versioned independently; secrets are encrypted by the service.
|
||||||
|
type ModuleConfig struct {
|
||||||
|
Name string `gorm:"primaryKey;size:32" json:"name"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
Data string `gorm:"type:text" json:"-"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsAudit struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ActorID uint `json:"actor_id"`
|
||||||
|
Module string `gorm:"size:32" json:"module"`
|
||||||
|
Action string `gorm:"size:64" json:"action"`
|
||||||
|
Fields string `gorm:"type:text" json:"fields"`
|
||||||
|
Result string `gorm:"type:text" json:"result"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionCounter struct {
|
||||||
|
Key string `gorm:"primaryKey;size:64"`
|
||||||
|
Count int
|
||||||
|
ExpiresAt time.Time `gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailTask retains no plaintext credentials, codes or links.
|
||||||
|
type MailTask struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Dedupe string `gorm:"uniqueIndex;size:64" json:"-"`
|
||||||
|
Kind string `gorm:"size:32" json:"kind"`
|
||||||
|
Recipient string `gorm:"size:256" json:"recipient"`
|
||||||
|
Payload string `gorm:"type:text" json:"-"`
|
||||||
|
Status string `gorm:"index;size:32" json:"status"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
Summary string `gorm:"size:128" json:"summary"`
|
||||||
|
NextAt time.Time `gorm:"index" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailChallenge struct {
|
||||||
|
Hash string `gorm:"primaryKey;size:64"`
|
||||||
|
Email string `gorm:"index;size:256"`
|
||||||
|
Purpose string `gorm:"size:32"`
|
||||||
|
ExpiresAt time.Time `gorm:"index"`
|
||||||
|
Used bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every remote object pins the immutable configuration revision that wrote it.
|
||||||
|
type StoredObject struct {
|
||||||
|
ID string `gorm:"primaryKey;size:64" json:"id"`
|
||||||
|
ConfigName string `gorm:"size:64;index" json:"-"`
|
||||||
|
Key string `gorm:"size:512" json:"-"`
|
||||||
|
MIME string `gorm:"size:128" json:"-"`
|
||||||
|
Public bool `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemporaryUpload struct {
|
||||||
|
ID string `gorm:"primaryKey;size:64"`
|
||||||
|
RelativePath string `gorm:"size:512"`
|
||||||
|
CreatedAt time.Time `gorm:"index"`
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/config"
|
"github.com/freefire/jiang13-bbs/config"
|
||||||
@@ -23,6 +26,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
var proxies []string
|
||||||
|
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
||||||
|
proxies = strings.Split(v, ",")
|
||||||
|
}
|
||||||
|
if err := r.SetTrustedProxies(proxies); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// 大附件落盘到临时文件,避免 multipart 整文件进内存(默认 32MiB)
|
// 大附件落盘到临时文件,避免 multipart 整文件进内存(默认 32MiB)
|
||||||
r.MaxMultipartMemory = 4 << 20
|
r.MaxMultipartMemory = 4 << 20
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
@@ -44,6 +54,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
||||||
boardSvc := service.NewBoardService(model.DB)
|
boardSvc := service.NewBoardService(model.DB)
|
||||||
settingSvc := service.NewSettingService(model.DB)
|
settingSvc := service.NewSettingService(model.DB)
|
||||||
|
ops := service.NewOperations(model.DB, cfg)
|
||||||
|
go ops.Run(context.Background())
|
||||||
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode)
|
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode)
|
||||||
commentSvc := service.NewCommentService(model.DB)
|
commentSvc := service.NewCommentService(model.DB)
|
||||||
likeSvc := service.NewLikeService(model.DB)
|
likeSvc := service.NewLikeService(model.DB)
|
||||||
@@ -54,6 +66,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
sitePageSvc := service.NewSitePageService(model.DB)
|
sitePageSvc := service.NewSitePageService(model.DB)
|
||||||
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
|
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
|
||||||
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")).WithSetting(settingSvc)
|
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")).WithSetting(settingSvc)
|
||||||
|
uploadSvc.WithOperations(ops)
|
||||||
|
postFileSvc.WithOperations(ops)
|
||||||
pointsSvc := service.NewPointsService(model.DB)
|
pointsSvc := service.NewPointsService(model.DB)
|
||||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
@@ -69,6 +83,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
limiter := service.DefaultRateLimiter()
|
limiter := service.DefaultRateLimiter()
|
||||||
|
|
||||||
h := &handler.Handlers{
|
h := &handler.Handlers{
|
||||||
|
Ops: ops,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
Hub: realtime.NewHub(),
|
Hub: realtime.NewHub(),
|
||||||
Auth: authSvc,
|
Auth: authSvc,
|
||||||
@@ -105,6 +120,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
service.StartLikeNotifyFlusher(notifSvc)
|
service.StartLikeNotifyFlusher(notifSvc)
|
||||||
|
|
||||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||||
|
r.Use(authMW.OptionalAuth(), h.RuntimeGuard, h.BusinessQuota)
|
||||||
|
|
||||||
// 上传文件静态服务(data/uploads → /uploads)
|
// 上传文件静态服务(data/uploads → /uploads)
|
||||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||||
@@ -134,6 +150,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/pages", h.SitePagesList)
|
pubAPI.GET("/pages", h.SitePagesList)
|
||||||
pubAPI.GET("/pages/:slug", h.SitePageDetail)
|
pubAPI.GET("/pages/:slug", h.SitePageDetail)
|
||||||
pubAPI.GET("/settings", h.PublicSettings)
|
pubAPI.GET("/settings", h.PublicSettings)
|
||||||
|
pubAPI.GET("/site-state", h.SiteState)
|
||||||
|
pubAPI.GET("/media/:object", h.PublicObject)
|
||||||
|
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||||
|
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
||||||
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
|
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
|
||||||
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||||
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||||
@@ -264,8 +284,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||||
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||||
|
|
||||||
// 站点外观设置(超级管理员/站长);含 timeline_git_import
|
// 站点设置(超级管理员/站长)
|
||||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||||
|
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||||
|
opsAPI.GET("/:module", h.ReadModule)
|
||||||
|
opsAPI.PUT("/:module", h.SaveModule)
|
||||||
|
opsAPI.POST("/:module/test", h.TestModule)
|
||||||
|
opsAPI.GET("/:module/records", h.ModuleRecords)
|
||||||
|
staffAPI.GET("/diagnostics", authMW.RequirePerm(service.PermSettings), h.Diagnostics)
|
||||||
|
staffAPI.POST("/maintenance/actions", authMW.RequirePerm(service.PermSettings), h.MaintenanceAction)
|
||||||
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
|
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
|
||||||
staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground)
|
staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground)
|
||||||
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
|
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
|||||||
|
|
||||||
// Register 用户注册
|
// Register 用户注册
|
||||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||||
|
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// 检查用户名是否已存在
|
// 检查用户名是否已存在
|
||||||
var count int64
|
var count int64
|
||||||
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
|
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
|
||||||
@@ -559,6 +562,9 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
|
|||||||
// - email 可为空;非空时需符合邮箱格式且不与他人重复
|
// - email 可为空;非空时需符合邮箱格式且不与他人重复
|
||||||
// - signature 不超过 255 字符
|
// - signature 不超过 255 字符
|
||||||
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
|
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
|
||||||
|
if err := NewOperations(s.db, nil).Filter("username", nickname, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
nickname = strings.TrimSpace(nickname)
|
nickname = strings.TrimSpace(nickname)
|
||||||
if nickname == "" {
|
if nickname == "" {
|
||||||
return nil, errors.New("昵称不能为空")
|
return nil, errors.New("昵称不能为空")
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const (
|
|||||||
BrandMarkImage = "image"
|
BrandMarkImage = "image"
|
||||||
BrandMarkText = "text"
|
BrandMarkText = "text"
|
||||||
|
|
||||||
|
FooterLinkAlignLeft = "left"
|
||||||
|
FooterLinkAlignCenter = "center"
|
||||||
|
FooterLinkAlignRight = "right"
|
||||||
|
|
||||||
BrandLogoSizeSQ = "sq"
|
BrandLogoSizeSQ = "sq"
|
||||||
BrandLogoSize2x1 = "2x1"
|
BrandLogoSize2x1 = "2x1"
|
||||||
BrandLogoSize3x1 = "3x1"
|
BrandLogoSize3x1 = "3x1"
|
||||||
@@ -53,6 +57,7 @@ type FooterLink struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
NewTab bool `json:"new_tab"`
|
NewTab bool `json:"new_tab"`
|
||||||
|
Align string `json:"align"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅接受本站品牌目录;Logo 不含 ico,Favicon 含 ico
|
// 仅接受本站品牌目录;Logo 不含 ico,Favicon 含 ico
|
||||||
@@ -219,6 +224,19 @@ type footerLinkIn struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
NewTab *bool `json:"new_tab"`
|
NewTab *bool `json:"new_tab"`
|
||||||
|
Align string `json:"align"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeFooterLinkAlign 单条页脚链接所属栏位;空串视为居右。
|
||||||
|
func NormalizeFooterLinkAlign(raw string) (string, bool) {
|
||||||
|
switch strings.TrimSpace(raw) {
|
||||||
|
case "", FooterLinkAlignRight:
|
||||||
|
return FooterLinkAlignRight, true
|
||||||
|
case FooterLinkAlignLeft, FooterLinkAlignCenter:
|
||||||
|
return strings.TrimSpace(raw), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeFooterLinks 校验并截断页脚链接;外链未显式指定时默认新标签
|
// NormalizeFooterLinks 校验并截断页脚链接;外链未显式指定时默认新标签
|
||||||
@@ -236,7 +254,11 @@ func NormalizeFooterLinks(raw []FooterLink) ([]FooterLink, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrInvalidSiteSetting
|
return nil, ErrInvalidSiteSetting
|
||||||
}
|
}
|
||||||
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab})
|
align, ok := NormalizeFooterLinkAlign(item.Align)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab, Align: align})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -266,7 +288,11 @@ func parseFooterLinksJSON(raw string) ([]FooterLink, error) {
|
|||||||
} else if isExternalFooterURL(u) {
|
} else if isExternalFooterURL(u) {
|
||||||
newTab = true
|
newTab = true
|
||||||
}
|
}
|
||||||
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab})
|
align, ok := NormalizeFooterLinkAlign(item.Align)
|
||||||
|
if !ok {
|
||||||
|
align = FooterLinkAlignRight
|
||||||
|
}
|
||||||
|
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab, Align: align})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -467,6 +493,8 @@ func (s *SettingService) SetFooterLinks(raw []FooterLink) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 去掉曾用的站级对齐键(现改为逐条 align)
|
||||||
|
_ = s.deleteKey("footer_links_align")
|
||||||
if len(list) == 0 {
|
if len(list) == 0 {
|
||||||
return s.deleteKey(SettingKeyFooterLinks)
|
return s.deleteKey(SettingKeyFooterLinks)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
)
|
)
|
||||||
@@ -134,6 +136,16 @@ func (s *UploadService) CopyBrandFromMedia(userID, attachmentID uint, slot strin
|
|||||||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||||||
return "", errors.New("图片不存在或不属于你")
|
return "", errors.New("图片不存在或不属于你")
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return s.SaveBrand(slot, r)
|
||||||
|
}
|
||||||
abs, ok := s.safeUploadPath(att.URL)
|
abs, ok := s.safeUploadPath(att.URL)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", errors.New("无效的图片地址")
|
return "", errors.New("无效的图片地址")
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ func TestNormalizeKeywords(t *testing.T) {
|
|||||||
|
|
||||||
func TestNormalizeFooterLinks(t *testing.T) {
|
func TestNormalizeFooterLinks(t *testing.T) {
|
||||||
got, err := NormalizeFooterLinks([]FooterLink{
|
got, err := NormalizeFooterLinks([]FooterLink{
|
||||||
{Label: "备案", URL: "https://beian.miit.gov.cn/", NewTab: true},
|
{Label: "备案", URL: "https://beian.miit.gov.cn/", NewTab: true, Align: FooterLinkAlignRight},
|
||||||
{Label: "关于", URL: "/about", NewTab: false},
|
{Label: "关于", URL: "/about", NewTab: false, Align: FooterLinkAlignLeft},
|
||||||
})
|
})
|
||||||
if err != nil || len(got) != 2 || got[1].URL != "/about" {
|
if err != nil || len(got) != 2 || got[1].URL != "/about" || got[1].Align != FooterLinkAlignLeft {
|
||||||
t.Fatalf("got %+v err=%v", got, err)
|
t.Fatalf("got %+v err=%v", got, err)
|
||||||
}
|
}
|
||||||
bad := []FooterLink{
|
bad := []FooterLink{
|
||||||
@@ -71,6 +71,7 @@ func TestNormalizeFooterLinks(t *testing.T) {
|
|||||||
{Label: "x", URL: "//evil.test"},
|
{Label: "x", URL: "//evil.test"},
|
||||||
{Label: "", URL: "/a"},
|
{Label: "", URL: "/a"},
|
||||||
{Label: "x", URL: "ftp://files.test/a"},
|
{Label: "x", URL: "ftp://files.test/a"},
|
||||||
|
{Label: "x", URL: "/ok", Align: "middle"},
|
||||||
}
|
}
|
||||||
for _, item := range bad {
|
for _, item := range bad {
|
||||||
if _, err := NormalizeFooterLinks([]FooterLink{item}); err == nil {
|
if _, err := NormalizeFooterLinks([]FooterLink{item}); err == nil {
|
||||||
@@ -79,8 +80,28 @@ func TestNormalizeFooterLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
raw := `[{"label":"ICP","url":"https://beian.miit.gov.cn/"}]`
|
raw := `[{"label":"ICP","url":"https://beian.miit.gov.cn/"}]`
|
||||||
parsed, err := parseFooterLinksJSON(raw)
|
parsed, err := parseFooterLinksJSON(raw)
|
||||||
if err != nil || len(parsed) != 1 || !parsed[0].NewTab {
|
if err != nil || len(parsed) != 1 || !parsed[0].NewTab || parsed[0].Align != FooterLinkAlignRight {
|
||||||
t.Fatalf("external default new tab, got %+v err=%v", parsed, err)
|
t.Fatalf("external default new tab + right align, got %+v err=%v", parsed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeFooterLinkAlign(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"", FooterLinkAlignRight, true},
|
||||||
|
{" right ", FooterLinkAlignRight, true},
|
||||||
|
{"left", FooterLinkAlignLeft, true},
|
||||||
|
{"center", FooterLinkAlignCenter, true},
|
||||||
|
{"middle", "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := NormalizeFooterLinkAlign(tc.in)
|
||||||
|
if ok != tc.ok || got != tc.want {
|
||||||
|
t.Fatalf("in=%q got=%q ok=%v want=%q/%v", tc.in, got, ok, tc.want, tc.ok)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Acto
|
|||||||
// pending 评论不计入 comment_count,审核通过时才 +1。
|
// pending 评论不计入 comment_count,审核通过时才 +1。
|
||||||
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
|
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
|
||||||
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) {
|
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) {
|
||||||
|
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return nil, nil, errors.New("评论内容不能为空")
|
return nil, nil, errors.New("评论内容不能为空")
|
||||||
@@ -600,6 +603,9 @@ func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
|
|||||||
|
|
||||||
// Update 编辑评论(作者本人或版主;已软删不可改);先写入旧正文快照再更新。
|
// Update 编辑评论(作者本人或版主;已软删不可改);先写入旧正文快照再更新。
|
||||||
func (s *CommentService) Update(actor *Actor, commentID, userID uint, content string) (*CommentNode, error) {
|
func (s *CommentService) Update(actor *Actor, commentID, userID uint, content string) (*CommentNode, error) {
|
||||||
|
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return nil, errors.New("评论内容不能为空")
|
return nil, errors.New("评论内容不能为空")
|
||||||
|
|||||||
579
backend/service/operations.go
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/freefire/jiang13-bbs/config"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SecurityConfig struct {
|
||||||
|
AllowRegister bool `json:"allow_register"`
|
||||||
|
RegisterNotice string `json:"register_notice"`
|
||||||
|
VerifyEmail bool `json:"verify_email"`
|
||||||
|
PasswordReset bool `json:"password_reset"`
|
||||||
|
LoginWindow int `json:"login_window"`
|
||||||
|
LoginFailures int `json:"login_failures"`
|
||||||
|
PostInterval int `json:"post_interval"`
|
||||||
|
CommentInterval int `json:"comment_interval"`
|
||||||
|
ResendInterval int `json:"resend_interval"`
|
||||||
|
EmailHourly int `json:"email_hourly"`
|
||||||
|
SearchMinute int `json:"search_minute"`
|
||||||
|
}
|
||||||
|
type MailConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
TLS string `json:"tls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
FromName string `json:"from_name"`
|
||||||
|
From string `json:"from"`
|
||||||
|
ReplyTo string `json:"reply_to"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
Retention int `json:"retention"`
|
||||||
|
SubjectTemplate string `json:"subject_template"`
|
||||||
|
BodyTemplate string `json:"body_template"`
|
||||||
|
}
|
||||||
|
type StorageConfig struct {
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
AccessKey string `json:"access_key"`
|
||||||
|
SecretKey string `json:"secret_key"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
CDN string `json:"cdn"`
|
||||||
|
PathStyle bool `json:"path_style"`
|
||||||
|
ImageMaxMB int `json:"image_max_mb"`
|
||||||
|
AttachmentMaxMB int `json:"attachment_max_mb"`
|
||||||
|
AttachmentMaxCount int `json:"attachment_max_count"`
|
||||||
|
AttachmentExtLimit bool `json:"attachment_ext_limit"`
|
||||||
|
AttachmentExts []string `json:"attachment_exts"`
|
||||||
|
}
|
||||||
|
type FilterRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Word string `json:"word"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Exceptions []string `json:"exceptions"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
type FilterConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Rules []FilterRule `json:"rules"`
|
||||||
|
}
|
||||||
|
type MaintenanceConfig struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Until string `json:"until"`
|
||||||
|
RetryAfter int `json:"retry_after"`
|
||||||
|
Contact string `json:"contact"`
|
||||||
|
TempDays int `json:"temp_days"`
|
||||||
|
}
|
||||||
|
type Operations struct {
|
||||||
|
smtpRoots *x509.CertPool
|
||||||
|
db *gorm.DB
|
||||||
|
cfg *config.Config
|
||||||
|
Started time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrConfigConflict = errors.New("配置已被其他管理员更新,请重新加载后合并修改")
|
||||||
|
|
||||||
|
func NewOperations(db *gorm.DB, cfg *config.Config) *Operations {
|
||||||
|
return &Operations{db: db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}), cfg: cfg, Started: time.Now()}
|
||||||
|
}
|
||||||
|
func defaultModule(name string) any {
|
||||||
|
switch name {
|
||||||
|
case "security":
|
||||||
|
return &SecurityConfig{AllowRegister: true, LoginWindow: 600, LoginFailures: 5, PostInterval: 6, CommentInterval: 2, ResendInterval: 60, EmailHourly: 5, SearchMinute: 30}
|
||||||
|
case "mail":
|
||||||
|
return &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, SubjectTemplate: defaultMailSubject, BodyTemplate: defaultMailBody}
|
||||||
|
case "storage":
|
||||||
|
return &StorageConfig{Backend: "local", Region: "us-east-1", Prefix: "jiang13/uploads/", PathStyle: true, ImageMaxMB: 5, AttachmentMaxMB: 20, AttachmentMaxCount: 10, AttachmentExtLimit: true, AttachmentExts: DefaultAttachmentExts}
|
||||||
|
case "filter":
|
||||||
|
return &FilterConfig{Rules: []FilterRule{}}
|
||||||
|
case "maintenance":
|
||||||
|
return &MaintenanceConfig{Mode: "normal", Title: "站点维护中", RetryAfter: 300, TempDays: 7}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (o *Operations) read(db *gorm.DB, name string) (any, int64, error) {
|
||||||
|
v := defaultModule(name)
|
||||||
|
if v == nil {
|
||||||
|
return nil, 0, errors.New("未知配置模块")
|
||||||
|
}
|
||||||
|
var row model.ModuleConfig
|
||||||
|
err := db.First(&row, "name = ?", name).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if err = json.Unmarshal([]byte(row.Data), v); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st := NewSettingService(db)
|
||||||
|
if s, ok := v.(*SecurityConfig); ok {
|
||||||
|
s.AllowRegister, err = st.AllowRegister()
|
||||||
|
}
|
||||||
|
if s, ok := v.(*StorageConfig); ok {
|
||||||
|
s.ImageMaxMB, err = st.ImageMaxMB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
s.AttachmentMaxMB, err = st.AttachmentMaxMB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
s.AttachmentMaxCount, err = st.AttachmentMaxCount()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
s.AttachmentExtLimit, err = st.AttachmentExtLimit()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
s.AttachmentExts, err = st.AttachmentExts()
|
||||||
|
}
|
||||||
|
if m, ok := v.(*MailConfig); ok {
|
||||||
|
m.applyTemplateDefaults()
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
return v, row.Version, err
|
||||||
|
}
|
||||||
|
func (o *Operations) Security() (SecurityConfig, error) {
|
||||||
|
v, _, e := o.read(o.db, "security")
|
||||||
|
if e != nil {
|
||||||
|
return SecurityConfig{}, e
|
||||||
|
}
|
||||||
|
return *v.(*SecurityConfig), nil
|
||||||
|
}
|
||||||
|
func (o *Operations) Maintenance() (MaintenanceConfig, error) {
|
||||||
|
v, _, e := o.read(o.db, "maintenance")
|
||||||
|
if e != nil {
|
||||||
|
return MaintenanceConfig{}, e
|
||||||
|
}
|
||||||
|
m := *v.(*MaintenanceConfig)
|
||||||
|
if os.Getenv("MAINTENANCE_RECOVERY") == "1" {
|
||||||
|
m.Mode = "normal"
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
func (o *Operations) Read(name string) (map[string]any, error) {
|
||||||
|
v, version, e := o.read(o.db, name)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
var data map[string]any
|
||||||
|
_ = json.Unmarshal(b, &data)
|
||||||
|
for _, k := range secretFields(name) {
|
||||||
|
data[k+"_configured"] = data[k] != ""
|
||||||
|
data[k] = ""
|
||||||
|
}
|
||||||
|
state := "已启用"
|
||||||
|
if name == "mail" && !v.(*MailConfig).Enabled {
|
||||||
|
state = "未配置"
|
||||||
|
if v.(*MailConfig).Host != "" {
|
||||||
|
state = "未启用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "filter" && !v.(*FilterConfig).Enabled {
|
||||||
|
state = "未启用"
|
||||||
|
}
|
||||||
|
if name == "storage" {
|
||||||
|
state = v.(*StorageConfig).Backend
|
||||||
|
}
|
||||||
|
if name == "maintenance" {
|
||||||
|
state = v.(*MaintenanceConfig).Mode
|
||||||
|
}
|
||||||
|
out := map[string]any{"data": data, "version": version, "state": state, "effective": "保存后立即生效", "site_url": o.cfg.SiteURL, "local_directory": o.cfg.DataDir, "encryption_ready": o.keyReady(), "recovery_override": os.Getenv("MAINTENANCE_RECOVERY") == "1"}
|
||||||
|
if name == "mail" {
|
||||||
|
out["template_preview"] = o.MailPreview("示例验证码").Body
|
||||||
|
out["reset_preview"] = o.MailPreview("示例验证码", "reset").Body
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func secretFields(name string) []string {
|
||||||
|
switch name {
|
||||||
|
case "mail":
|
||||||
|
return []string{"password"}
|
||||||
|
case "storage":
|
||||||
|
return []string{"access_key", "secret_key"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (o *Operations) masterKey() string {
|
||||||
|
if o.cfg != nil {
|
||||||
|
if v := strings.TrimSpace(o.cfg.SettingsMasterKey); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("SETTINGS_MASTER_KEY"))
|
||||||
|
}
|
||||||
|
func (o *Operations) aead() (cipher.AEAD, error) {
|
||||||
|
k, e := base64.StdEncoding.DecodeString(o.masterKey())
|
||||||
|
if e != nil || len(k) != 32 {
|
||||||
|
return nil, errors.New("请在 app.ini 的 [security] 或环境变量 SETTINGS_MASTER_KEY 配置 32 字节标准 Base64 主密钥后再保存凭据")
|
||||||
|
}
|
||||||
|
b, e := aes.NewCipher(k)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(b)
|
||||||
|
}
|
||||||
|
func (o *Operations) keyReady() bool { _, e := o.aead(); return e == nil }
|
||||||
|
func (o *Operations) seal(s, domain string) (string, error) {
|
||||||
|
a, e := o.aead()
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
n := make([]byte, a.NonceSize())
|
||||||
|
if _, e = rand.Read(n); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(a.Seal(n, n, []byte(s), []byte(domain))), nil
|
||||||
|
}
|
||||||
|
func (o *Operations) open(s, domain string) (string, error) {
|
||||||
|
if s == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
a, e := o.aead()
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
b, e := base64.StdEncoding.DecodeString(s)
|
||||||
|
if e != nil || len(b) < a.NonceSize() {
|
||||||
|
return "", errors.New("凭据密文无效")
|
||||||
|
}
|
||||||
|
p, e := a.Open(nil, b[:a.NonceSize()], b[a.NonceSize():], []byte(domain))
|
||||||
|
if e != nil {
|
||||||
|
return "", errors.New("凭据解密失败,请核对部署主密钥")
|
||||||
|
}
|
||||||
|
return string(p), nil
|
||||||
|
}
|
||||||
|
func (o *Operations) draft(db *gorm.DB, name string, raw json.RawMessage, clear []string) (any, int64, []string, error) {
|
||||||
|
old, version, e := o.read(db, name)
|
||||||
|
if e != nil {
|
||||||
|
return nil, 0, nil, e
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(old)
|
||||||
|
var previous map[string]any
|
||||||
|
_ = json.Unmarshal(b, &previous)
|
||||||
|
var input map[string]any
|
||||||
|
if e = json.Unmarshal(raw, &input); e != nil {
|
||||||
|
return nil, 0, nil, errors.New("表单格式无效")
|
||||||
|
}
|
||||||
|
for k := range input {
|
||||||
|
if strings.HasSuffix(k, "_configured") {
|
||||||
|
delete(input, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := previous[k]; !ok {
|
||||||
|
return nil, 0, nil, fmt.Errorf("未知字段:%s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields := []string{}
|
||||||
|
for k, val := range input {
|
||||||
|
if !reflect.DeepEqual(previous[k], val) {
|
||||||
|
fields = append(fields, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range secretFields(name) {
|
||||||
|
value, _ := input[key].(string)
|
||||||
|
input[key] = previous[key]
|
||||||
|
if value != "" {
|
||||||
|
if strings.Contains(value, "***") || strings.Contains(value, "••") {
|
||||||
|
return nil, 0, nil, fmt.Errorf("%s:不能保存脱敏占位符", key)
|
||||||
|
}
|
||||||
|
enc, err := o.seal(value, name+":"+key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, err
|
||||||
|
}
|
||||||
|
input[key] = enc
|
||||||
|
}
|
||||||
|
for _, c := range clear {
|
||||||
|
if c == key {
|
||||||
|
input[key] = ""
|
||||||
|
fields = append(fields, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range input {
|
||||||
|
previous[k] = v
|
||||||
|
}
|
||||||
|
b, _ = json.Marshal(previous)
|
||||||
|
v := defaultModule(name)
|
||||||
|
d := json.NewDecoder(bytes.NewReader(b))
|
||||||
|
d.DisallowUnknownFields()
|
||||||
|
if e = d.Decode(v); e != nil {
|
||||||
|
return nil, 0, nil, errors.New("字段类型无效")
|
||||||
|
}
|
||||||
|
if e = o.validate(db, name, v); e != nil {
|
||||||
|
return nil, 0, nil, e
|
||||||
|
}
|
||||||
|
return v, version, fields, nil
|
||||||
|
}
|
||||||
|
func validEmail(s string) bool {
|
||||||
|
a, e := mail.ParseAddress(s)
|
||||||
|
return e == nil && a.Address == s && !strings.ContainsAny(s, "\r\n")
|
||||||
|
}
|
||||||
|
func bounded(n, lo, hi int) bool { return n >= lo && n <= hi }
|
||||||
|
func (o *Operations) validate(db *gorm.DB, name string, v any) error {
|
||||||
|
bad := func(field, msg string) error { return fmt.Errorf("%s:%s", field, msg) }
|
||||||
|
switch c := v.(type) {
|
||||||
|
case *SecurityConfig:
|
||||||
|
if len([]rune(c.RegisterNotice)) > 200 {
|
||||||
|
return bad("register_notice", "最多 200 字")
|
||||||
|
}
|
||||||
|
for _, n := range []struct {
|
||||||
|
k string
|
||||||
|
n, lo, hi int
|
||||||
|
}{{"login_window", c.LoginWindow, 60, 3600}, {"login_failures", c.LoginFailures, 3, 50}, {"post_interval", c.PostInterval, 0, 3600}, {"comment_interval", c.CommentInterval, 0, 3600}, {"resend_interval", c.ResendInterval, 30, 3600}, {"email_hourly", c.EmailHourly, 1, 20}, {"search_minute", c.SearchMinute, 1, 120}} {
|
||||||
|
if !bounded(n.n, n.lo, n.hi) {
|
||||||
|
return bad(n.k, fmt.Sprintf("范围 %d–%d", n.lo, n.hi))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.VerifyEmail || c.PasswordReset {
|
||||||
|
m, _, e := o.read(db, "mail")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if !m.(*MailConfig).Enabled {
|
||||||
|
return bad("verify_email", "须先启用邮件服务")
|
||||||
|
}
|
||||||
|
if e = o.validate(db, "mail", m); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
origin, originErr := url.Parse(o.cfg.SiteURL)
|
||||||
|
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && !(o.cfg.DevMode && origin.Scheme == "http")) {
|
||||||
|
return bad("verify_email", "部署 SITE_URL 后才能开启账号邮件")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *MailConfig:
|
||||||
|
c.applyTemplateDefaults()
|
||||||
|
if strings.ContainsAny(c.SubjectTemplate, "\r\n") || len([]rune(c.SubjectTemplate)) > 200 {
|
||||||
|
return bad("subject_template", "主题最多 200 字,且不能换行")
|
||||||
|
}
|
||||||
|
if len(c.BodyTemplate) > 200000 {
|
||||||
|
return bad("body_template", "正文最多 200000 字节")
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.BodyTemplate, "{{code}}") {
|
||||||
|
return bad("body_template", "正文必须包含 {{code}},否则用户收不到验证码")
|
||||||
|
}
|
||||||
|
if !bounded(c.Port, 1, 65535) || !bounded(c.Timeout, 2, 30) || !bounded(c.Retention, 1, 365) {
|
||||||
|
return bad("port", "端口 1–65535;超时 2–30 秒;记录保留 1–365 天")
|
||||||
|
}
|
||||||
|
if c.TLS != "tls" && c.TLS != "starttls" {
|
||||||
|
return bad("tls", "仅支持 TLS 或必需 STARTTLS")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(c.Host, "/:\\ \r\n") || len(c.Host) > 253 {
|
||||||
|
return bad("host", "请输入主机名")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(c.FromName+c.Username, "\r\n") || len(c.FromName) > 120 || len(c.Username) > 256 {
|
||||||
|
return bad("from_name", "格式无效")
|
||||||
|
}
|
||||||
|
if c.Enabled {
|
||||||
|
if c.Host == "" {
|
||||||
|
return bad("host", "启用前填写 SMTP 主机")
|
||||||
|
}
|
||||||
|
if c.Username == "" {
|
||||||
|
return bad("username", "启用前填写登录用户名")
|
||||||
|
}
|
||||||
|
if c.Password == "" {
|
||||||
|
return bad("password", "启用前填写密码或授权码。要继续使用已保存的授权码,请不要移除它")
|
||||||
|
}
|
||||||
|
if !validEmail(c.From) {
|
||||||
|
return bad("from", "启用前填写有效的发件邮箱")
|
||||||
|
}
|
||||||
|
if c.ReplyTo != "" && !validEmail(c.ReplyTo) {
|
||||||
|
return bad("reply_to", "邮箱格式无效")
|
||||||
|
}
|
||||||
|
if _, e := o.open(c.Password, "mail:password"); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s, _, e := o.read(db, "security")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
x := s.(*SecurityConfig)
|
||||||
|
if x.VerifyEmail || x.PasswordReset {
|
||||||
|
return bad("enabled", "注册验证或密码找回正在使用邮件,请先关闭依赖")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *StorageConfig:
|
||||||
|
if c.Backend != "local" && c.Backend != "s3" {
|
||||||
|
return bad("backend", "存储类型无效")
|
||||||
|
}
|
||||||
|
if c.Backend == "s3" {
|
||||||
|
u, e := url.Parse(c.Endpoint)
|
||||||
|
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && !(o.cfg.DevMode && u.Scheme == "http")) {
|
||||||
|
return bad("endpoint", "须为 HTTPS origin(开发可 HTTP)")
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") {
|
||||||
|
return bad("bucket", "桶名称无效")
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`).MatchString(c.Region) {
|
||||||
|
return bad("region", "区域无效")
|
||||||
|
}
|
||||||
|
if c.AccessKey == "" {
|
||||||
|
return bad("access_key", "启用 S3 前填写 Access Key。要继续使用已保存的值,请不要移除它")
|
||||||
|
}
|
||||||
|
if c.SecretKey == "" {
|
||||||
|
return bad("secret_key", "启用 S3 前填写 Secret Key。要继续使用已保存的值,请不要移除它")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z0-9_/-]{1,100}/$`).MatchString(c.Prefix) || strings.HasPrefix(c.Prefix, "/") || strings.Contains(c.Prefix, "//") {
|
||||||
|
return bad("prefix", "必须为应用专用的相对目录并以 / 结束")
|
||||||
|
}
|
||||||
|
if c.CDN != "" {
|
||||||
|
u, e := url.Parse(c.CDN)
|
||||||
|
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return bad("cdn", "须为 HTTPS 地址")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bounded(c.ImageMaxMB, 1, 50) || !bounded(c.AttachmentMaxMB, 1, 100) || !bounded(c.AttachmentMaxCount, 1, 20) {
|
||||||
|
return bad("image_max_mb", "图片 1–50 MiB,附件 1–100 MiB,数量 1–20")
|
||||||
|
}
|
||||||
|
if len(c.AttachmentExts) == 0 || len(c.AttachmentExts) > 80 {
|
||||||
|
return bad("attachment_exts", "需要 1–80 个扩展名")
|
||||||
|
}
|
||||||
|
for _, x := range c.AttachmentExts {
|
||||||
|
if !attachmentExtRe.MatchString(x) {
|
||||||
|
return bad("attachment_exts", "扩展名只含小写字母与数字")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *FilterConfig:
|
||||||
|
if len(c.Rules) > 2000 {
|
||||||
|
return bad("rules", "最多 2000 条规则")
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for i := range c.Rules {
|
||||||
|
r := &c.Rules[i]
|
||||||
|
r.Word = strings.TrimSpace(r.Word)
|
||||||
|
if r.ID == "" || len(r.ID) > 64 || ids[r.ID] {
|
||||||
|
return bad("rules", "规则 ID 无效或重复")
|
||||||
|
}
|
||||||
|
ids[r.ID] = true
|
||||||
|
if len([]rune(r.Word)) < 1 || len([]rune(r.Word)) > 80 || len(r.Note) > 500 || len(r.Exceptions) > 20 {
|
||||||
|
return bad("rules", "词语 1–80 字,例外最多 20 个")
|
||||||
|
}
|
||||||
|
if r.Action != "block" && r.Action != "log" {
|
||||||
|
return bad("rules", "动作无效")
|
||||||
|
}
|
||||||
|
if len(r.Scopes) == 0 {
|
||||||
|
return bad("rules", "请选择适用范围")
|
||||||
|
}
|
||||||
|
for _, s := range r.Scopes {
|
||||||
|
if s != "username" && s != "title" && s != "body" && s != "comment" {
|
||||||
|
return bad("rules", "范围无效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range r.Exceptions {
|
||||||
|
if len(s) > 240 || strings.TrimSpace(s) == "" {
|
||||||
|
return bad("rules", "例外词无效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Enabled && len(c.Rules) == 0 {
|
||||||
|
return bad("enabled", "添加规则后才能启用")
|
||||||
|
}
|
||||||
|
case *MaintenanceConfig:
|
||||||
|
if c.Mode != "normal" && c.Mode != "readonly" && c.Mode != "paused" {
|
||||||
|
return bad("mode", "模式无效")
|
||||||
|
}
|
||||||
|
if !bounded(c.RetryAfter, 30, 86400) || !bounded(c.TempDays, 1, 365) {
|
||||||
|
return bad("retry_after", "重试 30–86400 秒,临时保留 1–365 天")
|
||||||
|
}
|
||||||
|
if len(c.Title) > 240 || len(c.Message) > 4000 || len(c.Contact) > 500 {
|
||||||
|
return bad("message", "文案过长")
|
||||||
|
}
|
||||||
|
if c.Until != "" {
|
||||||
|
if _, e := time.Parse(time.RFC3339, c.Until); e != nil {
|
||||||
|
return bad("until", "使用含时区的 ISO 时间")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (o *Operations) Save(name string, version int64, raw json.RawMessage, clear []string, actor uint) error {
|
||||||
|
return o.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Exec("SELECT pg_advisory_xact_lock(130013)").Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
v, current, fields, e := o.draft(tx, name, raw, clear)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if version != current {
|
||||||
|
return ErrConfigConflict
|
||||||
|
}
|
||||||
|
st := NewSettingService(tx)
|
||||||
|
if c, ok := v.(*SecurityConfig); ok {
|
||||||
|
if e = st.SetAllowRegister(c.AllowRegister); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, ok := v.(*StorageConfig); ok {
|
||||||
|
for _, f := range []func() error{func() error { return st.SetImageMaxMB(c.ImageMaxMB) }, func() error { return st.SetAttachmentMaxMB(c.AttachmentMaxMB) }, func() error { return st.SetAttachmentMaxCount(c.AttachmentMaxCount) }, func() error { return st.SetAttachmentExtLimit(c.AttachmentExtLimit) }, func() error { return st.SetAttachmentExts(c.AttachmentExts) }} {
|
||||||
|
if e = f(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
if e = tx.Save(&model.ModuleConfig{Name: name, Version: current + 1, Data: string(b)}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if name == "storage" {
|
||||||
|
if e = tx.Create(&model.ModuleConfig{Name: fmt.Sprintf("storage-%d", current+1), Version: current + 1, Data: string(b)}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, _ := json.Marshal(fields)
|
||||||
|
return tx.Create(&model.SettingsAudit{ActorID: actor, Module: name, Action: "save", Fields: string(f), Result: "已生效"}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (o *Operations) Audit(actor uint, module, action, result string) {
|
||||||
|
o.db.Create(&model.SettingsAudit{ActorID: actor, Module: module, Action: action, Fields: "[]", Result: result})
|
||||||
|
}
|
||||||
|
func (o *Operations) AuditRows(module string) ([]model.SettingsAudit, error) {
|
||||||
|
var a []model.SettingsAudit
|
||||||
|
switch module {
|
||||||
|
case "security", "filter", "maintenance":
|
||||||
|
e := o.db.Where("module = ?", module).Order("id desc").Limit(100).Find(&a).Error
|
||||||
|
return a, e
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func randomID() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, e := io.ReadFull(rand.Reader, b); e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
159
backend/service/operations_filter.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FilterHit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Word string `json:"word"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Start int `json:"start"`
|
||||||
|
End int `json:"end"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Excepted bool `json:"excepted"`
|
||||||
|
}
|
||||||
|
type FilterResult struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Hits []FilterHit `json:"hits"`
|
||||||
|
Skipped []string `json:"skipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFilter(s string) string {
|
||||||
|
return strings.ToLower(norm.NFKC.String(html.UnescapeString(s)))
|
||||||
|
}
|
||||||
|
func readableMarkdown(s string) string {
|
||||||
|
src := []byte(s)
|
||||||
|
doc := goldmark.DefaultParser().Parse(text.NewReader(src))
|
||||||
|
var b strings.Builder
|
||||||
|
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
if n.Type() == ast.TypeBlock {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
switch n.Kind() {
|
||||||
|
case ast.KindCodeBlock, ast.KindFencedCodeBlock, ast.KindCodeSpan, ast.KindHTMLBlock, ast.KindRawHTML, ast.KindAutoLink:
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
}
|
||||||
|
if t, ok := n.(*ast.Text); ok {
|
||||||
|
b.Write(t.Segment.Value(src))
|
||||||
|
if t.SoftLineBreak() || t.HardLineBreak() {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t, ok := n.(*ast.String); ok {
|
||||||
|
b.Write(t.Value)
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
func MatchFilter(c FilterConfig, scope, input string) FilterResult {
|
||||||
|
result := FilterResult{Result: "pass", Text: input, Hits: []FilterHit{}, Skipped: []string{}}
|
||||||
|
if scope == "body" || scope == "comment" {
|
||||||
|
result.Text = readableMarkdown(input)
|
||||||
|
result.Skipped = []string{"代码块", "行内代码", "链接目标及自动链接", "HTML 标签/HTML 块"}
|
||||||
|
}
|
||||||
|
result.Text = normalizeFilter(result.Text)
|
||||||
|
if !c.Enabled {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, r := range c.Rules {
|
||||||
|
if !r.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applies := false
|
||||||
|
for _, s := range r.Scopes {
|
||||||
|
if s == scope {
|
||||||
|
applies = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
word := normalizeFilter(r.Word)
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for pos := 0; pos < len(result.Text); {
|
||||||
|
i := strings.Index(result.Text[pos:], word)
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start := pos + i
|
||||||
|
end := start + len(word)
|
||||||
|
excepted := false
|
||||||
|
for _, ex := range r.Exceptions {
|
||||||
|
ex = normalizeFilter(ex)
|
||||||
|
if ex == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for off := 0; off < len(result.Text); {
|
||||||
|
k := strings.Index(result.Text[off:], ex)
|
||||||
|
if k < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a := off + k
|
||||||
|
if a <= start && a+len(ex) >= end {
|
||||||
|
excepted = true
|
||||||
|
}
|
||||||
|
off = a + len(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Hits = append(result.Hits, FilterHit{ID: r.ID, Word: r.Word, Scope: scope, Start: utf8.RuneCountInString(result.Text[:start]), End: utf8.RuneCountInString(result.Text[:end]), Action: r.Action, Excepted: excepted})
|
||||||
|
if !excepted {
|
||||||
|
if r.Action == "block" {
|
||||||
|
result.Result = "block"
|
||||||
|
} else if result.Result == "pass" {
|
||||||
|
result.Result = "log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func (o *Operations) Filter(scope, input string, actor uint) error {
|
||||||
|
v, _, e := o.read(o.db, "filter")
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("内容过滤暂不可用,请稍后重试")
|
||||||
|
}
|
||||||
|
r := MatchFilter(*v.(*FilterConfig), scope, input)
|
||||||
|
if len(r.Hits) > 0 {
|
||||||
|
ids := []string{}
|
||||||
|
for _, h := range r.Hits {
|
||||||
|
if !h.Excepted {
|
||||||
|
ids = append(ids, h.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(ids)
|
||||||
|
o.Audit(actor, "filter", scope+":"+r.Result, string(b))
|
||||||
|
}
|
||||||
|
if r.Result == "block" {
|
||||||
|
return errors.New("内容未通过站点规则,请修改后重试")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (o *Operations) TestFilter(raw json.RawMessage, scope, input string) (FilterResult, error) {
|
||||||
|
if len(input) > 100000 {
|
||||||
|
return FilterResult{}, errors.New("测试文本最多 100000 字节")
|
||||||
|
}
|
||||||
|
v, _, _, e := o.draft(o.db, "filter", raw, nil)
|
||||||
|
if e != nil {
|
||||||
|
return FilterResult{}, e
|
||||||
|
}
|
||||||
|
c := *v.(*FilterConfig)
|
||||||
|
c.Enabled = true
|
||||||
|
return MatchFilter(c, scope, input), nil
|
||||||
|
}
|
||||||
450
backend/service/operations_mail.go
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
stdhtml "html"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mailPayload struct{ To, Subject, Body string }
|
||||||
|
|
||||||
|
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
||||||
|
password, e := o.open(c.Password, "mail:password")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Duration(c.Timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, e := safeDial(ctx, "tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
_ = conn.SetDeadline(deadline)
|
||||||
|
tlsCfg := &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12, RootCAs: o.smtpRoots}
|
||||||
|
if c.TLS == "tls" {
|
||||||
|
t := tls.Client(conn, tlsCfg)
|
||||||
|
if e = t.HandshakeContext(ctx); e != nil {
|
||||||
|
return errors.New("TLS 协商或证书校验失败")
|
||||||
|
}
|
||||||
|
conn = t
|
||||||
|
}
|
||||||
|
client, e := smtp.NewClient(conn, c.Host)
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("SMTP 握手失败")
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if c.TLS == "starttls" {
|
||||||
|
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||||
|
return errors.New("服务器未提供必需 STARTTLS")
|
||||||
|
}
|
||||||
|
if e = client.StartTLS(tlsCfg); e != nil {
|
||||||
|
return errors.New("TLS 协商或证书校验失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e = client.Auth(smtp.PlainAuth("", c.Username, password, c.Host)); e != nil {
|
||||||
|
return errors.New("SMTP 认证失败")
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !validEmail(p.To) {
|
||||||
|
return errors.New("测试收件邮箱无效")
|
||||||
|
}
|
||||||
|
if e = client.Mail(c.From); e != nil {
|
||||||
|
return errors.New("服务器拒绝发件人")
|
||||||
|
}
|
||||||
|
if e = client.Rcpt(p.To); e != nil {
|
||||||
|
return errors.New("服务器拒绝收件人")
|
||||||
|
}
|
||||||
|
w, e := client.Data()
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("提交邮件失败")
|
||||||
|
}
|
||||||
|
if c.FromName == "" {
|
||||||
|
st, e := NewSettingService(o.db).Public()
|
||||||
|
if e == nil {
|
||||||
|
c.FromName = st.SiteName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
from := (&mail.Address{Name: c.FromName, Address: c.From}).String()
|
||||||
|
reply := c.ReplyTo
|
||||||
|
if reply == "" {
|
||||||
|
reply = c.From
|
||||||
|
}
|
||||||
|
// QQ / 网关常按 7bit 路径转发:HTML 必须用 quoted-printable,避免长行与中文破坏 style。
|
||||||
|
var msg bytes.Buffer
|
||||||
|
msg.WriteString("From: " + from + "\r\n")
|
||||||
|
msg.WriteString("To: " + p.To + "\r\n")
|
||||||
|
msg.WriteString("Reply-To: " + reply + "\r\n")
|
||||||
|
msg.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", p.Subject) + "\r\n")
|
||||||
|
msg.WriteString("Message-ID: <" + id + "@jiang13.local>\r\n")
|
||||||
|
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||||
|
msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||||
|
if e = writeQuotedPrintableHTML(&msg, p.Body); e != nil {
|
||||||
|
return errors.New("提交邮件失败")
|
||||||
|
}
|
||||||
|
if _, e = io.Copy(w, &msg); e != nil {
|
||||||
|
return errors.New("提交邮件失败或超时")
|
||||||
|
}
|
||||||
|
if e = w.Close(); e != nil {
|
||||||
|
return errors.New("服务器未确认接受邮件")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMailSubject = "{{site_name}} · {{purpose}}"
|
||||||
|
|
||||||
|
// 旧版内置正文:读取时若仍是此值,升级为新默认模板。
|
||||||
|
const legacyMailBody = "{{logo}}<h1>{{site_name}} · {{purpose}}</h1><p>您的验证码:</p><p><strong>{{code}}</strong></p><p>15 分钟内有效。如果不是您本人操作,请忽略本邮件。</p><p><a href=\"{{link}}\">前往站点完成{{purpose}}</a>(请粘贴以上验证码)</p>"
|
||||||
|
|
||||||
|
const defaultMailBody = `<div class="email-wrap" style="margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;">
|
||||||
|
<style>
|
||||||
|
.email-wrap{margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;}
|
||||||
|
.email-card{max-width:520px;margin:0 auto;background:#fff;border:1px solid #eef2f6;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,.06);overflow:hidden;padding:40px 36px 32px;}
|
||||||
|
.email-logo,.email-wrap img{display:block;margin:0 auto 24px;max-width:180px;height:auto;}
|
||||||
|
.email-title{margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;letter-spacing:-.02em;}
|
||||||
|
.email-lead{margin:0 0 24px;font-size:15px;color:#475569;text-align:center;}
|
||||||
|
.email-code-box{margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;}
|
||||||
|
.email-code-label{margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#475569;}
|
||||||
|
.email-code{margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;line-height:1.2;color:#0f172a;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
|
||||||
|
.email-hint{margin:0 0 20px;font-size:14px;color:#334155;text-align:center;}
|
||||||
|
.email-btn-wrap{text-align:center;margin:0 0 18px;}
|
||||||
|
.email-btn{display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#fff !important;text-decoration:none;font-size:15px;font-weight:600;}
|
||||||
|
.email-link{margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;}
|
||||||
|
.email-link a{color:#1d4ed8;}
|
||||||
|
.email-hr{height:1px;margin:0 0 20px;background:#e2e8f0;border:0;}
|
||||||
|
.email-foot{margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;}
|
||||||
|
.email-foot strong{color:#334155;font-weight:600;}
|
||||||
|
@media (max-width:480px){.email-card{padding:28px 20px;border-radius:20px;}.email-code{font-size:32px;letter-spacing:.12em;}.email-btn{display:block;}}
|
||||||
|
</style>
|
||||||
|
<div class="email-card" style="max-width:520px;margin:0 auto;background:#ffffff;border:1px solid #eef2f6;border-radius:24px;padding:40px 36px 32px;">
|
||||||
|
{{logo}}
|
||||||
|
<h1 class="email-title" style="margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;">{{purpose}}</h1>
|
||||||
|
<p class="email-lead" style="margin:0 0 24px;font-size:15px;color:#475569;text-align:center;">您好,感谢使用 {{site_name}}。请使用下面的验证码完成{{purpose}}。</p>
|
||||||
|
<div class="email-code-box" style="margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;">
|
||||||
|
<div class="email-code-label" style="margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;color:#475569;">您的验证码</div>
|
||||||
|
<div class="email-code" style="margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;color:#0f172a;font-family:ui-monospace,Menlo,Consolas,monospace;">{{code}}</div>
|
||||||
|
</div>
|
||||||
|
<p class="email-hint" style="margin:0 0 20px;font-size:14px;color:#334155;text-align:center;">验证码 15 分钟内有效。您也可以点击下方按钮前往站点继续操作。</p>
|
||||||
|
<div class="email-btn-wrap" style="text-align:center;margin:0 0 18px;">
|
||||||
|
<a class="email-btn" href="{{link}}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;">前往完成{{purpose}}</a>
|
||||||
|
</div>
|
||||||
|
<p class="email-link" style="margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;">若按钮无法点击,请复制链接:<br><a href="{{link}}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;">{{link}}</a></p>
|
||||||
|
<div class="email-hr" style="height:1px;margin:0 0 20px;background:#e2e8f0;"></div>
|
||||||
|
<p class="email-foot" style="margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;">本邮件由 <strong style="color:#334155;">{{site_name}}</strong> 系统发出,请勿直接回复。<br>如非本人操作,请忽略本邮件。<br><span style="color:#334155;">{{site_url}}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
func (c *MailConfig) applyTemplateDefaults() {
|
||||||
|
if strings.TrimSpace(c.SubjectTemplate) == "" {
|
||||||
|
c.SubjectTemplate = defaultMailSubject
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(c.BodyTemplate)
|
||||||
|
if body == "" || body == legacyMailBody {
|
||||||
|
c.BodyTemplate = defaultMailBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Operations) MailPreview(code string, kinds ...string) mailPayload {
|
||||||
|
kind := ""
|
||||||
|
if len(kinds) > 0 {
|
||||||
|
kind = kinds[0]
|
||||||
|
}
|
||||||
|
c := MailConfig{}
|
||||||
|
if o.db != nil {
|
||||||
|
if v, _, e := o.read(o.db, "mail"); e == nil {
|
||||||
|
c = *v.(*MailConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.renderMail(c, code, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Operations) renderMail(c MailConfig, code, kind string) mailPayload {
|
||||||
|
c.applyTemplateDefaults()
|
||||||
|
siteName, logoURL := "", ""
|
||||||
|
if o.db != nil {
|
||||||
|
if st, e := NewSettingService(o.db).Public(); e == nil {
|
||||||
|
siteName, logoURL = st.SiteName, st.LogoLightURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
purpose, path := "账号验证", "/register"
|
||||||
|
if kind == "reset" {
|
||||||
|
purpose, path = "密码找回", "/reset-password"
|
||||||
|
}
|
||||||
|
base, dev := "", false
|
||||||
|
if o.cfg != nil {
|
||||||
|
base = strings.TrimRight(o.cfg.SiteURL, "/")
|
||||||
|
dev = o.cfg.DevMode
|
||||||
|
}
|
||||||
|
link := ""
|
||||||
|
if base != "" {
|
||||||
|
link = base + path
|
||||||
|
}
|
||||||
|
absLogo := mailLogoURL(dev, base, logoURL)
|
||||||
|
body := strings.NewReplacer(
|
||||||
|
"{{site_name}}", stdhtml.EscapeString(siteName),
|
||||||
|
"{{purpose}}", stdhtml.EscapeString(purpose),
|
||||||
|
"{{code}}", stdhtml.EscapeString(code),
|
||||||
|
"{{link}}", stdhtml.EscapeString(link),
|
||||||
|
"{{site_url}}", stdhtml.EscapeString(base),
|
||||||
|
"{{logo_url}}", stdhtml.EscapeString(absLogo),
|
||||||
|
"{{logo}}", mailLogoImg(absLogo),
|
||||||
|
).Replace(c.BodyTemplate)
|
||||||
|
subject := strings.NewReplacer(
|
||||||
|
"{{site_name}}", mailOneLine(siteName),
|
||||||
|
"{{purpose}}", purpose,
|
||||||
|
"{{code}}", mailOneLine(code),
|
||||||
|
"{{link}}", mailOneLine(link),
|
||||||
|
"{{site_url}}", mailOneLine(base),
|
||||||
|
"{{logo_url}}", "",
|
||||||
|
"{{logo}}", "",
|
||||||
|
).Replace(c.SubjectTemplate)
|
||||||
|
return mailPayload{Subject: mailOneLine(subject), Body: prepareMailHTML(body)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailOneLine(s string) string {
|
||||||
|
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailLogoURL(dev bool, base, logoURL string) string {
|
||||||
|
if logoURL == "" || base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
origin, e := url.Parse(base)
|
||||||
|
ref, re := url.Parse(logoURL)
|
||||||
|
if e != nil || re != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u := origin.ResolveReference(ref)
|
||||||
|
if u.Scheme != "https" && !(dev && u.Scheme == "http") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailLogoImg(abs string) string {
|
||||||
|
if abs == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "<img class=\"email-logo\" width=\"180\" alt=\"站点 Logo\" src=\"" + stdhtml.EscapeString(abs) + "\" style=\"display:block;margin:0 auto 24px;max-width:180px;height:auto;\">"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Operations) TestMail(ctx context.Context, raw json.RawMessage, clear []string, send bool, to string, actor uint) error {
|
||||||
|
v, _, _, e := o.draft(o.db, "mail", raw, clear)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
c := *v.(*MailConfig)
|
||||||
|
c.Enabled = true
|
||||||
|
if e = o.validate(o.db, "mail", &c); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
var p *mailPayload
|
||||||
|
if send {
|
||||||
|
preview := o.renderMail(c, "测试邮件,无有效验证码", "")
|
||||||
|
preview.To = to
|
||||||
|
p = &preview
|
||||||
|
}
|
||||||
|
e = o.smtp(ctx, c, p, randomID())
|
||||||
|
result := "连接、TLS 与认证通过"
|
||||||
|
if send {
|
||||||
|
result = "服务器已接受(不代表送达)"
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
result = e.Error()
|
||||||
|
}
|
||||||
|
o.Audit(actor, "mail", "test", result)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
func (o *Operations) EnqueueMail(tx *gorm.DB, to, kind, code, dedupe string) error {
|
||||||
|
p := o.MailPreview(code, kind)
|
||||||
|
p.To = to
|
||||||
|
b, _ := json.Marshal(p)
|
||||||
|
encrypted, e := o.seal(string(b), "mail-task")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
masked := "***"
|
||||||
|
parts := strings.Split(to, "@")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
masked = "***@" + parts[1]
|
||||||
|
}
|
||||||
|
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.MailTask{Dedupe: dedupe, Kind: kind, Recipient: masked, Payload: encrypted, Status: "queued", NextAt: time.Now()}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lease is committed before I/O; expired leases are recovered after a restart.
|
||||||
|
func (o *Operations) ProcessMail(ctx context.Context) error {
|
||||||
|
v, _, e := o.read(o.db, "mail")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
c := *v.(*MailConfig)
|
||||||
|
if !c.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
o.db.Model(&model.MailTask{}).Where("status = ? AND attempts >= 3 AND next_at <= ?", "sending", time.Now()).Updates(map[string]any{"status": "failed", "summary": "重试次数已用完,最终接受状态未知", "payload": ""})
|
||||||
|
var task model.MailTask
|
||||||
|
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status IN ? AND next_at <= ?", []string{"queued", "retry", "sending"}, time.Now()).Order("id").First(&task).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
task.Attempts++
|
||||||
|
task.Status = "sending"
|
||||||
|
task.NextAt = time.Now().Add(2 * time.Minute)
|
||||||
|
return tx.Save(&task).Error
|
||||||
|
})
|
||||||
|
if errors.Is(e, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
plaintext, e := o.open(task.Payload, "mail-task")
|
||||||
|
var p mailPayload
|
||||||
|
if e == nil {
|
||||||
|
e = json.Unmarshal([]byte(plaintext), &p)
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
e = o.smtp(ctx, c, &p, task.Dedupe)
|
||||||
|
}
|
||||||
|
status, summary := "accepted", "服务器已接受(不代表送达)"
|
||||||
|
if e != nil {
|
||||||
|
status = "retry"
|
||||||
|
summary = "发送失败,请检查邮件服务"
|
||||||
|
if task.Attempts >= 3 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates := map[string]any{"status": status, "summary": summary, "next_at": time.Now().Add(time.Duration(task.Attempts) * time.Minute)}
|
||||||
|
if status == "accepted" || status == "failed" {
|
||||||
|
updates["payload"] = ""
|
||||||
|
}
|
||||||
|
return o.db.Model(&model.MailTask{}).Where("id = ? AND attempts = ? AND status = ?", task.ID, task.Attempts, "sending").Updates(updates).Error
|
||||||
|
}
|
||||||
|
func (o *Operations) Run(ctx context.Context) {
|
||||||
|
tick := time.NewTicker(2 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
n := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
_ = o.ProcessMail(ctx)
|
||||||
|
n++
|
||||||
|
if n%300 == 0 {
|
||||||
|
o.db.Where("expires_at < ?", time.Now()).Delete(&model.ActionCounter{})
|
||||||
|
o.db.Where("expires_at < ?", time.Now().Add(-24*time.Hour)).Delete(&model.EmailChallenge{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (o *Operations) MailRows() ([]model.MailTask, error) {
|
||||||
|
var a []model.MailTask
|
||||||
|
e := o.db.Order("id desc").Limit(100).Find(&a).Error
|
||||||
|
return a, e
|
||||||
|
}
|
||||||
|
func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
if !validEmail(email) {
|
||||||
|
return 0, errors.New("邮箱格式无效")
|
||||||
|
}
|
||||||
|
cfg, e := o.Security()
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
if purpose != "register" && purpose != "reset" {
|
||||||
|
return 0, errors.New("用途无效")
|
||||||
|
}
|
||||||
|
if (purpose == "register" && (!cfg.VerifyEmail || !cfg.AllowRegister)) || (purpose == "reset" && !cfg.PasswordReset) {
|
||||||
|
return 0, errors.New("该账号邮件功能未开启")
|
||||||
|
}
|
||||||
|
for _, q := range []struct {
|
||||||
|
k string
|
||||||
|
n, s int
|
||||||
|
}{{"email:ip:" + ip, 20, 3600}, {"email:resend:" + purpose + ":" + email, 1, cfg.ResendInterval}, {"email:hour:" + email, cfg.EmailHourly, 3600}} {
|
||||||
|
wait, e := o.Quota(q.k, q.n, q.s)
|
||||||
|
if e != nil || wait > 0 {
|
||||||
|
return wait, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if purpose == "reset" {
|
||||||
|
var n int64
|
||||||
|
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code := randomID()
|
||||||
|
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||||
|
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Create(&model.EmailChallenge{Hash: hash, Email: email, Purpose: purpose, ExpiresAt: time.Now().Add(15 * time.Minute)}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return o.EnqueueMail(tx, email, purpose, code, hash)
|
||||||
|
})
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
func (o *Operations) ConsumeCode(email, purpose, code string) error {
|
||||||
|
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(strings.ToLower(strings.TrimSpace(email))+":"+purpose+":"+code), time.Now()).Update("used", true)
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
if r.RowsAffected != 1 {
|
||||||
|
return fmt.Errorf("验证码无效或已过期")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe merged drafts so partial API updates cannot bypass activation validation.
|
||||||
|
func (o *Operations) ProbeBeforeSave(ctx context.Context, name string, raw json.RawMessage, clear []string) error {
|
||||||
|
v, _, _, e := o.draft(o.db, name, raw, clear)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e = o.validate(o.db, name, v); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
switch c := v.(type) {
|
||||||
|
case *StorageConfig:
|
||||||
|
return o.TestStorage(ctx, raw, clear)
|
||||||
|
case *MailConfig:
|
||||||
|
if c.Enabled {
|
||||||
|
return o.smtp(ctx, *c, nil, randomID())
|
||||||
|
}
|
||||||
|
case *SecurityConfig:
|
||||||
|
old, e := o.Security()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if (c.VerifyEmail && !old.VerifyEmail) || (c.PasswordReset && !old.PasswordReset) {
|
||||||
|
mail, _, e := o.read(o.db, "mail")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return o.smtp(ctx, *mail.(*MailConfig), nil, randomID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
265
backend/service/operations_mail_html.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 手机 QQ / 163 等会丢弃 <style>;发送与预览前把简单 class 规则内联,并做成完整 HTML 文档。
|
||||||
|
var (
|
||||||
|
mailCSSCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||||||
|
mailCSSRuleRe = regexp.MustCompile(`([^{}@]+)\{([^{}]+)\}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareMailHTML(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
doc, err := html.Parse(strings.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return wrapMailDocument(raw)
|
||||||
|
}
|
||||||
|
css := map[string]string{}
|
||||||
|
collectAndRemoveMailStyles(doc, css)
|
||||||
|
if len(css) > 0 {
|
||||||
|
applyMailClassStyles(doc, css)
|
||||||
|
}
|
||||||
|
ensureMailDocumentHead(doc)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := html.Render(&buf, doc); err != nil {
|
||||||
|
return wrapMailDocument(raw)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapMailDocument(body string) string {
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
if strings.Contains(lower, "<html") {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;padding:0;">` + body + `</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectAndRemoveMailStyles(n *html.Node, css map[string]string) {
|
||||||
|
if n.Type == html.ElementNode && n.Data == "style" {
|
||||||
|
var b strings.Builder
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
if c.Type == html.TextNode {
|
||||||
|
b.WriteString(c.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseMailCSSRules(b.String(), css)
|
||||||
|
// 删除 style 节点
|
||||||
|
parent := n.Parent
|
||||||
|
if parent != nil {
|
||||||
|
next := n.NextSibling
|
||||||
|
parent.RemoveChild(n)
|
||||||
|
if next != nil {
|
||||||
|
collectAndRemoveMailStyles(next, css)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; {
|
||||||
|
next := c.NextSibling
|
||||||
|
collectAndRemoveMailStyles(c, css)
|
||||||
|
c = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMailCSSRules(src string, css map[string]string) {
|
||||||
|
src = mailCSSCommentRe.ReplaceAllString(src, "")
|
||||||
|
src = stripMailAtRules(src)
|
||||||
|
for _, m := range mailCSSRuleRe.FindAllStringSubmatch(src, -1) {
|
||||||
|
decls := normalizeMailDecls(m[2])
|
||||||
|
if decls == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sel := range strings.Split(m[1], ",") {
|
||||||
|
sel = strings.TrimSpace(sel)
|
||||||
|
// 仅支持单一 class:.email-card
|
||||||
|
if !strings.HasPrefix(sel, ".") || strings.ContainsAny(sel, " \t>+~[:#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(sel, ".")
|
||||||
|
if name == "" || strings.Contains(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
css[name] = mergeMailDecls(css[name], decls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去掉 @media / @supports 等块,避免大括号干扰简单解析。
|
||||||
|
func stripMailAtRules(src string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(src); {
|
||||||
|
if src[i] == '@' {
|
||||||
|
j := i
|
||||||
|
for j < len(src) && src[j] != '{' {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j >= len(src) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
depth := 0
|
||||||
|
for j < len(src) {
|
||||||
|
if src[j] == '{' {
|
||||||
|
depth++
|
||||||
|
} else if src[j] == '}' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
j++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(src[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMailDecls(s string) string {
|
||||||
|
parts := strings.Split(s, ";")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, _, ok := strings.Cut(p, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.ToLower(strings.TrimSpace(key))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 后写覆盖先写
|
||||||
|
if idx, ok := seen[key]; ok {
|
||||||
|
out[idx] = p
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = len(out)
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ";")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeMailDecls(base, extra string) string {
|
||||||
|
if base == "" {
|
||||||
|
return normalizeMailDecls(extra)
|
||||||
|
}
|
||||||
|
if extra == "" {
|
||||||
|
return normalizeMailDecls(base)
|
||||||
|
}
|
||||||
|
return normalizeMailDecls(base + ";" + extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMailClassStyles(n *html.Node, css map[string]string) {
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
class := ""
|
||||||
|
styleIdx := -1
|
||||||
|
styleVal := ""
|
||||||
|
for i, a := range n.Attr {
|
||||||
|
switch strings.ToLower(a.Key) {
|
||||||
|
case "class":
|
||||||
|
class = a.Val
|
||||||
|
case "style":
|
||||||
|
styleIdx = i
|
||||||
|
styleVal = a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if class != "" {
|
||||||
|
var fromClass string
|
||||||
|
for _, c := range strings.Fields(class) {
|
||||||
|
if d, ok := css[c]; ok {
|
||||||
|
fromClass = mergeMailDecls(fromClass, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fromClass != "" {
|
||||||
|
// 元素已有 style 优先(等价于内联覆盖 class)
|
||||||
|
merged := mergeMailDecls(fromClass, styleVal)
|
||||||
|
if styleIdx >= 0 {
|
||||||
|
n.Attr[styleIdx].Val = merged
|
||||||
|
} else {
|
||||||
|
n.Attr = append(n.Attr, html.Attribute{Key: "style", Val: merged})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
applyMailClassStyles(c, css)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMailDocumentHead(doc *html.Node) {
|
||||||
|
var htmlNode *html.Node
|
||||||
|
for c := doc.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
if c.Type == html.ElementNode && c.Data == "html" {
|
||||||
|
htmlNode = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if htmlNode == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasLang := false
|
||||||
|
for _, a := range htmlNode.Attr {
|
||||||
|
if strings.EqualFold(a.Key, "lang") {
|
||||||
|
hasLang = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasLang {
|
||||||
|
htmlNode.Attr = append(htmlNode.Attr, html.Attribute{Key: "lang", Val: "zh-CN"})
|
||||||
|
}
|
||||||
|
var head *html.Node
|
||||||
|
for c := htmlNode.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
if c.Type == html.ElementNode && c.Data == "head" {
|
||||||
|
head = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if head == nil {
|
||||||
|
head = &html.Node{Type: html.ElementNode, Data: "head"}
|
||||||
|
htmlNode.InsertBefore(head, htmlNode.FirstChild)
|
||||||
|
}
|
||||||
|
hasCharset := false
|
||||||
|
for c := head.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
if c.Type == html.ElementNode && c.Data == "meta" {
|
||||||
|
for _, a := range c.Attr {
|
||||||
|
if strings.EqualFold(a.Key, "charset") {
|
||||||
|
hasCharset = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasCharset {
|
||||||
|
meta := &html.Node{
|
||||||
|
Type: html.ElementNode,
|
||||||
|
Data: "meta",
|
||||||
|
Attr: []html.Attribute{{Key: "charset", Val: "UTF-8"}},
|
||||||
|
}
|
||||||
|
head.InsertBefore(meta, head.FirstChild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeQuotedPrintableHTML(w interface{ Write([]byte) (int, error) }, body string) error {
|
||||||
|
qp := quotedprintable.NewWriter(w)
|
||||||
|
if _, err := qp.Write([]byte(body)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return qp.Close()
|
||||||
|
}
|
||||||
131
backend/service/operations_maintenance.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/freefire/jiang13-bbs/version"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (o *Operations) ResetPassword(email, code, password string) error {
|
||||||
|
cfg, e := o.Security()
|
||||||
|
if e != nil || !cfg.PasswordReset {
|
||||||
|
return errors.New("找回密码未开启")
|
||||||
|
}
|
||||||
|
if len(password) < 6 || len(password) > 64 {
|
||||||
|
return errors.New("密码长度须为 6–64 字节")
|
||||||
|
}
|
||||||
|
hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return o.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
scoped := *o
|
||||||
|
scoped.db = tx
|
||||||
|
if e := scoped.ConsumeCode(email, "reset", code); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
if e := tx.Where("LOWER(email) = ?", strings.ToLower(strings.TrimSpace(email))).Limit(2).Find(&users).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if len(users) != 1 {
|
||||||
|
return errors.New("账号不可用")
|
||||||
|
}
|
||||||
|
return tx.Model(&model.User{}).Where("id = ?", users[0].ID).Updates(map[string]any{"password": string(hash), "token_version": gorm.Expr("token_version + 1")}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (o *Operations) Diagnostics(ctx context.Context) map[string]any {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
dbState := "正常"
|
||||||
|
sql, e := o.db.DB()
|
||||||
|
if e != nil {
|
||||||
|
dbState = "异常"
|
||||||
|
} else if sql.PingContext(ctx) != nil {
|
||||||
|
dbState = "异常"
|
||||||
|
}
|
||||||
|
local := "正常"
|
||||||
|
if o.LocalWritable() != nil {
|
||||||
|
local = "异常"
|
||||||
|
}
|
||||||
|
v, _, e := o.read(o.db, "storage")
|
||||||
|
storage := "未知"
|
||||||
|
if e == nil {
|
||||||
|
storage = "本地:" + local
|
||||||
|
if v.(*StorageConfig).Backend == "s3" {
|
||||||
|
storage = "S3:未知(使用存储页测试)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var last model.SettingsAudit
|
||||||
|
result := map[string]any{"state": "未知"}
|
||||||
|
if o.db.Where("module = ? AND action = ?", "mail", "test").Order("id desc").First(&last).Error == nil {
|
||||||
|
result = map[string]any{"result": last.Result, "tested_at": last.CreatedAt, "note": "历史测试,不代表持续健康"}
|
||||||
|
}
|
||||||
|
var queued int64
|
||||||
|
queueErr := o.db.WithContext(ctx).Model(&model.MailTask{}).Where("status IN ?", []string{"queued", "retry", "sending"}).Count(&queued).Error
|
||||||
|
queueState := "正常 · PostgreSQL 持久邮件队列"
|
||||||
|
if queueErr != nil {
|
||||||
|
queueState = "异常 · 暂时无法读取邮件队列"
|
||||||
|
}
|
||||||
|
return map[string]any{"version": version.Version, "uptime_seconds": int(time.Since(o.Started).Seconds()), "database": dbState, "cache": "未配置独立应用缓存", "tasks": queueState, "queued_mail": queued, "storage": storage, "mail_test": result}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft references live in browsers, so unattached database rows are NEVER deleted.
|
||||||
|
func (o *Operations) ScanTemporary() (map[string]any, error) {
|
||||||
|
m, e := o.Maintenance()
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
count, size := 0, int64(0)
|
||||||
|
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
|
||||||
|
for _, dir := range []string{filepath.Join(o.cfg.DataDir, "uploads"), filepath.Join(o.cfg.DataDir, "private")} {
|
||||||
|
e = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.Type()&os.ModeSymlink != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(d.Name(), ".partial") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, e := d.Info()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if info.ModTime().Before(cutoff) {
|
||||||
|
count++
|
||||||
|
size += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates, e := o.temporaryCandidates()
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
var drafts int64
|
||||||
|
if e = o.db.Model(&model.PostAttachment{}).Where("post_id = 0").Count(&drafts).Error; e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return map[string]any{"candidates": candidates, "expired_partial_count": count, "expired_partial_bytes": size, "protected_draft_attachments": drafts, "retention_days": m.TempDays, "message": "保留所有已入库图片和草稿附件。只清理受本版本上传锁保护且已过期的候选文件;未知来源的旧 partial 不在线删除。"}, nil
|
||||||
|
}
|
||||||
|
func (o *Operations) ClearMailLogs() (int64, error) {
|
||||||
|
v, _, e := o.read(o.db, "mail")
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
c := v.(*MailConfig)
|
||||||
|
r := o.db.Where("status IN ? AND updated_at < ?", []string{"accepted", "failed"}, time.Now().Add(-time.Duration(c.Retention)*24*time.Hour)).Delete(&model.MailTask{})
|
||||||
|
return r.RowsAffected, r.Error
|
||||||
|
}
|
||||||
120
backend/service/operations_security.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func counterKey(key string) string { x := sha256.Sum256([]byte(key)); return hex.EncodeToString(x[:]) }
|
||||||
|
|
||||||
|
// PostgreSQL row locks provide shared atomic quotas across application instances.
|
||||||
|
func (o *Operations) Quota(key string, limit, seconds int) (int, error) {
|
||||||
|
if seconds <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
wait := 0
|
||||||
|
e := o.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
key = counterKey(key)
|
||||||
|
if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.ActionCounter{Key: key, ExpiresAt: time.Now().Add(time.Duration(seconds) * time.Second)}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
var c model.ActionCounter
|
||||||
|
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&c, "key = ?", key).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !now.Before(c.ExpiresAt) {
|
||||||
|
c.Count = 0
|
||||||
|
c.ExpiresAt = now.Add(time.Duration(seconds) * time.Second)
|
||||||
|
}
|
||||||
|
if c.Count >= limit {
|
||||||
|
wait = int(time.Until(c.ExpiresAt).Seconds()) + 1
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.Count++
|
||||||
|
return tx.Save(&c).Error
|
||||||
|
})
|
||||||
|
return wait, e
|
||||||
|
}
|
||||||
|
func (o *Operations) FailureWait(account, ip string, c SecurityConfig) (int, error) {
|
||||||
|
var rows []model.ActionCounter
|
||||||
|
if e := o.db.Where("key IN ? AND expires_at > ?", []string{counterKey("failure:account:" + strings.ToLower(strings.TrimSpace(account))), counterKey("failure:ip:" + ip)}, time.Now()).Find(&rows).Error; e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
limit := c.LoginFailures
|
||||||
|
if r.Key == counterKey("failure:ip:"+ip) {
|
||||||
|
limit *= 4
|
||||||
|
}
|
||||||
|
if r.Count >= limit {
|
||||||
|
return int(time.Until(r.ExpiresAt).Seconds()) + 1, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (o *Operations) RecordFailure(account, ip string, c SecurityConfig) {
|
||||||
|
_, _ = o.Quota("failure:account:"+strings.ToLower(strings.TrimSpace(account)), c.LoginFailures, c.LoginWindow)
|
||||||
|
_, _ = o.Quota("failure:ip:"+ip, c.LoginFailures*4, c.LoginWindow)
|
||||||
|
}
|
||||||
|
func (o *Operations) ClearFailure(account string) {
|
||||||
|
o.db.Delete(&model.ActionCounter{}, "key = ?", counterKey("failure:account:"+strings.ToLower(strings.TrimSpace(account))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve once, validate every address, and dial the validated IP to prevent DNS rebinding.
|
||||||
|
// Private targets require an exact deployment allowlist entry, not a settings checkbox.
|
||||||
|
func safeDial(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
host, port, e := net.SplitHostPort(address)
|
||||||
|
if e != nil {
|
||||||
|
return nil, errors.New("连接地址无效")
|
||||||
|
}
|
||||||
|
allow := false
|
||||||
|
for _, v := range strings.Split(os.Getenv("SERVICE_PRIVATE_HOSTS"), ",") {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(v), host) {
|
||||||
|
allow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ips, e := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if e != nil || len(ips) == 0 {
|
||||||
|
return nil, errors.New("地址解析失败")
|
||||||
|
}
|
||||||
|
for _, a := range ips {
|
||||||
|
if !allow && (restrictedServiceIP(a.IP) || !a.IP.IsGlobalUnicast() || a.IP.IsPrivate() || a.IP.IsLoopback() || a.IP.IsLinkLocalUnicast() || a.IP.IsUnspecified()) {
|
||||||
|
return nil, errors.New("目标地址受限;私网服务需部署 SERVICE_PRIVATE_HOSTS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var last error
|
||||||
|
for _, a := range ips {
|
||||||
|
c, e := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
||||||
|
if e == nil {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
last = e
|
||||||
|
}
|
||||||
|
_ = last
|
||||||
|
return nil, fmt.Errorf("连接失败或超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
func restrictedServiceIP(ip net.IP) bool {
|
||||||
|
a, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
a = a.Unmap()
|
||||||
|
for _, p := range []string{"100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "2001:db8::/32"} {
|
||||||
|
if netip.MustParsePrefix(p).Contains(a) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
243
backend/service/operations_storage.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
||||||
|
u, e := url.Parse(c.Endpoint)
|
||||||
|
if e != nil {
|
||||||
|
return nil, errors.New("存储地址无效")
|
||||||
|
}
|
||||||
|
access, e := o.open(c.AccessKey, "storage:access_key")
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
secret, e := o.open(c.SecretKey, "storage:secret_key")
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
lookup := minio.BucketLookupDNS
|
||||||
|
if c.PathStyle {
|
||||||
|
lookup = minio.BucketLookupPath
|
||||||
|
}
|
||||||
|
tr := &http.Transport{DialContext: safeDial, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 15 * time.Second, DisableKeepAlives: true}
|
||||||
|
return minio.New(u.Host, &minio.Options{Creds: credentials.NewStaticV4(access, secret, ""), Secure: u.Scheme == "https", Region: c.Region, BucketLookup: lookup, Transport: boundedS3Transport{base: tr, host: func() string {
|
||||||
|
if c.PathStyle {
|
||||||
|
return u.Host
|
||||||
|
}
|
||||||
|
return c.Bucket + "." + u.Host
|
||||||
|
}(), scheme: u.Scheme}})
|
||||||
|
}
|
||||||
|
func (o *Operations) TestStorage(ctx context.Context, raw json.RawMessage, clear []string) error {
|
||||||
|
v, _, _, e := o.draft(o.db, "storage", raw, clear)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
c := *v.(*StorageConfig)
|
||||||
|
if c.Backend == "local" {
|
||||||
|
return o.LocalWritable()
|
||||||
|
}
|
||||||
|
client, e := o.s3(c)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
key := c.Prefix + "tests/" + randomID()
|
||||||
|
payload := []byte("jiang13 storage probe")
|
||||||
|
if _, e = client.PutObject(ctx, c.Bucket, key, bytes.NewReader(payload), int64(len(payload)), minio.PutObjectOptions{ContentType: "text/plain", DisableMultipart: true}); e != nil {
|
||||||
|
return errors.New("存储写入失败(连接、凭据或权限)")
|
||||||
|
}
|
||||||
|
cleanup := func() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||||
|
}
|
||||||
|
object, e := client.GetObject(ctx, c.Bucket, key, minio.GetObjectOptions{})
|
||||||
|
if e != nil {
|
||||||
|
_ = cleanup()
|
||||||
|
return errors.New("存储读取失败")
|
||||||
|
}
|
||||||
|
b, e := io.ReadAll(io.LimitReader(object, 128))
|
||||||
|
_ = object.Close()
|
||||||
|
cleanErr := cleanup()
|
||||||
|
if e != nil || !bytes.Equal(b, payload) {
|
||||||
|
return errors.New("存储读取校验失败")
|
||||||
|
}
|
||||||
|
if cleanErr != nil {
|
||||||
|
return errors.New("存储清理失败;测试对象保留于应用 tests 前缀")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (o *Operations) LocalWritable() error {
|
||||||
|
dir := filepath.Join(o.cfg.DataDir, "uploads")
|
||||||
|
f, e := os.CreateTemp(dir, ".probe-")
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("本地上传目录不可写")
|
||||||
|
}
|
||||||
|
name := f.Name()
|
||||||
|
_, e = f.Write([]byte("probe"))
|
||||||
|
_ = f.Close()
|
||||||
|
removeErr := os.Remove(name)
|
||||||
|
if e != nil || removeErr != nil {
|
||||||
|
return errors.New("本地存储读写或清理失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads are validated locally first, then moved to the selected target. No fallback.
|
||||||
|
func (o *Operations) StoreFile(path, mimeType string, public bool) (string, error) {
|
||||||
|
v, version, e := o.read(o.db, "storage")
|
||||||
|
if e != nil {
|
||||||
|
return "", errors.New("读取存储配置失败")
|
||||||
|
}
|
||||||
|
c := *v.(*StorageConfig)
|
||||||
|
if c.Backend == "local" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
client, e := o.s3(c)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
f, e := os.Open(path)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
stat, e := f.Stat()
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
id := randomID()
|
||||||
|
key := c.Prefix + "objects/" + id
|
||||||
|
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
|
||||||
|
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
|
||||||
|
}
|
||||||
|
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
|
||||||
|
if e = o.db.Create(&obj).Error; e != nil {
|
||||||
|
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||||
|
return "", errors.New("保存文件记录失败")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
||||||
|
var obj model.StoredObject
|
||||||
|
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||||
|
return nil, "", errors.New("文件不存在")
|
||||||
|
}
|
||||||
|
if requirePublic && !obj.Public {
|
||||||
|
return nil, "", errors.New("文件不存在")
|
||||||
|
}
|
||||||
|
var row model.ModuleConfig
|
||||||
|
if e := o.db.First(&row, "name = ?", obj.ConfigName).Error; e != nil {
|
||||||
|
return nil, "", errors.New("历史存储配置不可用")
|
||||||
|
}
|
||||||
|
var c StorageConfig
|
||||||
|
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
|
||||||
|
return nil, "", errors.New("历史存储配置无效")
|
||||||
|
}
|
||||||
|
client, e := o.s3(c)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
object, e := client.GetObject(ctx, c.Bucket, obj.Key, minio.GetObjectOptions{})
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", errors.New("读取存储失败")
|
||||||
|
}
|
||||||
|
if _, e = object.Stat(); e != nil {
|
||||||
|
_ = object.Close()
|
||||||
|
return nil, "", errors.New("文件暂不可用")
|
||||||
|
}
|
||||||
|
return object, obj.MIME, nil
|
||||||
|
}
|
||||||
|
func (o *Operations) StorageReferences() ([]map[string]any, error) {
|
||||||
|
type row struct {
|
||||||
|
ConfigName string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
e := o.db.Model(&model.StoredObject{}).Select("config_name, count(*) as count").Group("config_name").Scan(&rows).Error
|
||||||
|
out := []map[string]any{}
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, map[string]any{"revision": r.ConfigName, "references": r.Count})
|
||||||
|
}
|
||||||
|
return out, e
|
||||||
|
}
|
||||||
|
func RemoteObjectID(url string) string { return strings.TrimPrefix(url, "/api/media/") }
|
||||||
|
|
||||||
|
type boundedS3Transport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
host, scheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t boundedS3Transport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
if !strings.EqualFold(r.URL.Host, t.host) || r.URL.Scheme != t.scheme {
|
||||||
|
return nil, errors.New("拒绝存储重定向到未配置目标")
|
||||||
|
}
|
||||||
|
return t.base.RoundTrip(r)
|
||||||
|
}
|
||||||
|
func (o *Operations) PublicObjectLocation(id string) (string, error) {
|
||||||
|
var object model.StoredObject
|
||||||
|
if e := o.db.First(&object, "id = ? AND public = true", id).Error; e != nil {
|
||||||
|
return "", errors.New("文件不存在")
|
||||||
|
}
|
||||||
|
var row model.ModuleConfig
|
||||||
|
if e := o.db.First(&row, "name = ?", object.ConfigName).Error; e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
var c StorageConfig
|
||||||
|
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
if c.CDN == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.TrimRight(c.CDN, "/") + "/" + object.Key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveObject revokes public access before best-effort remote cleanup.
|
||||||
|
// Failed cleanup retains the historic configuration reference for manual retry.
|
||||||
|
func (o *Operations) RemoveObject(id string) error {
|
||||||
|
var obj model.StoredObject
|
||||||
|
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := o.db.Model(&obj).Update("public", false).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
var row model.ModuleConfig
|
||||||
|
if e := o.db.First(&row, "name = ?", obj.ConfigName).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
var c StorageConfig
|
||||||
|
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
client, e := o.s3(c)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if e = client.RemoveObject(ctx, c.Bucket, obj.Key, minio.RemoveObjectOptions{}); e != nil {
|
||||||
|
return errors.New("远程文件清理失败,已撤销公开访问")
|
||||||
|
}
|
||||||
|
return o.db.Delete(&obj).Error
|
||||||
|
}
|
||||||
213
backend/service/operations_temporary.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func temporaryLockKey(path string) int64 {
|
||||||
|
key := filepath.ToSlash(filepath.Clean(path))
|
||||||
|
for _, root := range []string{"/uploads/", "/private/"} {
|
||||||
|
if i := strings.LastIndex(key, root); i >= 0 {
|
||||||
|
key = key[i+1:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := sha256.Sum256([]byte("temporary:" + key))
|
||||||
|
return int64(binary.BigEndian.Uint64(s[:8]))
|
||||||
|
}
|
||||||
|
func (o *Operations) lockTemporary(path string, try bool) (*sql.Conn, bool, error) {
|
||||||
|
db, e := o.db.DB()
|
||||||
|
if e != nil {
|
||||||
|
return nil, false, e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c, e := db.Conn(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return nil, false, e
|
||||||
|
}
|
||||||
|
if try {
|
||||||
|
var ok bool
|
||||||
|
e = c.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", temporaryLockKey(path)).Scan(&ok)
|
||||||
|
if e != nil || !ok {
|
||||||
|
c.Close()
|
||||||
|
return nil, false, e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, e = c.ExecContext(ctx, "SELECT pg_advisory_lock($1)", temporaryLockKey(path)); e != nil {
|
||||||
|
c.Close()
|
||||||
|
return nil, false, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, true, nil
|
||||||
|
}
|
||||||
|
func unlockTemporary(c *sql.Conn, path string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, e := c.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", temporaryLockKey(path))
|
||||||
|
if e != nil {
|
||||||
|
_ = c.Raw(func(any) error { return driver.ErrBadConn })
|
||||||
|
}
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The database session lock lasts for the complete upload, including a stalled writer.
|
||||||
|
// A crashed process releases it automatically; cleaners can never unlink an active upload.
|
||||||
|
func (o *Operations) BeginTemporary(path string) (func(), error) {
|
||||||
|
rel, e := filepath.Rel(o.cfg.DataDir, path)
|
||||||
|
if e != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||||
|
return nil, errors.New("临时文件目录无效")
|
||||||
|
}
|
||||||
|
c, _, e := o.lockTemporary(path, false)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
row := model.TemporaryUpload{ID: counterKey(filepath.ToSlash(rel)), RelativePath: filepath.ToSlash(rel)}
|
||||||
|
if e = o.db.Create(&row).Error; e != nil {
|
||||||
|
unlockTemporary(c, path)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
if _, e := os.Lstat(path); os.IsNotExist(e) {
|
||||||
|
o.db.Delete(&model.TemporaryUpload{}, "id = ?", row.ID)
|
||||||
|
}
|
||||||
|
unlockTemporary(c, path)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
func (o *Operations) temporaryPath(row model.TemporaryUpload) (string, error) {
|
||||||
|
rel := filepath.FromSlash(row.RelativePath)
|
||||||
|
if filepath.IsAbs(rel) || strings.HasPrefix(filepath.Clean(rel), "..") || !strings.HasSuffix(rel, ".partial") {
|
||||||
|
return "", errors.New("临时路径不在允许范围")
|
||||||
|
}
|
||||||
|
path := filepath.Join(o.cfg.DataDir, rel)
|
||||||
|
parent := filepath.ToSlash(filepath.Dir(rel))
|
||||||
|
if parent != "uploads/images" && parent != "uploads/backgrounds" && parent != "private/files" {
|
||||||
|
return "", errors.New("目录不在允许范围")
|
||||||
|
}
|
||||||
|
root, e := filepath.EvalSymlinks(o.cfg.DataDir)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
actual, e := filepath.EvalSymlinks(path)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
within, e := filepath.Rel(root, actual)
|
||||||
|
if e != nil || strings.HasPrefix(within, "..") || filepath.IsAbs(within) {
|
||||||
|
return "", errors.New("拒绝目录外文件")
|
||||||
|
}
|
||||||
|
info, e := os.Lstat(path)
|
||||||
|
if e != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return "", errors.New("拒绝非普通文件")
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemporaryCandidate struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Operations) temporaryCandidates() ([]TemporaryCandidate, error) {
|
||||||
|
m, e := o.Maintenance()
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
|
||||||
|
var rows []model.TemporaryUpload
|
||||||
|
if e = o.db.Where("created_at < ?", cutoff).Order("created_at").Limit(100).Find(&rows).Error; e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
out := []TemporaryCandidate{}
|
||||||
|
for _, row := range rows {
|
||||||
|
path, e := o.temporaryPath(row)
|
||||||
|
if e != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, ok, e := o.lockTemporary(path, true)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, e := os.Stat(path)
|
||||||
|
if e == nil && info.ModTime().Before(cutoff) {
|
||||||
|
out = append(out, TemporaryCandidate{row.ID, info.Size(), info.ModTime()})
|
||||||
|
}
|
||||||
|
unlockTemporary(c, path)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (o *Operations) CleanTemporary(ids []string) (map[string]any, error) {
|
||||||
|
if len(ids) == 0 || len(ids) > 100 {
|
||||||
|
return nil, errors.New("每次请选择扫描出的 1–100 个临时文件")
|
||||||
|
}
|
||||||
|
m, e := o.Maintenance()
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
|
||||||
|
removed, skipped, failed := 0, 0, 0
|
||||||
|
for _, id := range ids {
|
||||||
|
var row model.TemporaryUpload
|
||||||
|
if o.db.First(&row, "id = ? AND created_at < ?", id, cutoff).Error != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, e := o.temporaryPath(row)
|
||||||
|
if e != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, ok, e := o.lockTemporary(path, true)
|
||||||
|
if e != nil {
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, e := os.Stat(path)
|
||||||
|
if e != nil || !info.ModTime().Before(cutoff) {
|
||||||
|
unlockTemporary(c, path)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Raw Markdown references are checked again while holding the upload lock.
|
||||||
|
referenced := false
|
||||||
|
for _, table := range []string{"posts", "comments"} {
|
||||||
|
var n int64
|
||||||
|
e = o.db.Table(table).Where("content LIKE ?", "%"+filepath.Base(path)+"%").Count(&n).Error
|
||||||
|
if e != nil || n > 0 {
|
||||||
|
referenced = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if referenced {
|
||||||
|
unlockTemporary(c, path)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e = os.Remove(path); e != nil {
|
||||||
|
failed++
|
||||||
|
} else {
|
||||||
|
o.db.Delete(&model.TemporaryUpload{}, "id = ?", id)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
unlockTemporary(c, path)
|
||||||
|
}
|
||||||
|
return map[string]any{"message": "临时文件清理完成", "removed": removed, "skipped": skipped, "failed": failed}, nil
|
||||||
|
}
|
||||||
625
backend/service/operations_test.go
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/freefire/jiang13-bbs/config"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testKey(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("SETTINGS_MASTER_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||||
|
}
|
||||||
|
func testOps(t *testing.T) *Operations {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("OPS_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set OPS_TEST_DATABASE_URL to an isolated PostgreSQL database")
|
||||||
|
}
|
||||||
|
u, e := url.Parse(dsn)
|
||||||
|
if e != nil || !strings.HasPrefix(strings.TrimPrefix(u.Path, "/"), "ops_test") {
|
||||||
|
t.Fatal("test database name must begin ops_test")
|
||||||
|
}
|
||||||
|
db, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
schema := "ops_test_" + fmt.Sprint(time.Now().UnixNano())
|
||||||
|
if e = db.Exec("CREATE SCHEMA " + schema).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("search_path", schema)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
scoped, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sql, _ := scoped.DB()
|
||||||
|
_ = sql.Close()
|
||||||
|
db.Exec("DROP SCHEMA " + schema + " CASCADE")
|
||||||
|
sql, _ = db.DB()
|
||||||
|
_ = sql.Close()
|
||||||
|
})
|
||||||
|
if e = scoped.AutoMigrate(&model.TemporaryUpload{}, &model.ModuleConfig{}, &model.SiteSetting{}, &model.SettingsAudit{}, &model.ActionCounter{}, &model.MailTask{}, &model.EmailChallenge{}, &model.StoredObject{}, &model.User{}, &model.PostAttachment{}); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
_ = os.MkdirAll(filepath.Join(dir, "uploads"), 0700)
|
||||||
|
_ = os.MkdirAll(filepath.Join(dir, "private"), 0700)
|
||||||
|
return NewOperations(scoped, &config.Config{DataDir: dir, DevMode: true, SiteURL: "https://forum.example.com"})
|
||||||
|
}
|
||||||
|
func raw(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
|
||||||
|
func TestOperationalSecrets(t *testing.T) {
|
||||||
|
testKey(t)
|
||||||
|
o := &Operations{}
|
||||||
|
enc, e := o.seal("do-not-leak", "mail:password")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
enc2, _ := o.seal("do-not-leak", "mail:password")
|
||||||
|
if enc == enc2 || strings.Contains(enc, "do-not-leak") {
|
||||||
|
t.Fatal("nonce or encryption failure")
|
||||||
|
}
|
||||||
|
p, e := o.open(enc, "mail:password")
|
||||||
|
if e != nil || p != "do-not-leak" {
|
||||||
|
t.Fatal("roundtrip")
|
||||||
|
}
|
||||||
|
if _, e = o.open(enc, "storage:secret_key"); e == nil {
|
||||||
|
t.Fatal("domain substitution accepted")
|
||||||
|
}
|
||||||
|
t.Setenv("SETTINGS_MASTER_KEY", "")
|
||||||
|
if _, e = o.open(enc, "mail:password"); e == nil {
|
||||||
|
t.Fatal("missing key accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestFilterNormalizationExceptionsAndMarkdown(t *testing.T) {
|
||||||
|
c := FilterConfig{Enabled: true, Rules: []FilterRule{{ID: "a", Word: "bad", Scopes: []string{"body"}, Action: "block", Enabled: true, Exceptions: []string{"badminton"}}, {ID: "b", Word: "hello", Scopes: []string{"body"}, Action: "log", Enabled: true}}}
|
||||||
|
r := MatchFilter(c, "body", "HELLO badminton and BAD **bad** [label](https://bad.example)\n\n\u0060bad\u0060\n\n\u0060\u0060\u0060go\nbad\n\u0060\u0060\u0060")
|
||||||
|
if r.Result != "block" {
|
||||||
|
t.Fatal(r)
|
||||||
|
}
|
||||||
|
blocks, excepted := 0, 0
|
||||||
|
for _, h := range r.Hits {
|
||||||
|
if h.Action == "block" {
|
||||||
|
if h.Excepted {
|
||||||
|
excepted++
|
||||||
|
} else {
|
||||||
|
blocks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blocks != 2 || excepted != 1 {
|
||||||
|
t.Fatalf("blocks=%d excepted=%d text=%q", blocks, excepted, r.Text)
|
||||||
|
}
|
||||||
|
if MatchFilter(c, "title", "bad").Result != "pass" {
|
||||||
|
t.Fatal("scope leak")
|
||||||
|
}
|
||||||
|
if MatchFilter(c, "body", "badminton").Result != "pass" {
|
||||||
|
t.Fatal("exception not applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRateLimiterUsesRequestCategory(t *testing.T) {
|
||||||
|
r := NewRateLimiter()
|
||||||
|
r.SetLimit(RateLogin, 1)
|
||||||
|
if !r.Allow("login:127.0.0.1") || r.Allow("login:127.0.0.1") {
|
||||||
|
t.Fatal("per-IP key bypassed limit")
|
||||||
|
}
|
||||||
|
if !r.Allow("login:127.0.0.2") {
|
||||||
|
t.Fatal("different IP denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestPrivateServiceTargetDenied(t *testing.T) {
|
||||||
|
t.Setenv("SERVICE_PRIVATE_HOSTS", "")
|
||||||
|
c, e := safeDial(context.Background(), "tcp", "127.0.0.1:25")
|
||||||
|
if c != nil {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
if e == nil || !strings.Contains(e.Error(), "受限") {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestModuleConflictAndAtomicDependency(t *testing.T) {
|
||||||
|
testKey(t)
|
||||||
|
o := testOps(t)
|
||||||
|
cfg := defaultModule("security").(*SecurityConfig)
|
||||||
|
cfg.CommentInterval = 15
|
||||||
|
var wins atomic.Int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 8 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
e := o.Save("security", 0, raw(cfg), nil, 1)
|
||||||
|
if e == nil {
|
||||||
|
wins.Add(1)
|
||||||
|
} else if !errors.Is(e, ErrConfigConflict) {
|
||||||
|
t.Errorf("unexpected: %v", e)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if wins.Load() != 1 {
|
||||||
|
t.Fatalf("concurrent winners: %d", wins.Load())
|
||||||
|
}
|
||||||
|
reopened := NewOperations(o.db, o.cfg)
|
||||||
|
s, e := reopened.Security()
|
||||||
|
if e != nil || s.CommentInterval != 15 {
|
||||||
|
t.Fatal("restart persistence", e)
|
||||||
|
}
|
||||||
|
cfg.VerifyEmail = true
|
||||||
|
cfg.AllowRegister = false
|
||||||
|
if e = o.Save("security", 1, raw(cfg), nil, 1); e == nil {
|
||||||
|
t.Fatal("dependency accepted")
|
||||||
|
}
|
||||||
|
s, _ = o.Security()
|
||||||
|
if !s.AllowRegister || s.VerifyEmail {
|
||||||
|
t.Fatal("failed save changed active config")
|
||||||
|
}
|
||||||
|
m := defaultModule("mail").(*MailConfig)
|
||||||
|
m.Enabled = true
|
||||||
|
m.Host = "smtp.example.com"
|
||||||
|
m.Username = "user"
|
||||||
|
m.Password = "secret-value"
|
||||||
|
m.From = "sender@example.com"
|
||||||
|
if e = o.Save("mail", 0, raw(m), nil, 1); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
got, e := o.Read("mail")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(got)
|
||||||
|
if strings.Contains(string(b), "secret-value") || strings.Contains(string(b), "mail:password") {
|
||||||
|
t.Fatal("secret exposed")
|
||||||
|
}
|
||||||
|
cfg.AllowRegister = true
|
||||||
|
if e = o.Save("security", 1, raw(cfg), nil, 1); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e = o.Save("mail", 1, raw(map[string]any{"enabled": false}), nil, 1); e == nil {
|
||||||
|
t.Fatal("mail dependency bypass")
|
||||||
|
}
|
||||||
|
if e = o.Save("mail", 1, raw(map[string]any{}), []string{"password"}, 1); e == nil {
|
||||||
|
t.Fatal("dependent credential cleared")
|
||||||
|
}
|
||||||
|
if e = o.Save("mail", 1, raw(map[string]any{"password": ""}), nil, 1); e != nil {
|
||||||
|
t.Fatal("blank failed to retain secret", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestSharedQuotaAndFiniteLoginRecovery(t *testing.T) {
|
||||||
|
o := testOps(t)
|
||||||
|
var wins atomic.Int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 20 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
wait, e := o.Quota("same", 5, 60)
|
||||||
|
if e != nil {
|
||||||
|
t.Error(e)
|
||||||
|
} else if wait == 0 {
|
||||||
|
wins.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if wins.Load() != 5 {
|
||||||
|
t.Fatal(wins.Load())
|
||||||
|
}
|
||||||
|
cfg := *defaultModule("security").(*SecurityConfig)
|
||||||
|
for range cfg.LoginFailures {
|
||||||
|
o.RecordFailure("Victim", "ip", cfg)
|
||||||
|
}
|
||||||
|
wait, e := o.FailureWait("victim", "other", cfg)
|
||||||
|
if e != nil || wait <= 0 || wait > cfg.LoginWindow+1 {
|
||||||
|
t.Fatal(wait, e)
|
||||||
|
}
|
||||||
|
o.db.Model(&model.ActionCounter{}).Where("key = ?", counterKey("failure:account:victim")).Update("expires_at", time.Now().Add(-time.Second))
|
||||||
|
if wait, e = o.FailureWait("victim", "other", cfg); e != nil || wait != 0 {
|
||||||
|
t.Fatal("account did not recover")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func smtpFixture(t *testing.T, authFail, stall bool) (string, int, *x509.CertPool, *atomic.Int32) {
|
||||||
|
t.Helper()
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||||
|
der, e := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||||
|
cert, _ := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AppendCertsFromPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||||
|
ln, e := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
var accepted atomic.Int32
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, e := ln.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(4 * time.Second))
|
||||||
|
if stall {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(conn, "220 test SMTP\r\n")
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
for {
|
||||||
|
line, e := r.ReadString('\n')
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "EHLO"):
|
||||||
|
io.WriteString(conn, "250-test\r\n250 AUTH PLAIN\r\n")
|
||||||
|
case strings.HasPrefix(line, "AUTH"):
|
||||||
|
if authFail {
|
||||||
|
io.WriteString(conn, "535 rejected\r\n")
|
||||||
|
} else {
|
||||||
|
io.WriteString(conn, "235 accepted\r\n")
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "DATA"):
|
||||||
|
io.WriteString(conn, "354 go\r\n")
|
||||||
|
for {
|
||||||
|
l, e := r.ReadString('\n')
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l == ".\r\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accepted.Add(1)
|
||||||
|
io.WriteString(conn, "250 queued\r\n")
|
||||||
|
case strings.HasPrefix(line, "QUIT"):
|
||||||
|
io.WriteString(conn, "221 bye\r\n")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
io.WriteString(conn, "250 ok\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
host, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||||
|
n := 0
|
||||||
|
fmt.Sscan(port, &n)
|
||||||
|
return host, n, pool, &accepted
|
||||||
|
}
|
||||||
|
func TestSMTPConnectionAcceptAuthAndTimeout(t *testing.T) {
|
||||||
|
testKey(t)
|
||||||
|
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||||
|
for _, mode := range []string{"success", "auth", "timeout", "certificate"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
host, port, roots, accepted := smtpFixture(t, mode == "auth", mode == "timeout")
|
||||||
|
o := &Operations{smtpRoots: roots}
|
||||||
|
if mode == "certificate" {
|
||||||
|
o.smtpRoots = nil
|
||||||
|
}
|
||||||
|
secret, _ := o.seal("smtp-test-secret", "mail:password")
|
||||||
|
c := MailConfig{Host: host, Port: port, TLS: "tls", Username: "test", Password: secret, From: "test@example.com", FromName: "Test", Timeout: 2}
|
||||||
|
e := o.smtp(context.Background(), c, &mailPayload{To: "admin@example.com", Subject: "Test", Body: "<p>test</p>"}, "test")
|
||||||
|
if mode == "success" {
|
||||||
|
if e != nil || accepted.Load() != 1 {
|
||||||
|
t.Fatal(e, accepted.Load())
|
||||||
|
}
|
||||||
|
} else if e == nil {
|
||||||
|
t.Fatal("failure expected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestStorageProbeAndHistoricPrivateObject(t *testing.T) {
|
||||||
|
testKey(t)
|
||||||
|
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||||
|
o := testOps(t)
|
||||||
|
var mu sync.Mutex
|
||||||
|
objects := map[string][]byte{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if r.Header.Get("Authorization") == "" {
|
||||||
|
w.WriteHeader(403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case "PUT":
|
||||||
|
var body io.Reader = r.Body
|
||||||
|
if strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") || strings.HasPrefix(r.Header.Get("X-Amz-Content-Sha256"), "STREAMING-") {
|
||||||
|
body = httputil.NewChunkedReader(r.Body)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(body)
|
||||||
|
objects[r.URL.Path] = b
|
||||||
|
w.Header().Set("ETag", `"test"`)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
case "GET", "HEAD":
|
||||||
|
b, ok := objects[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprint(len(b)))
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("ETag", `"test"`)
|
||||||
|
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||||
|
if r.Method == "GET" {
|
||||||
|
w.Write(b)
|
||||||
|
}
|
||||||
|
case "DELETE":
|
||||||
|
delete(objects, r.URL.Path)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(400)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := defaultModule("storage").(*StorageConfig)
|
||||||
|
c.Backend = "s3"
|
||||||
|
c.Endpoint = srv.URL
|
||||||
|
c.Bucket = "test-bucket"
|
||||||
|
c.AccessKey = "test-access"
|
||||||
|
c.SecretKey = "test-secret"
|
||||||
|
if e := o.TestStorage(context.Background(), raw(c), nil); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if len(objects) != 0 {
|
||||||
|
t.Fatal("probe leaked object")
|
||||||
|
}
|
||||||
|
if e := o.Save("storage", 0, raw(c), nil, 1); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "file.txt")
|
||||||
|
os.WriteFile(path, []byte("historic"), 0600)
|
||||||
|
id, e := o.StoreFile(path, "text/plain", false)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
c.Backend = "local"
|
||||||
|
c.AccessKey = ""
|
||||||
|
c.SecretKey = ""
|
||||||
|
if e = o.Save("storage", 1, raw(c), nil, 1); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, _, e = o.OpenObject(context.Background(), id, true); e == nil {
|
||||||
|
t.Fatal("private object publicly accessible")
|
||||||
|
}
|
||||||
|
r, _, e := o.OpenObject(context.Background(), id, false)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
b, _ := io.ReadAll(r)
|
||||||
|
if string(b) != "historic" {
|
||||||
|
t.Fatalf("%q", b)
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
if e = o.RemoveObject(id); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, _, e = o.OpenObject(context.Background(), id, false); e == nil {
|
||||||
|
t.Fatal("deleted object accessible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemporaryCleanupProtectsActiveReferencedAndDraftFiles(t *testing.T) {
|
||||||
|
o := testOps(t)
|
||||||
|
if e := o.db.Exec("CREATE TABLE posts (content text); CREATE TABLE comments (content text)").Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(o.cfg.DataDir, "uploads", "images")
|
||||||
|
_ = os.MkdirAll(dir, 0700)
|
||||||
|
path := filepath.Join(dir, "managed.png.partial")
|
||||||
|
release, e := o.BeginTemporary(path)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(path, []byte("temporary"), 0600)
|
||||||
|
old := time.Now().Add(-8 * 24 * time.Hour)
|
||||||
|
_ = os.Chtimes(path, old, old)
|
||||||
|
o.db.Model(&model.TemporaryUpload{}).Where("id = ?", counterKey("uploads/images/managed.png.partial")).Update("created_at", old)
|
||||||
|
candidates, e := o.temporaryCandidates()
|
||||||
|
if e != nil || len(candidates) != 0 {
|
||||||
|
t.Fatal("active upload selected", e)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
candidates, e = o.temporaryCandidates()
|
||||||
|
if e != nil || len(candidates) != 1 {
|
||||||
|
t.Fatal("expired inactive upload not selected", e)
|
||||||
|
}
|
||||||
|
_ = o.db.Exec("INSERT INTO posts(content) VALUES (?)", "referenced "+filepath.Base(path)).Error
|
||||||
|
r, e := o.CleanTemporary([]string{candidates[0].ID})
|
||||||
|
if e != nil || r["removed"] != 0 {
|
||||||
|
t.Fatal("reference deleted", r, e)
|
||||||
|
}
|
||||||
|
o.db.Exec("DELETE FROM posts")
|
||||||
|
draft := filepath.Join(dir, "draft.png")
|
||||||
|
_ = os.WriteFile(draft, []byte("draft"), 0600)
|
||||||
|
_ = os.Chtimes(draft, old, old)
|
||||||
|
unknown := filepath.Join(dir, "legacy.png.partial")
|
||||||
|
_ = os.WriteFile(unknown, []byte("unknown"), 0600)
|
||||||
|
_ = os.Chtimes(unknown, old, old)
|
||||||
|
r, e = o.CleanTemporary([]string{candidates[0].ID})
|
||||||
|
if e != nil || r["removed"] != 1 {
|
||||||
|
t.Fatal(r, e)
|
||||||
|
}
|
||||||
|
for _, p := range []string{draft, unknown} {
|
||||||
|
if _, e = os.Stat(p); e != nil {
|
||||||
|
t.Fatal("protected file removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, e = o.CleanTemporary([]string{candidates[0].ID})
|
||||||
|
if e != nil || r["removed"] != 0 {
|
||||||
|
t.Fatal("duplicate cleanup", r, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestDurableMailRetriesAndCodeSingleUse(t *testing.T) {
|
||||||
|
testKey(t)
|
||||||
|
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||||
|
o := testOps(t)
|
||||||
|
host, port, roots, _ := smtpFixture(t, true, false)
|
||||||
|
o.smtpRoots = roots
|
||||||
|
c := defaultModule("mail").(*MailConfig)
|
||||||
|
c.Enabled = true
|
||||||
|
c.Host = host
|
||||||
|
c.Port = port
|
||||||
|
c.From = "test@example.com"
|
||||||
|
c.FromName = "Test"
|
||||||
|
c.Username = "test"
|
||||||
|
c.Password = "test"
|
||||||
|
if e := o.Save("mail", 0, raw(c), nil, 1); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
for range 3 {
|
||||||
|
if e := o.ProcessMail(context.Background()); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
o.db.Model(&model.MailTask{}).Where("dedupe = ?", "same-task").Update("next_at", time.Now().Add(-time.Second))
|
||||||
|
}
|
||||||
|
rows, e := o.MailRows()
|
||||||
|
if e != nil || len(rows) != 1 || rows[0].Status != "failed" || rows[0].Attempts != 3 || rows[0].Payload != "" {
|
||||||
|
t.Fatal(rows, e)
|
||||||
|
}
|
||||||
|
challenge := model.EmailChallenge{Hash: counterKey("user@example.com:register:single-code"), Email: "user@example.com", Purpose: "register", ExpiresAt: time.Now().Add(time.Minute)}
|
||||||
|
if e = o.db.Create(&challenge).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e = o.ConsumeCode("user@example.com", "register", "single-code"); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e = o.ConsumeCode("user@example.com", "register", "single-code"); e == nil {
|
||||||
|
t.Fatal("code replay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailQueueRecoveryAndDestinationQuota(t *testing.T) {
|
||||||
|
testKey(t)
|
||||||
|
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||||
|
o := testOps(t)
|
||||||
|
host, port, roots, accepted := smtpFixture(t, false, false)
|
||||||
|
c := defaultModule("mail").(*MailConfig)
|
||||||
|
c.Enabled = true
|
||||||
|
c.Host = host
|
||||||
|
c.Port = port
|
||||||
|
c.Username = "test"
|
||||||
|
c.Password = "secret"
|
||||||
|
c.From = "test@example.com"
|
||||||
|
c.FromName = "Test"
|
||||||
|
if e := o.Save("mail", 0, raw(c), nil, 1); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
security := defaultModule("security").(*SecurityConfig)
|
||||||
|
security.VerifyEmail = true
|
||||||
|
if e := o.Save("security", 0, raw(security), nil, 1); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if wait, e := o.SendCode("USER@example.com", "register", "192.0.2.1"); e != nil || wait != 0 {
|
||||||
|
t.Fatal(wait, e)
|
||||||
|
}
|
||||||
|
if wait, e := o.SendCode("user@example.com", "register", "192.0.2.2"); e != nil || wait < 1 {
|
||||||
|
t.Fatal("destination quota bypassed", wait, e)
|
||||||
|
}
|
||||||
|
var task model.MailTask
|
||||||
|
if e := o.db.First(&task).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if strings.Contains(task.Payload, "user@example.com") {
|
||||||
|
t.Fatal("unencrypted queue")
|
||||||
|
}
|
||||||
|
o.db.Model(&task).Updates(map[string]any{"status": "sending", "attempts": 1, "next_at": time.Now().Add(-time.Minute)})
|
||||||
|
restarted := NewOperations(o.db, o.cfg)
|
||||||
|
restarted.smtpRoots = roots
|
||||||
|
if e := restarted.ProcessMail(context.Background()); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var finished model.MailTask
|
||||||
|
o.db.First(&finished, task.ID)
|
||||||
|
if finished.Status != "accepted" || finished.Attempts != 2 || finished.Payload != "" || accepted.Load() != 1 {
|
||||||
|
t.Fatal("queue did not recover", finished.Status, finished.Attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailTemplateGuards(t *testing.T) {
|
||||||
|
var cfg MailConfig
|
||||||
|
cfg.applyTemplateDefaults()
|
||||||
|
if cfg.BodyTemplate != defaultMailBody || cfg.SubjectTemplate != defaultMailSubject {
|
||||||
|
t.Fatal("defaults not applied")
|
||||||
|
}
|
||||||
|
cfg.BodyTemplate = legacyMailBody
|
||||||
|
cfg.applyTemplateDefaults()
|
||||||
|
if cfg.BodyTemplate != defaultMailBody {
|
||||||
|
t.Fatal("legacy default not upgraded")
|
||||||
|
}
|
||||||
|
o := &Operations{cfg: &config.Config{SiteURL: "https://forum.example.com", DevMode: true}}
|
||||||
|
if e := o.validate(nil, "mail", &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, BodyTemplate: "<script></script><p>no code</p>"}); e == nil || !strings.Contains(e.Error(), "{{code}}") {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
custom := `<!DOCTYPE html><html><body><style>.x{color:red}</style><script>void 0</script><p class="x">{{code}}</p><a href="{{link}}">go</a></body></html>`
|
||||||
|
p := o.renderMail(MailConfig{BodyTemplate: custom}, "<b>x</b>", "reset")
|
||||||
|
if strings.Contains(p.Body, "<b>x</b>") || !strings.Contains(p.Body, "<b>x</b>") || !strings.Contains(p.Body, "<script>") {
|
||||||
|
t.Fatal(p.Body)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(p.Body), "<style") {
|
||||||
|
t.Fatal("style blocks should be inlined for mail clients", p.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Body, "color:red") && !strings.Contains(p.Body, "color: red") {
|
||||||
|
t.Fatal("class styles should be inlined", p.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Subject, "密码找回") || strings.Contains(p.Subject, "\n") {
|
||||||
|
t.Fatal(p.Subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Body, "https://forum.example.com/reset-password") {
|
||||||
|
t.Fatal(p.Body)
|
||||||
|
}
|
||||||
|
def := o.renderMail(MailConfig{}, "123456", "")
|
||||||
|
if strings.Contains(strings.ToLower(def.Body), "<style") {
|
||||||
|
t.Fatal("default template style should be inlined")
|
||||||
|
}
|
||||||
|
if !strings.Contains(def.Body, "background:#f5f7fb") && !strings.Contains(def.Body, "background: #f5f7fb") {
|
||||||
|
t.Fatal("default wrap style missing after prepare", def.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -694,6 +694,11 @@ func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
|||||||
|
|
||||||
// Create 创建帖子。status 由 handler 按角色计算
|
// Create 创建帖子。status 由 handler 按角色计算
|
||||||
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||||
|
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
|
||||||
|
if err := NewOperations(s.db, nil).Filter(scope, text, in.UserID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
title := strings.TrimSpace(in.Title)
|
title := strings.TrimSpace(in.Title)
|
||||||
content := strings.TrimSpace(in.Content)
|
content := strings.TrimSpace(in.Content)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -856,6 +861,11 @@ func validateTypeMeta(postType, meta string) error {
|
|||||||
|
|
||||||
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
||||||
func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) {
|
func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) {
|
||||||
|
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
|
||||||
|
if err := NewOperations(s.db, nil).Filter(scope, text, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
var post model.Post
|
var post model.Post
|
||||||
if err := s.db.First(&post, postID).Error; err != nil {
|
if err := s.db.First(&post, postID).Error; err != nil {
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ var (
|
|||||||
|
|
||||||
// PostFileService 帖子文件附件(私有目录 + API 下载)
|
// PostFileService 帖子文件附件(私有目录 + API 下载)
|
||||||
type PostFileService struct {
|
type PostFileService struct {
|
||||||
|
ops *Operations
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
dir string // data/private/files
|
dir string // data/private/files
|
||||||
setting *SettingService
|
setting *SettingService
|
||||||
@@ -132,6 +133,13 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
|||||||
stored := hex.EncodeToString(raw) + ext
|
stored := hex.EncodeToString(raw) + ext
|
||||||
full := absPath(s.dir, stored)
|
full := absPath(s.dir, stored)
|
||||||
tmp := full + ".partial"
|
tmp := full + ".partial"
|
||||||
|
if s.ops != nil {
|
||||||
|
release, e := s.ops.BeginTemporary(tmp)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -168,6 +176,17 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
|||||||
Size: int(written),
|
Size: int(written),
|
||||||
PricePoints: pricePoints,
|
PricePoints: pricePoints,
|
||||||
}
|
}
|
||||||
|
if s.ops != nil {
|
||||||
|
id, e := s.ops.StoreFile(full, att.MIME, false)
|
||||||
|
if e != nil {
|
||||||
|
_ = os.Remove(full)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
att.ObjectID = id
|
||||||
|
if id != "" {
|
||||||
|
_ = os.Remove(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := s.db.Create(att).Error; err != nil {
|
if err := s.db.Create(att).Error; err != nil {
|
||||||
_ = os.Remove(full)
|
_ = os.Remove(full)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -268,6 +287,9 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
|||||||
if err := s.db.Delete(&att).Error; err != nil {
|
if err := s.db.Delete(&att).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if s.ops != nil && att.ObjectID != "" {
|
||||||
|
return s.ops.RemoveObject(att.ObjectID)
|
||||||
|
}
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -332,3 +354,5 @@ func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostA
|
|||||||
}).Error
|
}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PostFileService) WithOperations(o *Operations) { s.ops = o }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -33,12 +34,22 @@ func (r *RateLimiter) Allow(key string) bool {
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
limit, ok := r.limits[key]
|
limit, ok := r.limits[strings.SplitN(key, ":", 2)[0]]
|
||||||
if !ok {
|
if !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
if len(r.records) > 10000 {
|
||||||
|
for k, v := range r.records {
|
||||||
|
if len(v) == 0 || now.Sub(v[len(v)-1]) > time.Minute {
|
||||||
|
delete(r.records, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.records) > 20000 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
windowStart := now.Add(-1 * time.Minute)
|
windowStart := now.Add(-1 * time.Minute)
|
||||||
|
|
||||||
// 清理过期记录
|
// 清理过期记录
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const (
|
|||||||
SettingKeyAllowRegister = "allow_register"
|
SettingKeyAllowRegister = "allow_register"
|
||||||
// SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开)
|
// SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开)
|
||||||
SettingKeyAllowComments = "allow_comments"
|
SettingKeyAllowComments = "allow_comments"
|
||||||
|
// SettingKeyCommentsRequireLogin 评论仅登录可见;缺行视为关闭(游客可读)
|
||||||
|
SettingKeyCommentsRequireLogin = "comments_require_login"
|
||||||
// SettingKeyAllowMessages 全站开放私聊/群聊;缺行视为开启(默认开)
|
// SettingKeyAllowMessages 全站开放私聊/群聊;缺行视为开启(默认开)
|
||||||
SettingKeyAllowMessages = "allow_messages"
|
SettingKeyAllowMessages = "allow_messages"
|
||||||
SettingKeyPostCooldownHours = "post_cooldown_hours"
|
SettingKeyPostCooldownHours = "post_cooldown_hours"
|
||||||
@@ -101,6 +103,7 @@ type PublicSiteSettings struct {
|
|||||||
SiteDescription string `json:"site_description"`
|
SiteDescription string `json:"site_description"`
|
||||||
AllowRegister bool `json:"allow_register"`
|
AllowRegister bool `json:"allow_register"`
|
||||||
AllowComments bool `json:"allow_comments"`
|
AllowComments bool `json:"allow_comments"`
|
||||||
|
CommentsRequireLogin bool `json:"comments_require_login"`
|
||||||
AllowMessages bool `json:"allow_messages"`
|
AllowMessages bool `json:"allow_messages"`
|
||||||
PostCooldownHours int `json:"post_cooldown_hours"`
|
PostCooldownHours int `json:"post_cooldown_hours"`
|
||||||
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
||||||
@@ -181,6 +184,26 @@ func (s *SettingService) setBoolDefaultTrue(key string, on bool) error {
|
|||||||
return s.putValue(key, "false")
|
return s.putValue(key, "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 缺行视为关;仅显式 true/1/on/yes 为开。
|
||||||
|
func parseBoolDefaultFalse(v string, found bool) bool {
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(strings.ToLower(v)) {
|
||||||
|
case "true", "1", "on", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) setBoolDefaultFalse(key string, on bool) error {
|
||||||
|
if !on {
|
||||||
|
return s.deleteKey(key)
|
||||||
|
}
|
||||||
|
return s.putValue(key, "true")
|
||||||
|
}
|
||||||
|
|
||||||
// Public 返回解析后的公开站点设置(缺省已填默认值)
|
// Public 返回解析后的公开站点设置(缺省已填默认值)
|
||||||
func (s *SettingService) Public() (PublicSiteSettings, error) {
|
func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||||
out := PublicSiteSettings{
|
out := PublicSiteSettings{
|
||||||
@@ -189,6 +212,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
TrustReviewedPublish: true,
|
TrustReviewedPublish: true,
|
||||||
AllowRegister: true,
|
AllowRegister: true,
|
||||||
AllowComments: true,
|
AllowComments: true,
|
||||||
|
CommentsRequireLogin: false,
|
||||||
AllowMessages: true,
|
AllowMessages: true,
|
||||||
PostCooldownHours: DefaultCooldownHours,
|
PostCooldownHours: DefaultCooldownHours,
|
||||||
CodeBlockAutoFold: true,
|
CodeBlockAutoFold: true,
|
||||||
@@ -254,6 +278,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
}
|
}
|
||||||
out.AllowComments = comments
|
out.AllowComments = comments
|
||||||
|
|
||||||
|
requireLogin, err := s.CommentsRequireLogin()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.CommentsRequireLogin = requireLogin
|
||||||
|
|
||||||
messages, err := s.AllowMessages()
|
messages, err := s.AllowMessages()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -447,6 +477,20 @@ func (s *SettingService) SetAllowComments(on bool) error {
|
|||||||
return s.setBoolDefaultTrue(SettingKeyAllowComments, on)
|
return s.setBoolDefaultTrue(SettingKeyAllowComments, on)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommentsRequireLogin 评论是否仅登录可见。缺行视为关闭。
|
||||||
|
func (s *SettingService) CommentsRequireLogin() (bool, error) {
|
||||||
|
v, found, err := s.getValue(SettingKeyCommentsRequireLogin)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return parseBoolDefaultFalse(v, found), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommentsRequireLogin 持久化「登录可见评论」;关闭时删键保持「缺行=默认关」。
|
||||||
|
func (s *SettingService) SetCommentsRequireLogin(on bool) error {
|
||||||
|
return s.setBoolDefaultFalse(SettingKeyCommentsRequireLogin, on)
|
||||||
|
}
|
||||||
|
|
||||||
// AllowMessages 是否允许全站私聊/群聊。缺行视为开启。
|
// AllowMessages 是否允许全站私聊/群聊。缺行视为开启。
|
||||||
func (s *SettingService) AllowMessages() (bool, error) {
|
func (s *SettingService) AllowMessages() (bool, error) {
|
||||||
v, found, err := s.getValue(SettingKeyAllowMessages)
|
v, found, err := s.getValue(SettingKeyAllowMessages)
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SettingKeyTimelineGitImport 提交导入适配器 JSON(不进公开 settings)
|
|
||||||
const SettingKeyTimelineGitImport = "timeline_git_import"
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxAdapterJSONBytes = 64 * 1024
|
|
||||||
maxAdapterRegexLen = 512
|
maxAdapterRegexLen = 512
|
||||||
maxAdapterSources = 20
|
maxAdapterSources = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// TimelineGitAdapter 声明式提交导入配置
|
// TimelineGitAdapter 声明式提交导入配置(内置,不可在站点设置中编辑)
|
||||||
type TimelineGitAdapter struct {
|
type TimelineGitAdapter struct {
|
||||||
MaxPages int `json:"max_pages"`
|
MaxPages int `json:"max_pages"`
|
||||||
MaxCommits int `json:"max_commits"`
|
MaxCommits int `json:"max_commits"`
|
||||||
@@ -97,29 +93,6 @@ func DefaultTimelineGitAdapterJSON() string {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateTimelineGitAdapterJSON 校验并规范化适配器 JSON
|
|
||||||
func ValidateTimelineGitAdapterJSON(raw string) (string, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return "", errors.New("适配器不能为空")
|
|
||||||
}
|
|
||||||
if len(raw) > maxAdapterJSONBytes {
|
|
||||||
return "", errors.New("适配器过大")
|
|
||||||
}
|
|
||||||
var cfg TimelineGitAdapter
|
|
||||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
||||||
return "", fmt.Errorf("JSON 无效: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateTimelineGitAdapter(&cfg); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
out, err := json.MarshalIndent(cfg, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(out), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
|
func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
|
||||||
if cfg.MaxPages < 1 || cfg.MaxPages > 50 {
|
if cfg.MaxPages < 1 || cfg.MaxPages > 50 {
|
||||||
return errors.New("max_pages 须在 1–50")
|
return errors.New("max_pages 须在 1–50")
|
||||||
@@ -177,54 +150,14 @@ func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TimelineGitAdapterJSON 读取站点适配器;缺行返回默认
|
|
||||||
func (s *SettingService) TimelineGitAdapterJSON() (string, error) {
|
|
||||||
v, found, err := s.getValue(SettingKeyTimelineGitImport)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !found || strings.TrimSpace(v) == "" {
|
|
||||||
return DefaultTimelineGitAdapterJSON(), nil
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTimelineGitAdapterJSON 保存适配器(已校验)
|
|
||||||
func (s *SettingService) SetTimelineGitAdapterJSON(raw string) (string, error) {
|
|
||||||
normalized, err := ValidateTimelineGitAdapterJSON(raw)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetTimelineGitAdapter 恢复默认
|
|
||||||
func (s *SettingService) ResetTimelineGitAdapter() (string, error) {
|
|
||||||
def := DefaultTimelineGitAdapterJSON()
|
|
||||||
normalized, err := ValidateTimelineGitAdapterJSON(def)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) loadTimelineGitAdapter() (*TimelineGitAdapter, error) {
|
func (s *SettingService) loadTimelineGitAdapter() (*TimelineGitAdapter, error) {
|
||||||
raw, err := s.TimelineGitAdapterJSON()
|
raw := DefaultTimelineGitAdapterJSON()
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var cfg TimelineGitAdapter
|
var cfg TimelineGitAdapter
|
||||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||||
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
|
return nil, errors.New("内置提交导入规则无效")
|
||||||
}
|
}
|
||||||
if err := validateTimelineGitAdapter(&cfg); err != nil {
|
if err := validateTimelineGitAdapter(&cfg); err != nil {
|
||||||
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
|
return nil, errors.New("内置提交导入规则无效")
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"golang.org/x/image/webp"
|
"golang.org/x/image/webp"
|
||||||
@@ -37,6 +39,7 @@ const (
|
|||||||
|
|
||||||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||||||
type UploadService struct {
|
type UploadService struct {
|
||||||
|
ops *Operations
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
dir string // 上传根目录(如 data/uploads)
|
dir string // 上传根目录(如 data/uploads)
|
||||||
setting *SettingService
|
setting *SettingService
|
||||||
@@ -122,6 +125,17 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
|||||||
Height: cfg.Height,
|
Height: cfg.Height,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.ops != nil {
|
||||||
|
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||||
|
if e != nil {
|
||||||
|
_ = os.Remove(fullPath)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
att.URL = "/api/media/" + id
|
||||||
|
_ = os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
// 附件记录与用户头像更新在同一事务内完成
|
// 附件记录与用户头像更新在同一事务内完成
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(att).Error; err != nil {
|
if err := tx.Create(att).Error; err != nil {
|
||||||
@@ -204,6 +218,13 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
|||||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||||
fullPath := filepath.Join(s.dir, "images", filename)
|
fullPath := filepath.Join(s.dir, "images", filename)
|
||||||
tmp := fullPath + ".partial"
|
tmp := fullPath + ".partial"
|
||||||
|
if s.ops != nil {
|
||||||
|
release, e := s.ops.BeginTemporary(tmp)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -270,6 +291,17 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
|||||||
Width: w,
|
Width: w,
|
||||||
Height: h,
|
Height: h,
|
||||||
}
|
}
|
||||||
|
if s.ops != nil {
|
||||||
|
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||||
|
if e != nil {
|
||||||
|
_ = os.Remove(fullPath)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
att.URL = "/api/media/" + id
|
||||||
|
_ = os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := s.db.Create(att).Error; err != nil {
|
if err := s.db.Create(att).Error; err != nil {
|
||||||
_ = os.Remove(fullPath)
|
_ = os.Remove(fullPath)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -310,6 +342,13 @@ func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
|
|||||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||||
fullPath := filepath.Join(s.dir, "backgrounds", filename)
|
fullPath := filepath.Join(s.dir, "backgrounds", filename)
|
||||||
tmp := fullPath + ".partial"
|
tmp := fullPath + ".partial"
|
||||||
|
if s.ops != nil {
|
||||||
|
release, e := s.ops.BeginTemporary(tmp)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -370,6 +409,16 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
|
|||||||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||||||
return "", errors.New("图片不存在或不属于你")
|
return "", errors.New("图片不存在或不属于你")
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return s.SaveBackground(r)
|
||||||
|
}
|
||||||
abs, ok := s.safeUploadPath(att.URL)
|
abs, ok := s.safeUploadPath(att.URL)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", errors.New("无效的图片地址")
|
return "", errors.New("无效的图片地址")
|
||||||
@@ -411,7 +460,7 @@ func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL strin
|
|||||||
// UseAvatar 选用一张【本人历史上传】的头像
|
// UseAvatar 选用一张【本人历史上传】的头像
|
||||||
func (s *UploadService) UseAvatar(userID uint, url string) error {
|
func (s *UploadService) UseAvatar(userID uint, url string) error {
|
||||||
url = strings.TrimSpace(url)
|
url = strings.TrimSpace(url)
|
||||||
if !strings.HasPrefix(url, "/uploads/avatars/") || len(url) > 512 {
|
if (!strings.HasPrefix(url, "/uploads/avatars/") && !strings.HasPrefix(url, "/api/media/")) || len(url) > 512 {
|
||||||
return errors.New("无效的头像地址")
|
return errors.New("无效的头像地址")
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
@@ -469,6 +518,12 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
||||||
|
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
||||||
|
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
|
// DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
|
||||||
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
|
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
|
||||||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||||
@@ -476,3 +531,5 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
||||||
|
|||||||
72
backend/tests/operations-api.mjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Run only against an isolated development backend and matching Next.js frontend.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
const base = process.env.OPS_TEST_BASE_URL;
|
||||||
|
if (process.env.OPS_TEST_CONFIRM !== "isolated" || !base || !/^http:\/\/(127\.0\.0\.1|localhost):3301$/.test(base)) throw Error("Requires isolated test backend on port 3301");
|
||||||
|
const jars = { admin: {}, user: {}, anon: {} };
|
||||||
|
async function request(path, method = "GET", body, who = "admin", csrf = true) {
|
||||||
|
const jar = jars[who]; const headers = { "Content-Type": "application/json", Cookie: Object.entries(jar).map(([k,v])=>k+"="+v).join("; ") };
|
||||||
|
if (csrf && jar.j13_csrf) headers["X-CSRF-Token"] = jar.j13_csrf;
|
||||||
|
const r = await fetch(base+path,{method,headers,...(body === undefined ? {} : {body:JSON.stringify(body)})});
|
||||||
|
for (const c of r.headers.getSetCookie()) { const pair=c.split(";")[0];const i=pair.indexOf("=");jar[pair.slice(0,i)]=pair.slice(i+1); }
|
||||||
|
const text=await r.text();let data;try{data=JSON.parse(text)}catch{data=text};return {status:r.status,data,retry:r.headers.get("retry-after")};
|
||||||
|
}
|
||||||
|
const admin = {username:"ops_admin",email:"admin@example.test",password:"Isolated-test-2026"};
|
||||||
|
const user = {username:"ops_member",email:"member@example.test",password:"Isolated-test-2026"};
|
||||||
|
for (const [who,body] of [["admin",admin],["user",user]]) {
|
||||||
|
let r=await request("/api/login","POST",body,who);
|
||||||
|
if (r.status!==200) r=await request("/api/register","POST",body,who);
|
||||||
|
assert.equal(r.status,200,JSON.stringify(r.data));
|
||||||
|
}
|
||||||
|
const priorMode=(await request("/api/admin/settings/modules/maintenance")).data;priorMode.data.mode="normal";assert.equal((await request("/api/admin/settings/modules/maintenance","PUT",priorMode)).status,200);
|
||||||
|
await request("/api/admin/settings","PUT",{post_cooldown_hours:0});
|
||||||
|
const riskAccount="risk-"+Date.now();
|
||||||
|
const modulePath = n => "/api/admin/settings/modules/"+n;
|
||||||
|
const read=async n=>(await request(modulePath(n))).data;
|
||||||
|
const save=async(n,s)=>(await request(modulePath(n),"PUT",{version:s.version,data:s.data}));
|
||||||
|
for (const path of [modulePath("mail"),modulePath("storage"),"/api/admin/diagnostics"]) {
|
||||||
|
assert.equal((await request(path,"GET",undefined,"anon")).status,401);
|
||||||
|
assert.equal((await request(path,"GET",undefined,"user")).status,403);
|
||||||
|
}
|
||||||
|
for (const path of [modulePath("mail")+"/records",modulePath("filter")+"/records"]) assert.equal((await request(path,"GET",undefined,"user")).status,403);
|
||||||
|
assert.equal((await request(modulePath("security"),"PUT",{},"user")).status,403);
|
||||||
|
assert.equal((await request("/api/admin/maintenance/actions","POST",{action:"scan"},"user")).status,403);
|
||||||
|
assert.equal((await request(modulePath("mail")+"/test","POST",{data:{},action:"connection"},"user")).status,403);
|
||||||
|
assert.equal((await request(modulePath("security"),"PUT",{},"admin",false)).status,403);
|
||||||
|
let sec=await read("security");const originalSec=structuredClone(sec.data);
|
||||||
|
sec.data.allow_register=false;assert.equal((await save("security",sec)).status,200);
|
||||||
|
assert.equal((await request("/api/register","POST",{username:"blocked",password:"Blocked-test-2026"},"anon")).status,403);
|
||||||
|
sec=await read("security");sec.data={...sec.data,allow_register:true,comment_interval:15,login_failures:3};
|
||||||
|
const concurrent=await Promise.all([save("security",sec),save("security",sec)]);
|
||||||
|
assert.deepEqual(concurrent.map(x=>x.status).sort(),[200,409]);
|
||||||
|
console.log("PASS permissions, CSRF, registration, concurrent version conflict");
|
||||||
|
for(let i=0;i<3;i++) assert.equal((await request("/api/login","POST",{username:riskAccount,password:"wrong"},"anon")).status,401);
|
||||||
|
let blocked=await request("/api/login","POST",{username:riskAccount,password:"wrong"},"anon");assert.equal(blocked.status,429);assert.ok(Number(blocked.retry)>0);
|
||||||
|
console.log("PASS direct API login protection and Retry-After");
|
||||||
|
let filter=await read("filter");const originalFilter=structuredClone(filter.data);
|
||||||
|
filter.data={enabled:true,rules:[{id:"api-test",word:"blockedphrase",scopes:["body","comment","username"],action:"block",enabled:true,exceptions:["notblockedphrase"],note:"isolated test"}]};
|
||||||
|
assert.equal((await save("filter",filter)).status,200);
|
||||||
|
const board=(await request("/api/boards")).data.boards[0].id;
|
||||||
|
const post=await request("/api/posts","POST",{board_id:board,title:"Operational validation",content:"Safe validation content"});
|
||||||
|
assert.equal(post.status,200,JSON.stringify(post.data));const postID=post.data.post.id;
|
||||||
|
let r=await request("/api/posts/"+postID,"PUT",{title:"Operational validation",content:"blockedphrase",board_id:board});assert.equal(r.status,400);
|
||||||
|
const tested=await request(modulePath("filter")+"/test","POST",{data:filter.data,scope:"body",text:"notblockedphrase and blockedphrase"});
|
||||||
|
assert.equal(tested.data.result,"block");assert.equal(tested.data.hits[0].excepted,true);
|
||||||
|
r=await request("/api/posts/"+postID+"/comments","POST",{content:"Validation comment"},"user");assert.equal(r.status,200,JSON.stringify(r.data));
|
||||||
|
r=await request("/api/posts/"+postID+"/comments","POST",{content:"Second comment"},"user");assert.equal(r.status,429);assert.ok(r.retry);
|
||||||
|
console.log("PASS create/edit filter, scoped exception, comment interval");
|
||||||
|
let m=await read("maintenance");m.data.mode="readonly";assert.equal((await save("maintenance",m)).status,200);
|
||||||
|
assert.equal((await request("/api/posts/"+postID+"/like","POST",{},"user")).status,503);
|
||||||
|
assert.equal((await request("/api/posts","GET",undefined,"anon")).status,200);
|
||||||
|
m=await read("maintenance");m.data.mode="paused";m.data.title="隔离测试维护中";assert.equal((await save("maintenance",m)).status,200);
|
||||||
|
r=await request("/api/posts","GET",undefined,"anon");assert.equal(r.status,503);assert.ok(r.retry);
|
||||||
|
assert.equal((await request(modulePath("maintenance"))).status,200);
|
||||||
|
assert.equal((await request("/api/login","POST",user,"anon")).status,200);
|
||||||
|
const html=await fetch("http://localhost:3000/");assert.equal(html.status,503);assert.ok(html.headers.get("retry-after"));assert.match(await html.text(),/隔离测试维护中/);
|
||||||
|
const robots=await fetch("http://localhost:3000/robots.txt");assert.equal(robots.status,200);
|
||||||
|
m=await read("maintenance");m.data.mode="normal";assert.equal((await save("maintenance",m)).status,200);
|
||||||
|
assert.equal((await fetch("http://localhost:3000/")).status,200);
|
||||||
|
filter=await read("filter");filter.data=originalFilter;assert.equal((await save("filter",filter)).status,200);
|
||||||
|
sec=await read("security");sec.data=originalSec;assert.equal((await save("security",sec)).status,200);
|
||||||
|
console.log("PASS read-only, paused API/page 503, robots, administrator recovery, restoration");
|
||||||
|
console.log("ALL API CHECKS PASSED; disposable browser login: ops_admin / Isolated-test-2026");
|
||||||
|
|
||||||
@@ -24,3 +24,13 @@ POSTGRES_DB=jiang13
|
|||||||
|
|
||||||
# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。
|
# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。
|
||||||
JWT_SECRET=change-me-to-a-long-random-string
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# 正式站点 origin,生产使用 HTTPS;邮件 / Canonical / Sitemap 共用
|
||||||
|
# 32 字节随机值的标准 Base64;不填写时禁止保存邮件/S3凭据。独立备份,禁止提交真实密钥。
|
||||||
|
SETTINGS_MASTER_KEY=
|
||||||
|
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
|
||||||
|
SERVICE_PRIVATE_HOSTS=
|
||||||
|
# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头
|
||||||
|
TRUSTED_PROXIES=
|
||||||
|
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
|
||||||
|
MAINTENANCE_RECOVERY=0
|
||||||
|
|||||||
11
deploy/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 部署契约变更
|
||||||
|
|
||||||
|
## 未发布:站点设置与运行时契约 v2
|
||||||
|
|
||||||
|
最低可升级源版本:0.1.0。应用镜像的发布版本号由正式发布流程指定,本工作区未推送镜像。
|
||||||
|
|
||||||
|
新增可选环境变量 SETTINGS_MASTER_KEY、SERVICE_PRIVATE_HOSTS、TRUSTED_PROXIES、MAINTENANCE_RECOVERY。Compose 保留既有服务名、端口、volume;按 CONTRACT.md 将运行时环境契约扩展记为 v2。SITE_URL 现同时用于账号邮件和 SEO,Go/Next必须一致;凭据功能要求部署独立加密主密钥。
|
||||||
|
|
||||||
|
升级前备份数据库与 DATA_DIR,首次启动自动执行新增表及 object_id 字段迁移。启用 S3 后不能直接回滚到不支持远程对象的旧应用;旧版本也不执行新的维护/过滤规则。保留主密钥与历史存储配置,回滚需在运维维护窗口先处理远程文件读取兼容。
|
||||||
|
|
||||||
|
详细配置、迁移、恢复和验证见 ../docs/site-operations.md。
|
||||||
@@ -41,6 +41,10 @@ services:
|
|||||||
DATA_DIR: /var/lib/jiang13/data
|
DATA_DIR: /var/lib/jiang13/data
|
||||||
DB_DSN: postgres://${POSTGRES_USER:-jiang13}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-jiang13}?sslmode=disable
|
DB_DSN: postgres://${POSTGRES_USER:-jiang13}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-jiang13}?sslmode=disable
|
||||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in deploy/.env}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in deploy/.env}
|
||||||
|
SETTINGS_MASTER_KEY: ${SETTINGS_MASTER_KEY:-}
|
||||||
|
SERVICE_PRIVATE_HOSTS: ${SERVICE_PRIVATE_HOSTS:-}
|
||||||
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
|
||||||
|
MAINTENANCE_RECOVERY: ${MAINTENANCE_RECOVERY:-0}
|
||||||
SITE_URL: ${SITE_URL:-https://localhost}
|
SITE_URL: ${SITE_URL:-https://localhost}
|
||||||
volumes:
|
volumes:
|
||||||
- appdata:/var/lib/jiang13/data
|
- appdata:/var/lib/jiang13/data
|
||||||
|
|||||||
BIN
docs/screenshots/settings/filter-desktop.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/settings/filter-mobile.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/screenshots/settings/mail-desktop.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/screenshots/settings/mail-mobile.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
docs/screenshots/settings/maintenance-desktop.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/screenshots/settings/maintenance-mobile.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/screenshots/settings/security-desktop-dark.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/screenshots/settings/security-desktop.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/screenshots/settings/security-mobile-dark.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/screenshots/settings/security-mobile.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/screenshots/settings/storage-desktop.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/screenshots/settings/storage-mobile.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
144
docs/site-operations.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# 站点设置五模块:实现与交付说明
|
||||||
|
|
||||||
|
验证日期:2026-09-21。本次修改位于工作区,未发布到生产。测试使用独立 PostgreSQL 数据库、临时数据目录、SMTP/S3 协议测试设施,没有向真实用户发信或清理生产数据。
|
||||||
|
|
||||||
|
## 现有能力与改动归属
|
||||||
|
|
||||||
|
- 原有基本信息、互动与安全(含原内容互动与访问安全)、主题变量、管理权限、CSRF、Cookie 会话、上传校验、附件下载授权继续使用。站点设置各分区为独立路由(/admin/settings/basic 等)。时间线编辑器仍支持从 GitHub/Gitea 导入提交(内置规则,不可在站点设置中编辑)。
|
||||||
|
- 原有开放注册及上传大小/扩展名/数量设置迁到对应新模块。实际值仍由原 SiteSetting 键提供,模块保存通过同一事务更新,旧通用接口拒绝写入这些已迁移键,避免双重来源。
|
||||||
|
- 新增独立模块版本、机密加密、共享计数、SMTP 任务与验证码、S3 适配器、统一内容匹配、维护访问守卫及受控临时文件清理。
|
||||||
|
- 原站点没有独立应用缓存和订阅邮件流程,不展示清空缓存或群发通知的虚假能力。
|
||||||
|
- 延续现有明暗主题与品牌令牌。截图来自隔离站点默认蓝色主题,没有覆盖真实站点已保存的绿色或其他主题色。
|
||||||
|
|
||||||
|
## 文件与关键实现
|
||||||
|
|
||||||
|
- backend/model/operations.go、model/db.go、model/models.go:模块、审计、额度、持久邮件、验证码、对象引用和临时上传记录;附件新增 object_id。
|
||||||
|
- backend/service/operations.go:默认值、完整校验、AES-256-GCM、版本比较、跨模块事务依赖、旧键兼容。
|
||||||
|
- backend/service/operations_security.go:PostgreSQL 原子共享计数、账号/来源登录保护、受控服务目标解析与连接。
|
||||||
|
- backend/service/operations_mail.go:验证证书的 TLS/STARTTLS、测试连接/实际测试发信、品牌模板、加密队列、租约恢复、有限重试、验证码。
|
||||||
|
- backend/service/operations_storage.go:S3 签名请求、专用前缀读写删除探测、不可变配置快照、公开图片/CDN与私有附件隔离。
|
||||||
|
- backend/service/operations_filter.go:CommonMark 可读文本、NFKC/大小写归一化、匹配位置和局部例外、拦截优先、脱敏审计。
|
||||||
|
- backend/service/operations_maintenance.go、operations_temporary.go:密码重置、真实诊断、日志清理、带跨实例上传锁的临时文件扫描与清理。
|
||||||
|
- backend/handler/operations.go、handlers.go、router/router.go:复用 PermSettings 和 CSRF;管理接口、公开白名单、业务配额、维护访问控制。
|
||||||
|
- backend/handler/auth.go、post.go、setting.go;service/auth.go、post.go、comment.go、upload.go、post_file.go、brand_store.go:实际注册/登录/发布/编辑/上传/下载接入。
|
||||||
|
- backend/service/ratelimit.go、middleware/ratelimit.go:修复原限流器把完整请求键当类别查找的问题,加入 Retry-After 和过期清理。
|
||||||
|
- backend/go.mod、go.sum:MinIO Go S3 客户端及 Goldmark 解析器。
|
||||||
|
- frontend/app/admin/settings/OperationsPanel.tsx、FilterRules.tsx、SettingsAdmin.tsx:五模块表单、独立保存、草稿测试、版本错误、导航离开保护、规则导入/编辑/批量操作、诊断与确认清理。
|
||||||
|
- frontend/app/admin/settings/BasicIdentityPanel.tsx:只读正式地址来源与 SEO 关键词说明修正。
|
||||||
|
- frontend/lib/api.ts、app/register/page.tsx、app/login/page.tsx、app/reset-password/page.tsx:注册验证码、找回密码、管理调用。
|
||||||
|
- frontend/components/OperationalBanner.tsx、app/layout.tsx:只读提示,最多约 30 秒刷新提示;服务器写入限制立即生效。
|
||||||
|
- frontend/middleware.ts:服务端读取维护状态并返回真正的 HTTP 503,保留原有会话恢复和 robots 大小写改写。
|
||||||
|
- frontend/app/globals.css:复用设计令牌的表单、结果、规则和窄屏布局。
|
||||||
|
- backend/service/operations_test.go、backend/tests/operations-api.mjs:风险驱动的服务与 HTTP 回归。
|
||||||
|
- .env.example、deploy/.env.example、deploy/docker-compose.yml、deploy/CHANGELOG.md:部署变量与升级说明。
|
||||||
|
|
||||||
|
## 设置项与校验
|
||||||
|
|
||||||
|
以下是缺失项的初始值;已有注册、文件限制继续使用数据库有效值。每个模块有独立版本,保存后立即供下一次服务请求读取;不需要重启应用。SMTP/S3 服务状态只是配置状态,测试结果是带时间的历史结果,不表示持续在线。
|
||||||
|
|
||||||
|
### 访问与安全
|
||||||
|
|
||||||
|
- allow_register:默认 true,继承原配置。关闭后接口拒绝新注册,前台仍保留注册入口以便展示 register_notice;现有账号不受影响。
|
||||||
|
- register_notice:默认空,最多 200 字;关闭注册时在注册页展示。
|
||||||
|
- verify_email、password_reset:默认 false。启用前要求邮件已启用、凭据可解密、正式地址有效且 SMTP 连接/认证通过;依赖存在时禁止停用邮件或清空必需凭据。注册验证码在创建账号前消费,不批量改变老账号状态。
|
||||||
|
- login_window:600 秒,范围 60–3600;login_failures:5 次,范围 3–50。账号与来源分开计数,来源阈值为账号阈值四倍,窗口到期自动恢复;成功登录清除账号失败计数。
|
||||||
|
- post_interval:6 秒;comment_interval:2 秒,均允许 0–3600。根据原有每分钟 10/30 次校准,新规则采用最小间隔,0 仅关闭该最小间隔。原有积分/等级业务发帖冷却仍有效,不能把两者视作相互替代。
|
||||||
|
- resend_interval:60 秒,范围 30–3600,目标邮箱+用途;email_hourly:5 次,范围 1–20,按规范化邮箱共享;另外每来源最多 20 次/小时。
|
||||||
|
- search_minute:30 次/分钟,范围 1–120;登录用户按 ID、游客按真实可信来源计量。
|
||||||
|
- 新增安全额度使用 PostgreSQL 原子计数、哈希键及到期清理,多实例共享。原有其他交互接口的内存防滥用限流仍按进程工作,不宣称全站一致。
|
||||||
|
- 密码沿用既有 6–64 字节、bcrypt、会话版本规则;重置后撤销旧会话。第三方验证码/MFA 不在本期。
|
||||||
|
|
||||||
|
### 邮件服务
|
||||||
|
|
||||||
|
- enabled:默认 false;host/username/password/from:启用时必填,主机不能含协议或路径;密码保存前必须有主密钥。
|
||||||
|
- tls:默认 tls(隐式 TLS),也支持 starttls(必须升级且验证证书);port 默认 465,范围 1–65535。STARTTLS 常用 587,由站长按服务商实际端口填写。没有忽略证书或明文降级选项。
|
||||||
|
- from_name:留空复用站点名称,最多 120 字节;reply_to:可空,非空需为邮箱;用户名最多 256 字节,禁止邮件头换行。
|
||||||
|
- timeout:10 秒,范围 2–30;retention:30 天,范围 1–365。缩短保留时间不立即删除记录,需另行确认清理。
|
||||||
|
- 正式地址和浅色 Logo 复用基本信息/部署 SITE_URL。主题与正文可在后台自由编辑,注册验证和找回密码共用;留空恢复内置模板。变量为 {{site_name}}、{{purpose}}、{{code}}、{{link}}、{{site_url}}、{{logo_url}}(绝对图片 URL)、{{logo}}(完整 img 标签,勿写入 src)。正文必须保留 {{code}};不做 HTML 过滤。发送时变量值会转义,并把 `<style>` 中的简单 class 规则内联到元素(手机 QQ 等客户端会丢弃 style 标签);邮件以 quoted-printable HTML 发出。预览与实发使用同一渲染结果。邮件链接回到可信正式地址,由用户粘贴验证码完成验证/重置。
|
||||||
|
- 凭据读取只返回已配置标志,不回显原文,拒绝掩码占位符。未保存时直接填写;已保存时默认沿用,可单独更换或移除。更换与移除互斥,留空且不移除则保持旧值。
|
||||||
|
- 保存启用的邮件配置前实际验证连接与认证。测试按钮使用当前草稿,测试连接不投递;发送测试邮件只发给管理员指定地址。
|
||||||
|
- 业务邮件记录排队/发送中/重试/失败/服务器已接受。最多 3 次尝试,2 分钟租约用于崩溃恢复;任务去重与稳定 Message-ID。SMTP 在“远端接受后本地未记录”的故障窗口可能重投,不承诺严格 exactly-once。
|
||||||
|
- 队列有效载荷加密,完成/最终失败后清空;日志不记录验证码、正文或凭据。服务器接受不等于送达;SPF/DKIM/DMARC只给指引,未检测则不显示通过。
|
||||||
|
|
||||||
|
### 文件与存储
|
||||||
|
|
||||||
|
- backend:默认 local;本地根目录由 DATA_DIR 固定,只读显示。切换 S3 只影响新头像、正文图片和普通附件。站点 Logo/背景继续保存在部署品牌目录;媒体库中的远程图片可按权限复制为品牌素材。
|
||||||
|
- endpoint:生产 HTTPS origin,开发允许 HTTP;bucket:3–63 位符合桶名格式;region:默认 us-east-1,1–64 位字母/数字/_/-;access_key、secret_key:机密交互同邮件。
|
||||||
|
- prefix:默认 jiang13/uploads/,受控相对目录,以 / 结束,不允许越界;path_style:默认 true,支持路径或虚拟主机寻址。
|
||||||
|
- cdn:默认空,可选 HTTPS 基址。只用于已声明公开图片,部署方必须配置其桶/前缀权限。私有文件仍走鉴权下载;CDN 不是将私有桶设为公开的指令。
|
||||||
|
- image_max_mb:默认 5 MiB,范围 1–50;attachment_max_mb:默认 20 MiB,范围 1–100;attachment_max_count:默认每帖 10,范围 1–20。原 API 每次上传一个文件,前端批量仍受每帖上限约束。
|
||||||
|
- attachment_ext_limit:默认 true;attachment_exts:继承原业务白名单,1–80 个小写字母数字扩展名。原站点支持软件安装包和技术文本;这些仍只作为强制下载附件,不能内联执行。
|
||||||
|
- 正文图片复用 JPEG/PNG/WebP 解码检查与最大 4096px;头像保持 2 MiB、64–512px WebP;SVG 仅保留管理员品牌上传能力。附件复用真实图片头校验,伪装/活跃内容降为二进制并强制 attachment/nosniff。
|
||||||
|
- 1 MiB=1,048,576 字节;仓库 Nginx 示例与 Next 代理缓冲均为 512 MiB,仍需确认实际部署入口没有更低限制。
|
||||||
|
- 保存 S3 前验证合并后的草稿,在专用 tests/ 前缀上传、读回并删除一个随机测试对象,不扫描桶。失败不更新配置;不会自动切回其他存储。
|
||||||
|
- 每个远程对象保存不可变 storage-N 配置引用与对象键。切换后原本地 URL、历史远程配置继续可读;界面显示历史引用数,不提供删除历史配置按钮。
|
||||||
|
- 删除媒体时先撤销本站公开资源访问,再删除远程对象;清理失败保留配置引用,需运维处理残留对象。自行配置的 CDN 缓存失效由部署方处理。
|
||||||
|
|
||||||
|
### 内容过滤
|
||||||
|
|
||||||
|
- enabled:默认 false;rules:默认空,最多 2000。每条 ID 唯一且最多 64 字节,词语 1–80 字,备注最多 500 字节,最多 20 条例外,每条例外最多 240 字节且非空。
|
||||||
|
- scopes:username/title/body/comment;action:block/log;每条规则独立启停。NFKC 归一化并忽略大小写,仅用于匹配,原始内容不改写。
|
||||||
|
- 正文/评论从 CommonMark AST 提取可读文本,跳过代码块、行内代码、链接目标/自动链接、HTML块/标签;普通链接显示文字参与匹配。测试显示归一化文本及 0 起始字符位置。
|
||||||
|
- 例外仅覆盖对应规则的对应出现位置,同文其他命中仍拦截;拦截优先于记录。创建/编辑帖子与评论、注册/修改昵称在服务层检查,导入草稿最终发布同样走该服务。
|
||||||
|
- TXT 每行一个词,300 KB 上限;预览新增/重复/无效项,默认合并,覆盖需确认,应用导入仅修改草稿,保存后生效。导出 TXT 仅词语,范围/例外不作为无损备份。
|
||||||
|
- 不改写历史内容,不自动换星号,不伪造审核工作流。正则和复杂审核为后续增强。
|
||||||
|
|
||||||
|
### 维护与诊断
|
||||||
|
|
||||||
|
- mode:默认 normal,可选 readonly/paused;readonly 阻止普通用户内容写入,paused 公开页面及业务 API 返回 503。
|
||||||
|
- title:默认“站点维护中”,最多 240 字节;message:最多 4000 字节;contact:最多 500 字节;均按纯文本处理。
|
||||||
|
- until:默认空,有值必须是含时区的 RFC3339;仅展示,不会自动恢复。
|
||||||
|
- retry_after:默认 300 秒,范围 30–86400;temp_days:默认 7 天,范围 1–365。
|
||||||
|
- 登录、退出、会话恢复、找回密码、站点状态、健康检查保持可用;管理接口继续独立权限校验。后台绕过基于实时真实 PermSettings;robots 保留原响应,不用 robots 实现停站。
|
||||||
|
- Go 实际拒绝写入;Next 页面/RSC 请求查询实时状态,暂停时返回 503 + Retry-After + private,no-store。状态不可读时安全失败为 503。已打开页面的只读提示会定期更新,后端限制不依赖提示是否刷新。
|
||||||
|
- 数据库 ping、本地创建/删除探测、队列查询、版本/运行时长和带时间的历史邮件测试是真实数据;无独立缓存如实标注,S3 连续健康未知,使用专用测试按钮检测。
|
||||||
|
- 临时扫描不删除;一次清理最多 100 个受控候选。只清理本版本登记、过期且不在上传中的 .partial,执行前复查引用和跨实例数据库会话锁;正文/草稿附件、未知来源旧 partial 均保留。返回成功/跳过/失败数量,可重新扫描重试。
|
||||||
|
- 清理邮件记录仅影响超出保留期的终态记录,不删除排队任务、会话、限流计数。
|
||||||
|
- 无在线清库、重置全站、备份恢复或程序更新按钮。
|
||||||
|
|
||||||
|
## 数据库迁移与部署
|
||||||
|
|
||||||
|
1. 升级前备份数据库和 DATA_DIR;多个实例滚动部署时,先让数据库执行新增迁移,再统一升级服务。旧版本不认识维护守卫和 S3 引用,不应长期与新版本混跑。
|
||||||
|
2. 启动时 GORM AutoMigrate 新增 module_configs、settings_audits、action_counters、mail_tasks、email_challenges、stored_objects、temporary_uploads,并为 post_attachments 新增 object_id。既有行为空值表示本地附件,不批量重写历史链接。
|
||||||
|
3. SETTINGS_MASTER_KEY 必须为安全随机 32 字节的标准 Base64;例如在安全终端使用 openssl rand -base64 32 生成。不要使用测试用的全零密钥。本地开发写在 `backend/app.ini` 的 `[security]`;生产与多实例用同名环境变量,环境变量优先于 ini。各 API 实例配置相同密钥,单独备份,不写入仓库或与密文一同存库。更换/丢失密钥会使队列与所有历史存储凭据不可解密;本期没有在线轮换功能。修改后需重启 API。
|
||||||
|
4. Go 与 Next 的 SITE_URL 必须一致且为正式外部 HTTPS origin;后台基本信息只读显示。Next 的 BACKEND_URL 指向可达 API,不使用请求 Host 生成邮件地址。
|
||||||
|
5. SERVICE_PRIVATE_HOSTS 为逗号分隔的精确主机名。默认拒绝私网、回环、链路本地及特殊地址,DNS 解析后连接已经检查的 IP;确有自建 SMTP/S3 时只放行对应主机。虚拟主机 S3 寻址时要按实际连接的 bucket.endpoint 主机配置。
|
||||||
|
6. TRUSTED_PROXIES 仅列出真正的代理 IP/CIDR。默认不信任转发头;不要直接填任意全网,避免登录/IP额度绕过。
|
||||||
|
7. MAINTENANCE_RECOVERY=1 是部署级紧急恢复:重启 API 后强制正常访问;修复后台模式,再撤销该变量并重启。不会悄悄覆写数据库保存模式。
|
||||||
|
8. Compose 已透传四个新变量并保留原服务/端口/数据卷。参见 deploy/CHANGELOG.md。JWT、DB_DSN、Cookie安全属性仍保持部署级管理。
|
||||||
|
9. 反代/CDN不得缓存 HTML、/api/site-state、鉴权接口、503;停站和恢复后验证外部入口的真实状态码及缓存头。外部 CDN 未在本地测试范围内。
|
||||||
|
10. 多实例本地文件存储需要共享相同数据卷;所有实例使用一致数据库、密钥、正式地址。临时上传锁通过相对路径在数据库中协调。S3需保留历史配置/主密钥以及桶内对象。
|
||||||
|
|
||||||
|
## 验证记录与复现
|
||||||
|
|
||||||
|
服务测试使用临时 schema,结束删除 schema;API脚本只允许显式声明隔离的 3301 本机后端。不要把这些测试指向现有真实站点。
|
||||||
|
|
||||||
|
- 后端:设置 OPS_TEST_DATABASE_URL=postgres://…/ops_test?sslmode=disable 后,在 backend 运行 go test ./...。已通过完整测试。覆盖加密/AAD/缺失密钥、失败保存不改变配置、版本争用和依赖原子性、共享限流/到期恢复、SMTP认证成功/失败/超时/证书拒绝、队列去重/重启租约恢复/有限重试、邮箱跨来源限流/验证码单次消费、S3读写删除/历史私有对象、Unicode/例外/Markdown过滤、临时文件活跃锁/正文引用/草稿保护。
|
||||||
|
- HTTP:OPS_TEST_BASE_URL=http://127.0.0.1:3301、OPS_TEST_CONFIRM=isolated,运行 node tests/operations-api.mjs,配合指向同后端的 Next :3000。已通过权限/CSRF/注册关闭/并发200+409/登录429与Retry-After/创建编辑过滤/评论间隔/只读写入拒绝/暂停API和页面503/robots200/恢复入口/恢复页面200。
|
||||||
|
- 前端:npx next typegen、npx tsc --noEmit --incremental false、npm run build 均通过;npm test 的 16 项已有 SEO/JSON-LD 测试也通过。现有 middleware 文件命名有 Next 的弃用提示,仍可构建;本次保留原文件及用户已有 robots 修改,未另做 proxy 迁移。
|
||||||
|
- 实际浏览器:安全设置保存显示新版本及已生效;服务重启后仍读取已保存版本;本地存储测试真实成功;未配置 SMTP 测试显示错误;过滤测试显示局部例外及代码跳过;TXT预览正确显示新增1/重复2;导入未保存离开确认、取消保留及放弃行为正常;规则变化后旧测试标为过期;诊断与临时扫描正常。
|
||||||
|
- 1440px 桌面与 390px 手机、浅/暗主题人工截图检查。手机文档宽度390px,无横向页面溢出。全部截图来自一次性测试账号和数据,规则/维护标题不是生产默认值。
|
||||||
|
|
||||||
|
### 页面截图
|
||||||
|
|
||||||
|
- [访问与安全(桌面)](screenshots/settings/security-desktop.jpg)
|
||||||
|
- [邮件服务(桌面)](screenshots/settings/mail-desktop.jpg)
|
||||||
|
- [文件与存储(桌面)](screenshots/settings/storage-desktop.jpg)
|
||||||
|
- [内容过滤(桌面)](screenshots/settings/filter-desktop.jpg)
|
||||||
|
- [维护与诊断(桌面)](screenshots/settings/maintenance-desktop.jpg)
|
||||||
|
- [邮件服务(手机)](screenshots/settings/mail-mobile.jpg)
|
||||||
|
- [文件与存储(手机)](screenshots/settings/storage-mobile.jpg)
|
||||||
|
- [内容过滤(手机)](screenshots/settings/filter-mobile.jpg)
|
||||||
|
- [维护与诊断(手机)](screenshots/settings/maintenance-mobile.jpg)
|
||||||
|
- [访问与安全(手机浅色)](screenshots/settings/security-mobile.jpg)
|
||||||
|
- [访问与安全(手机暗色)](screenshots/settings/security-mobile-dark.jpg)
|
||||||
|
- [访问与安全(桌面暗色)](screenshots/settings/security-desktop-dark.jpg)
|
||||||
|
|
||||||
|
## 仍需站长配置与外部验收
|
||||||
|
|
||||||
|
- 填写正式 SITE_URL、随机 SETTINGS_MASTER_KEY、可信代理及必要的内部服务允许列表。
|
||||||
|
- 提供真实 SMTP 主机/端口/模式/账号授权码/发件邮箱;先连接测试,再只向指定测试邮箱发送。真实供应商、STARTTLS供应商互操作、垃圾邮件归类及 SPF/DKIM/DMARC 未做外部实测。
|
||||||
|
- 使用 S3 时提供 Endpoint/Bucket/Region/两项密钥/专用前缀和必要 CDN 权限,验证真实服务的小文件、大文件上传与私有下载。协议设施通过不等于所有云厂商均兼容;真实 MinIO/云供应商、CDN 和反代组合尚需部署验收。
|
||||||
|
- 现有公开图片继续公开;私有附件保留鉴权/帖子可见性/积分规则。历史附件批量迁移、密钥在线轮换、专用云SDK、第三方验证码、MFA、订阅通知、正则审核、数据库在线恢复是后续增强。
|
||||||
|
|
||||||
|
验证结束后已停止本次启动的临时前端、后端与 PostgreSQL;截图和源码保留在仓库内。
|
||||||
@@ -34,12 +34,10 @@ import {
|
|||||||
apiAdminRejectPost,
|
apiAdminRejectPost,
|
||||||
apiAdminApproveComment,
|
apiAdminApproveComment,
|
||||||
apiAdminRejectComment,
|
apiAdminRejectComment,
|
||||||
apiUpdateSiteSettings,
|
|
||||||
type AdminContentComment,
|
type AdminContentComment,
|
||||||
type AdminContentCounts,
|
type AdminContentCounts,
|
||||||
type AdminContentPost,
|
type AdminContentPost,
|
||||||
type AdminContentStatus,
|
type AdminContentStatus,
|
||||||
type PublicSettings,
|
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { canModerateAuthor } from "@/lib/roles";
|
import { canModerateAuthor } from "@/lib/roles";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
@@ -58,8 +56,6 @@ import {
|
|||||||
AdminSearchField,
|
AdminSearchField,
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminStatusChip,
|
AdminStatusChip,
|
||||||
AdminStepper,
|
|
||||||
AdminSwitch,
|
|
||||||
contentStatusChip,
|
contentStatusChip,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
|
||||||
@@ -184,29 +180,6 @@ function PendingActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 策略项:标题、控件与保存按钮成组换行,不拆散单项 */
|
|
||||||
function PolicyChip({
|
|
||||||
title,
|
|
||||||
hint,
|
|
||||||
control,
|
|
||||||
instant,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
hint?: string;
|
|
||||||
control: ReactNode;
|
|
||||||
instant?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="admin-content-policy-chip" data-tip={hint || undefined}>
|
|
||||||
<span className="admin-content-policy-chip-copy">
|
|
||||||
<span className="admin-content-policy-chip-label">{title}</span>
|
|
||||||
{instant ? <span className="admin-content-policy-chip-how">立即生效</span> : null}
|
|
||||||
</span>
|
|
||||||
<div className="admin-content-policy-chip-ctrl shrink-0 flex items-center gap-1.5">{control}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColumnShell({
|
function ColumnShell({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@@ -290,10 +263,8 @@ export default function ContentAdmin({
|
|||||||
initialCommentsTotal,
|
initialCommentsTotal,
|
||||||
initialCounts,
|
initialCounts,
|
||||||
initError,
|
initError,
|
||||||
canConfigureSettings,
|
|
||||||
canPurge,
|
canPurge,
|
||||||
viewerRole,
|
viewerRole,
|
||||||
initialSettings,
|
|
||||||
}: {
|
}: {
|
||||||
initialKind: ContentKind;
|
initialKind: ContentKind;
|
||||||
initialStatus: AdminContentStatus;
|
initialStatus: AdminContentStatus;
|
||||||
@@ -303,10 +274,8 @@ export default function ContentAdmin({
|
|||||||
initialCommentsTotal: number;
|
initialCommentsTotal: number;
|
||||||
initialCounts: AdminContentCounts;
|
initialCounts: AdminContentCounts;
|
||||||
initError: string;
|
initError: string;
|
||||||
canConfigureSettings: boolean;
|
|
||||||
canPurge: boolean;
|
canPurge: boolean;
|
||||||
viewerRole: string;
|
viewerRole: string;
|
||||||
initialSettings: PublicSettings;
|
|
||||||
}) {
|
}) {
|
||||||
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
|
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
|
||||||
const [mobileKind, setMobileKind] = useState<ContentKind>(initialKind);
|
const [mobileKind, setMobileKind] = useState<ContentKind>(initialKind);
|
||||||
@@ -338,11 +307,6 @@ export default function ContentAdmin({
|
|||||||
|
|
||||||
const [actingKey, setActingKey] = useState<string | null>(null);
|
const [actingKey, setActingKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
|
|
||||||
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
|
|
||||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initialSettings.post_link_new_tab !== false);
|
|
||||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
|
||||||
|
|
||||||
const [confirm, setConfirm] = useState<{
|
const [confirm, setConfirm] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -463,65 +427,6 @@ export default function ContentAdmin({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTrust = async () => {
|
|
||||||
if (!canConfigureSettings || savingPolicy) return;
|
|
||||||
setSavingPolicy(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
|
|
||||||
setTrustOn(res.trust_reviewed_publish);
|
|
||||||
toast(
|
|
||||||
res.trust_reviewed_publish
|
|
||||||
? "已开启过审免审(帖子与评论)"
|
|
||||||
: "已关闭过审免审(帖子与评论将按规则待审)",
|
|
||||||
"ok"
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSavingPolicy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCooldown = async () => {
|
|
||||||
if (!canConfigureSettings || savingPolicy) return;
|
|
||||||
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
|
|
||||||
setSavingPolicy(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
|
|
||||||
setCooldown(res.post_cooldown_hours);
|
|
||||||
toast(
|
|
||||||
res.post_cooldown_hours === 0
|
|
||||||
? "已关闭新用户发帖冷静期"
|
|
||||||
: `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`,
|
|
||||||
"ok"
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSavingPolicy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePostLinkNewTab = async () => {
|
|
||||||
if (!canConfigureSettings || savingPolicy) return;
|
|
||||||
const next = !postLinkNewTab;
|
|
||||||
setSavingPolicy(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
|
|
||||||
setPostLinkNewTab(res.post_link_new_tab !== false);
|
|
||||||
toast(
|
|
||||||
res.post_link_new_tab !== false
|
|
||||||
? "帖子与评论外链将在新标签打开"
|
|
||||||
: "帖子与评论外链将在当前页打开",
|
|
||||||
"ok"
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSavingPolicy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const postsToolbar = (
|
const postsToolbar = (
|
||||||
<div className="admin-content-col-toolbar">
|
<div className="admin-content-col-toolbar">
|
||||||
<div className="admin-content-status-scroll">
|
<div className="admin-content-status-scroll">
|
||||||
@@ -922,73 +827,9 @@ export default function ContentAdmin({
|
|||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
title="内容管理"
|
title="内容管理"
|
||||||
description="审核与管理帖子、评论。共用策略作用于全站发帖与评论,与下方列表筛选无关。"
|
description="审核与管理帖子、评论。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{canConfigureSettings && (
|
|
||||||
<div className="admin-content-policy" role="group" aria-label="全站内容策略">
|
|
||||||
<div className="admin-content-policy-lead">
|
|
||||||
<span className="admin-content-policy-title">共用策略</span>
|
|
||||||
<span className="admin-content-policy-scope">
|
|
||||||
过审免审与外链开关改完立即写入;冷静期需点保存。失败时恢复为已确认值。
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-content-policy-row">
|
|
||||||
<PolicyChip
|
|
||||||
title="过审免审"
|
|
||||||
instant
|
|
||||||
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。修改后立即生效。"
|
|
||||||
control={
|
|
||||||
<AdminSwitch
|
|
||||||
checked={trustOn}
|
|
||||||
disabled={savingPolicy}
|
|
||||||
onChange={() => void toggleTrust()}
|
|
||||||
label="过审免审(帖子与评论)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PolicyChip
|
|
||||||
title="新用户冷静期"
|
|
||||||
hint="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受此限制。需点保存后生效。"
|
|
||||||
control={
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<AdminStepper
|
|
||||||
value={cooldown}
|
|
||||||
onChange={setCooldown}
|
|
||||||
min={0}
|
|
||||||
max={168}
|
|
||||||
unit="小时"
|
|
||||||
ariaLabel="新用户发帖冷静期小时数"
|
|
||||||
disabled={savingPolicy}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-line btn-sm"
|
|
||||||
disabled={savingPolicy}
|
|
||||||
onClick={() => void saveCooldown()}
|
|
||||||
>
|
|
||||||
{savingPolicy ? "保存中…" : "保存"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PolicyChip
|
|
||||||
title="外链新开"
|
|
||||||
instant
|
|
||||||
hint="帖子与评论中的外链在新标签打开。修改后立即生效。"
|
|
||||||
control={
|
|
||||||
<AdminSwitch
|
|
||||||
checked={postLinkNewTab}
|
|
||||||
disabled={savingPolicy}
|
|
||||||
onChange={() => void togglePostLinkNewTab()}
|
|
||||||
label="外链新开(帖子与评论)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 窄屏:顶部分段切换双栏 */}
|
{/* 窄屏:顶部分段切换双栏 */}
|
||||||
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="内容类型">
|
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="内容类型">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
type AdminContentPost,
|
type AdminContentPost,
|
||||||
type AdminContentStatus,
|
type AdminContentStatus,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles";
|
import { canModerateAny, isOwner } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
|||||||
let initError = "";
|
let initError = "";
|
||||||
|
|
||||||
// PC 双栏同时展示:SSR 并行预取帖子与评论
|
// PC 双栏同时展示:SSR 并行预取帖子与评论
|
||||||
const [postsSettled, commentsSettled, counts, settings] = await Promise.all([
|
const [postsSettled, commentsSettled, counts] = await Promise.all([
|
||||||
fetchAdminContentPosts(1, initialStatus, cookie || undefined)
|
fetchAdminContentPosts(1, initialStatus, cookie || undefined)
|
||||||
.then((res) => ({ ok: true as const, res }))
|
.then((res) => ({ ok: true as const, res }))
|
||||||
.catch((e) => ({
|
.catch((e) => ({
|
||||||
@@ -65,7 +65,6 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
|||||||
error: e instanceof Error ? e.message : "获取评论失败",
|
error: e instanceof Error ? e.message : "获取评论失败",
|
||||||
})),
|
})),
|
||||||
fetchAdminContentCounts(cookie || undefined),
|
fetchAdminContentCounts(cookie || undefined),
|
||||||
getPublicSettingsCached(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (postsSettled.ok) {
|
if (postsSettled.ok) {
|
||||||
@@ -92,10 +91,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
|||||||
initialCommentsTotal={initialCommentsTotal}
|
initialCommentsTotal={initialCommentsTotal}
|
||||||
initialCounts={counts}
|
initialCounts={counts}
|
||||||
initError={initError}
|
initError={initError}
|
||||||
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
|
||||||
canPurge={isOwner(me.user.role)}
|
canPurge={isOwner(me.user.role)}
|
||||||
viewerRole={me.user.role}
|
viewerRole={me.user.role}
|
||||||
initialSettings={settings}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
280
frontend/app/admin/settings/AccessSettings.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, Timer } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminSettingsGroup,
|
||||||
|
AdminSettingsRow,
|
||||||
|
AdminStepper,
|
||||||
|
AdminSwitch,
|
||||||
|
} from "@/components/admin";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import OperationsPanel from "./OperationsPanel";
|
||||||
|
|
||||||
|
function InstantNote({ saving }: { saving?: boolean }) {
|
||||||
|
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容互动开关 + 访问安全模块 */
|
||||||
|
export default function AccessSettings({ initial }: { initial: PublicSettings }) {
|
||||||
|
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
||||||
|
const [commentsRequireLogin, setCommentsRequireLogin] = useState(
|
||||||
|
initial.comments_require_login === true,
|
||||||
|
);
|
||||||
|
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
||||||
|
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
|
||||||
|
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
|
||||||
|
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
|
||||||
|
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
|
||||||
|
const [togglingComments, setTogglingComments] = useState(false);
|
||||||
|
const [togglingCommentsLogin, setTogglingCommentsLogin] = useState(false);
|
||||||
|
const [togglingMessages, setTogglingMessages] = useState(false);
|
||||||
|
const [togglingLink, setTogglingLink] = useState(false);
|
||||||
|
const [togglingTrust, setTogglingTrust] = useState(false);
|
||||||
|
const [savingCooldown, setSavingCooldown] = useState(false);
|
||||||
|
const [moduleDirty, setModuleDirty] = useState(false);
|
||||||
|
const leave = useUnsavedGuard(moduleDirty);
|
||||||
|
|
||||||
|
const toggleComments = async () => {
|
||||||
|
if (togglingComments) return;
|
||||||
|
const next = !allowComments;
|
||||||
|
setTogglingComments(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ allow_comments: next });
|
||||||
|
setAllowComments(res.allow_comments);
|
||||||
|
toast(res.allow_comments ? "已开放评论" : "已关闭评论", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setTogglingComments(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCommentsRequireLogin = async () => {
|
||||||
|
if (togglingCommentsLogin || !allowComments) return;
|
||||||
|
const next = !commentsRequireLogin;
|
||||||
|
setTogglingCommentsLogin(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ comments_require_login: next });
|
||||||
|
setCommentsRequireLogin(res.comments_require_login === true);
|
||||||
|
toast(res.comments_require_login ? "评论仅登录用户可见" : "游客也可查看评论", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setTogglingCommentsLogin(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMessages = async () => {
|
||||||
|
if (togglingMessages) return;
|
||||||
|
const next = !allowMessages;
|
||||||
|
setTogglingMessages(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ allow_messages: next });
|
||||||
|
setAllowMessages(res.allow_messages);
|
||||||
|
toast(res.allow_messages ? "已开放消息" : "已关闭消息", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setTogglingMessages(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePostLinkNewTab = async () => {
|
||||||
|
if (togglingLink) return;
|
||||||
|
const next = !postLinkNewTab;
|
||||||
|
setTogglingLink(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
|
||||||
|
setPostLinkNewTab(res.post_link_new_tab !== false);
|
||||||
|
toast(
|
||||||
|
res.post_link_new_tab !== false
|
||||||
|
? "帖子与评论外链将在新标签打开"
|
||||||
|
: "帖子与评论外链将在当前页打开",
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setTogglingLink(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTrust = async () => {
|
||||||
|
if (togglingTrust) return;
|
||||||
|
setTogglingTrust(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
|
||||||
|
setTrustOn(res.trust_reviewed_publish);
|
||||||
|
toast(
|
||||||
|
res.trust_reviewed_publish
|
||||||
|
? "已开启过审免审(帖子与评论)"
|
||||||
|
: "已关闭过审免审(帖子与评论将按规则待审)",
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setTogglingTrust(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCooldown = async () => {
|
||||||
|
if (savingCooldown) return;
|
||||||
|
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
|
||||||
|
setSavingCooldown(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
|
||||||
|
setCooldown(res.post_cooldown_hours);
|
||||||
|
setSavedCooldown(res.post_cooldown_hours);
|
||||||
|
toast(
|
||||||
|
res.post_cooldown_hours === 0
|
||||||
|
? "已关闭新用户发帖冷静期"
|
||||||
|
: `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`,
|
||||||
|
"ok",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSavingCooldown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-settings-stack">
|
||||||
|
<AdminSettingsGroup title="内容与互动">
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<MessageSquare size={15} /> 开放评论
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="关闭后全站不提供评论功能。"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AdminSwitch
|
||||||
|
checked={allowComments}
|
||||||
|
disabled={togglingComments}
|
||||||
|
onChange={() => void toggleComments()}
|
||||||
|
label="开放评论"
|
||||||
|
/>
|
||||||
|
<InstantNote saving={togglingComments} />
|
||||||
|
</div>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<LogIn size={15} /> 登录可见评论
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="开启后游客无法查看评论内容,需登录后阅读与参与。"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AdminSwitch
|
||||||
|
checked={commentsRequireLogin}
|
||||||
|
disabled={!allowComments || togglingCommentsLogin}
|
||||||
|
onChange={() => void toggleCommentsRequireLogin()}
|
||||||
|
label="登录可见评论"
|
||||||
|
/>
|
||||||
|
<InstantNote saving={togglingCommentsLogin} />
|
||||||
|
</div>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<MessagesSquare size={15} /> 开放消息
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="关闭后前台隐藏私聊与群聊入口。"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AdminSwitch
|
||||||
|
checked={allowMessages}
|
||||||
|
disabled={togglingMessages}
|
||||||
|
onChange={() => void toggleMessages()}
|
||||||
|
label="开放消息"
|
||||||
|
/>
|
||||||
|
<InstantNote saving={togglingMessages} />
|
||||||
|
</div>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ExternalLink size={15} /> 外链新标签打开
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="帖子与评论中的外链是否在新标签打开。"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AdminSwitch
|
||||||
|
checked={postLinkNewTab}
|
||||||
|
disabled={togglingLink}
|
||||||
|
onChange={() => void togglePostLinkNewTab()}
|
||||||
|
label="外链新标签打开"
|
||||||
|
/>
|
||||||
|
<InstantNote saving={togglingLink} />
|
||||||
|
</div>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ShieldCheck size={15} /> 过审免审
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="用户曾有过审内容后,后续发帖与评论直接公开。"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AdminSwitch
|
||||||
|
checked={trustOn}
|
||||||
|
disabled={togglingTrust}
|
||||||
|
onChange={() => void toggleTrust()}
|
||||||
|
label="过审免审"
|
||||||
|
/>
|
||||||
|
<InstantNote saving={togglingTrust} />
|
||||||
|
</div>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Timer size={15} /> 新用户发帖冷静期
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受限。"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<AdminStepper
|
||||||
|
value={cooldown}
|
||||||
|
onChange={setCooldown}
|
||||||
|
min={0}
|
||||||
|
max={168}
|
||||||
|
unit="小时"
|
||||||
|
ariaLabel="新用户发帖冷静期小时数"
|
||||||
|
disabled={savingCooldown}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={savingCooldown || cooldown === savedCooldown}
|
||||||
|
onClick={() => void saveCooldown()}
|
||||||
|
>
|
||||||
|
{savingCooldown ? "保存中…" : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
<OperationsPanel name="security" onDirtyChange={setModuleDirty} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={leave.leaveOpen}
|
||||||
|
title="有未保存的修改"
|
||||||
|
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
||||||
|
confirmLabel="离开"
|
||||||
|
danger
|
||||||
|
onCancel={leave.cancelLeave}
|
||||||
|
onConfirm={leave.confirmLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
|
||||||
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
@@ -17,6 +17,12 @@ export default function BasicIdentityPanel({
|
|||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial.site_name);
|
const [name, setName] = useState(initial.site_name);
|
||||||
|
const [siteURL, setSiteURL] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
apiOperations<{ site_url: string }>("/api/site-state")
|
||||||
|
.then((s) => setSiteURL(s.site_url))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
||||||
const [slogan, setSlogan] = useState(initial.site_slogan);
|
const [slogan, setSlogan] = useState(initial.site_slogan);
|
||||||
const [desc, setDesc] = useState(initial.site_description);
|
const [desc, setDesc] = useState(initial.site_description);
|
||||||
@@ -48,7 +54,10 @@ export default function BasicIdentityPanel({
|
|||||||
const preview = siteDocumentTitle(name, slogan);
|
const preview = siteDocumentTitle(name, slogan);
|
||||||
|
|
||||||
const addKeyword = (raw: string) => {
|
const addKeyword = (raw: string) => {
|
||||||
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean);
|
const parts = raw
|
||||||
|
.split(/[,,;;]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
if (parts.length === 0) return;
|
if (parts.length === 0) return;
|
||||||
setSaveError("");
|
setSaveError("");
|
||||||
setKeywords((prev) => {
|
setKeywords((prev) => {
|
||||||
@@ -121,9 +130,11 @@ export default function BasicIdentityPanel({
|
|||||||
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
||||||
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
||||||
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
||||||
setKeywords((prev) => (JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev));
|
setKeywords((prev) =>
|
||||||
|
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
|
||||||
|
);
|
||||||
onSaved?.(res);
|
onSaved?.(res);
|
||||||
toast("基础资料已保存", "ok");
|
toast("已保存", "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (ticket !== saveGen.current) return;
|
if (ticket !== saveGen.current) return;
|
||||||
const msg = e instanceof Error ? e.message : "保存失败";
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
@@ -134,105 +145,102 @@ export default function BasicIdentityPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearSaveError = () => {
|
||||||
|
if (saveError) setSaveError("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSettingsGroup
|
<AdminSettingsGroup
|
||||||
title="基础资料"
|
id="settings-identity"
|
||||||
description="用于页眉、浏览器标题、公开页摘要与搜索关键词。保存后对访客生效。"
|
title="站点身份"
|
||||||
footer={
|
footer={
|
||||||
<AdminSettingsActions
|
<AdminSettingsActions
|
||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveLabel="保存基础资料"
|
saveLabel="保存"
|
||||||
error={saveError}
|
error={saveError}
|
||||||
onSave={() => void save()}
|
onSave={() => void save()}
|
||||||
onDiscard={discard}
|
onDiscard={discard}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AdminSettingsRow label="显示名称" htmlFor="site-name" hintBelow="用于版权行与浏览器标题">
|
<div className="admin-identity-studio">
|
||||||
|
<div className="admin-identity-url" role="group" aria-label="正式站点地址">
|
||||||
|
<span className="admin-identity-url-label">正式地址</span>
|
||||||
|
<code className="admin-identity-url-value">{siteURL || "尚未部署 SITE_URL"}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-identity-grid">
|
||||||
<AdminField
|
<AdminField
|
||||||
id="site-name"
|
id="site-name"
|
||||||
label="显示名称"
|
label="显示名称"
|
||||||
embed
|
className="admin-identity-field"
|
||||||
className="admin-settings-control-text"
|
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setName(v);
|
setName(v);
|
||||||
if (nameError) setNameError("");
|
if (nameError) setNameError("");
|
||||||
if (saveError) setSaveError("");
|
clearSaveError();
|
||||||
}}
|
}}
|
||||||
placeholder="姜十三论坛"
|
placeholder="姜十三论坛"
|
||||||
error={nameError}
|
error={nameError}
|
||||||
/>
|
/>
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow
|
|
||||||
label="字标"
|
|
||||||
htmlFor="site-wordmark"
|
|
||||||
hintBelow="无 Logo 或纯文字页眉时使用;留空则使用显示名称"
|
|
||||||
>
|
|
||||||
<AdminField
|
<AdminField
|
||||||
id="site-wordmark"
|
id="site-wordmark"
|
||||||
label="字标"
|
label="字标"
|
||||||
embed
|
className="admin-identity-field"
|
||||||
className="admin-settings-control-text"
|
|
||||||
maxLength={BRAND_LIMITS.wordmark}
|
maxLength={BRAND_LIMITS.wordmark}
|
||||||
value={wordmark}
|
value={wordmark}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setWordmark(v);
|
setWordmark(v);
|
||||||
if (saveError) setSaveError("");
|
clearSaveError();
|
||||||
}}
|
}}
|
||||||
placeholder="姜十三"
|
placeholder="姜十三"
|
||||||
/>
|
/>
|
||||||
</AdminSettingsRow>
|
</div>
|
||||||
<AdminSettingsRow
|
|
||||||
label="标语"
|
|
||||||
htmlFor="site-slogan"
|
|
||||||
hintBelow={
|
|
||||||
<>
|
|
||||||
出现在浏览器标题和页眉副标题。预览:
|
|
||||||
<span style={{ color: "var(--ink)" }}>{preview}</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AdminField
|
<AdminField
|
||||||
id="site-slogan"
|
id="site-slogan"
|
||||||
label="标语"
|
label="标语"
|
||||||
embed
|
className="admin-identity-field"
|
||||||
className="admin-settings-control-text"
|
|
||||||
maxLength={BRAND_LIMITS.slogan}
|
maxLength={BRAND_LIMITS.slogan}
|
||||||
value={slogan}
|
value={slogan}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setSlogan(v);
|
setSlogan(v);
|
||||||
if (saveError) setSaveError("");
|
clearSaveError();
|
||||||
}}
|
}}
|
||||||
placeholder="技术分享与讨论"
|
placeholder="技术分享与讨论"
|
||||||
/>
|
/>
|
||||||
</AdminSettingsRow>
|
<p className="admin-identity-title-preview" aria-live="polite">
|
||||||
<AdminSettingsRow label="简介" htmlFor="site-desc" hintBelow="用于页面摘要与搜索描述" wide>
|
浏览器标题预览 · <strong>{preview}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
<AdminField
|
<AdminField
|
||||||
id="site-desc"
|
id="site-desc"
|
||||||
label="简介"
|
label="简介"
|
||||||
embed
|
|
||||||
as="textarea"
|
as="textarea"
|
||||||
className="admin-settings-control-long"
|
className="admin-identity-field"
|
||||||
minHeight="96px"
|
minHeight="72px"
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
value={desc}
|
value={desc}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setDesc(v);
|
setDesc(v);
|
||||||
if (saveError) setSaveError("");
|
clearSaveError();
|
||||||
}}
|
}}
|
||||||
placeholder="一句话介绍社区定位…"
|
placeholder="一句话介绍社区…"
|
||||||
/>
|
/>
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow
|
<div className="admin-identity-keywords">
|
||||||
label="SEO 关键词"
|
<div className="admin-identity-keywords-head">
|
||||||
htmlFor="seo-keyword-input"
|
<label htmlFor="seo-keyword-input" className="field-label">
|
||||||
hintBelow={`用于搜索结果。最多 ${BRAND_LIMITS.keywordCount} 个,每个 ${BRAND_LIMITS.keywordRunes} 字。点击已添加的词可删除。`}
|
关键词
|
||||||
wide
|
</label>
|
||||||
>
|
<span className="meta text-[12px]">
|
||||||
<div className="admin-settings-control-long">
|
{keywords.length}/{BRAND_LIMITS.keywordCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-identity-keywords-box">
|
||||||
|
{keywords.length > 0 ? (
|
||||||
<div className="admin-settings-keywords">
|
<div className="admin-settings-keywords">
|
||||||
{keywords.map((k) => (
|
{keywords.map((k) => (
|
||||||
<button
|
<button
|
||||||
@@ -241,16 +249,16 @@ export default function BasicIdentityPanel({
|
|||||||
className="admin-settings-kw"
|
className="admin-settings-kw"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setKeywords((prev) => prev.filter((x) => x !== k));
|
setKeywords((prev) => prev.filter((x) => x !== k));
|
||||||
if (saveError) setSaveError("");
|
clearSaveError();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{k}
|
{k}
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
) : null}
|
||||||
|
<div className="admin-identity-keywords-add">
|
||||||
<div className="admin-field-shell flex-1 min-w-0">
|
<div className="admin-field-shell flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
id="seo-keyword-input"
|
id="seo-keyword-input"
|
||||||
@@ -262,17 +270,22 @@ export default function BasicIdentityPanel({
|
|||||||
addKeyword(kwInput);
|
addKeyword(kwInput);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入后回车添加"
|
placeholder="回车添加"
|
||||||
className="admin-field-input"
|
className="admin-field-input"
|
||||||
aria-label="添加关键词"
|
aria-label="添加关键词"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => addKeyword(kwInput)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm shrink-0"
|
||||||
|
onClick={() => addKeyword(kwInput)}
|
||||||
|
>
|
||||||
添加
|
添加
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
</div>
|
||||||
|
</div>
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
frontend/app/admin/settings/BasicSettings.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
|
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||||
|
import BrandSeoPanel from "./BrandSeoPanel";
|
||||||
|
import FooterLinksPanel from "./FooterLinksPanel";
|
||||||
|
import type { PublicSettings } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function BasicSettings({ initial }: { initial: PublicSettings }) {
|
||||||
|
const [live, setLive] = useState(initial);
|
||||||
|
const [basicDirty, setBasicDirty] = useState(false);
|
||||||
|
const [brandDirty, setBrandDirty] = useState(false);
|
||||||
|
const [seoDirty, setSeoDirty] = useState(false);
|
||||||
|
const dirty = basicDirty || brandDirty || seoDirty;
|
||||||
|
const leave = useUnsavedGuard(dirty);
|
||||||
|
|
||||||
|
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
||||||
|
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
||||||
|
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-settings-stack">
|
||||||
|
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
||||||
|
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
||||||
|
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={leave.leaveOpen}
|
||||||
|
title="有未保存的修改"
|
||||||
|
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
||||||
|
confirmLabel="离开"
|
||||||
|
danger
|
||||||
|
onCancel={leave.cancelLeave}
|
||||||
|
onConfirm={leave.confirmLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminSettingsActions,
|
AdminSettingsActions,
|
||||||
AdminSettingsGroup,
|
AdminSettingsGroup,
|
||||||
AdminSettingsRow,
|
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
import BrandLockup from "@/components/BrandLockup";
|
import BrandLockup from "@/components/BrandLockup";
|
||||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||||
@@ -44,10 +43,10 @@ function slotDirty(d: SlotDraft, saved: string): boolean {
|
|||||||
return d.committed !== saved;
|
return d.committed !== saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SLOTS: { id: BrandSlot; label: string; hint: string; swatch: "light" | "dark" | "neutral" }[] = [
|
const SLOTS: { id: BrandSlot; label: string; swatch: "light" | "dark" | "neutral"; compact?: boolean }[] = [
|
||||||
{ id: "logo_light", label: "浅色主题 Logo", hint: "浅色主题页眉。只传一套时,暗色共用这张。", swatch: "light" },
|
{ id: "logo_light", label: "浅色 Logo", swatch: "light" },
|
||||||
{ id: "logo_dark", label: "深色主题 Logo", hint: "深色主题页眉。PNG / WebP / SVG,不超过 2MB。", swatch: "dark" },
|
{ id: "logo_dark", label: "深色 Logo", swatch: "dark" },
|
||||||
{ id: "favicon", label: "Favicon", hint: "浏览器标签图标。另支持 ICO,不超过 512KB。", swatch: "neutral" },
|
{ id: "favicon", label: "Favicon", swatch: "neutral", compact: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BrandSeoPanel({
|
export default function BrandSeoPanel({
|
||||||
@@ -116,13 +115,8 @@ export default function BrandSeoPanel({
|
|||||||
}, [dirty, onDirtyChange]);
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => {
|
const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => {
|
||||||
if (draft.file) {
|
if (draft.file) return apiUploadBrand(slot, draft.file);
|
||||||
const url = await apiUploadBrand(slot, draft.file);
|
if (draft.mediaId != null) return apiBrandFromMedia(slot, draft.mediaId);
|
||||||
return url;
|
|
||||||
}
|
|
||||||
if (draft.mediaId != null) {
|
|
||||||
return apiBrandFromMedia(slot, draft.mediaId);
|
|
||||||
}
|
|
||||||
return draft.committed;
|
return draft.committed;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,11 +136,7 @@ export default function BrandSeoPanel({
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (saving || !dirty) return;
|
if (saving || !dirty) return;
|
||||||
const ticket = ++saveGen.current;
|
const ticket = ++saveGen.current;
|
||||||
const snap = {
|
const snap = { mark, size: logoSize, fit: logoFit };
|
||||||
mark,
|
|
||||||
size: logoSize,
|
|
||||||
fit: logoFit,
|
|
||||||
};
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError("");
|
setSaveError("");
|
||||||
try {
|
try {
|
||||||
@@ -188,14 +178,18 @@ export default function BrandSeoPanel({
|
|||||||
return cur;
|
return cur;
|
||||||
});
|
});
|
||||||
setFavicon((cur) => {
|
setFavicon((cur) => {
|
||||||
if (cur.file === favicon.file && cur.mediaId === favicon.mediaId && cur.committed === favicon.committed) {
|
if (
|
||||||
|
cur.file === favicon.file &&
|
||||||
|
cur.mediaId === favicon.mediaId &&
|
||||||
|
cur.committed === favicon.committed
|
||||||
|
) {
|
||||||
if (cur.blob) URL.revokeObjectURL(cur.blob);
|
if (cur.blob) URL.revokeObjectURL(cur.blob);
|
||||||
return emptyDraft(res.favicon_url);
|
return emptyDraft(res.favicon_url);
|
||||||
}
|
}
|
||||||
return cur;
|
return cur;
|
||||||
});
|
});
|
||||||
onSaved?.(res);
|
onSaved?.(res);
|
||||||
toast("品牌设置已保存", "ok");
|
toast("已保存", "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (ticket !== saveGen.current) return;
|
if (ticket !== saveGen.current) return;
|
||||||
const msg = e instanceof Error ? e.message : "保存失败";
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
@@ -206,7 +200,11 @@ export default function BrandSeoPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSlot = (slot: BrandSlot, label: string, hint: string, swatch: "light" | "dark" | "neutral", draft: SlotDraft) => {
|
const slotDraft = (id: BrandSlot) =>
|
||||||
|
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
||||||
|
|
||||||
|
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
||||||
|
const draft = slotDraft(slot);
|
||||||
const preview = previewOf(draft);
|
const preview = previewOf(draft);
|
||||||
const swatchClass =
|
const swatchClass =
|
||||||
swatch === "light"
|
swatch === "light"
|
||||||
@@ -215,20 +213,18 @@ export default function BrandSeoPanel({
|
|||||||
? "admin-brand-swatch-dark"
|
? "admin-brand-swatch-dark"
|
||||||
: "admin-brand-swatch-neutral";
|
: "admin-brand-swatch-neutral";
|
||||||
return (
|
return (
|
||||||
<div key={slot} className="admin-brand-slot-card">
|
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
||||||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p className="meta text-[12px] mt-0.5 mb-2">{hint}</p>
|
|
||||||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={preview} alt={`${label}预览`} />
|
<img src={preview} alt="" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[12.5px]">未设置</span>
|
<span>未设置</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
<div className="admin-brand-asset-meta">
|
||||||
|
<span className="admin-brand-asset-label">{label}</span>
|
||||||
|
<div className="admin-brand-asset-actions">
|
||||||
<label className="btn btn-line btn-sm cursor-pointer">
|
<label className="btn btn-line btn-sm cursor-pointer">
|
||||||
<ImagePlus size={14} /> 上传
|
<ImagePlus size={14} /> 上传
|
||||||
<input
|
<input
|
||||||
@@ -250,41 +246,72 @@ export default function BrandSeoPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn btn-line btn-sm" disabled={saving} onClick={() => setPicker(slot)}>
|
|
||||||
<Images size={14} /> 媒体库
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line btn-sm"
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setPicker(slot)}
|
||||||
|
>
|
||||||
|
<Images size={14} /> 库
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label={`清除${label}`}
|
||||||
disabled={saving || !preview}
|
disabled={saving || !preview}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||||||
drafts[slot][1](emptyDraft(""));
|
drafts[slot][1](emptyDraft(""));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X size={14} /> 清除
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSettingsGroup
|
<AdminSettingsGroup
|
||||||
title="品牌与图标"
|
id="settings-brand"
|
||||||
description="预览随编辑更新,保存后对访客生效。"
|
title="品牌"
|
||||||
footer={
|
footer={
|
||||||
<AdminSettingsActions
|
<AdminSettingsActions
|
||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveLabel="保存品牌设置"
|
saveLabel="保存"
|
||||||
error={saveError}
|
error={saveError}
|
||||||
onSave={() => void save()}
|
onSave={() => void save()}
|
||||||
onDiscard={discard}
|
onDiscard={discard}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AdminSettingsRow label="页眉形式" hintBelow="图加文字和纯图共用画幅与铺法">
|
<div className="admin-brand-studio">
|
||||||
|
<div className="admin-brand-studio-preview" aria-label="页眉预览">
|
||||||
|
<div className="admin-brand-studio-chrome">
|
||||||
|
<span aria-hidden />
|
||||||
|
<span aria-hidden />
|
||||||
|
<span aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="admin-brand-lockup-preview-bar">
|
||||||
|
<BrandLockup
|
||||||
|
mode={mark}
|
||||||
|
size={logoSize}
|
||||||
|
fit={logoFit}
|
||||||
|
frame
|
||||||
|
name={initial.site_name}
|
||||||
|
wordmark={initial.site_wordmark}
|
||||||
|
slogan={initial.site_slogan}
|
||||||
|
logoLight={previewOf(light)}
|
||||||
|
logoDark={previewOf(dark)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-brand-studio-toolbar">
|
||||||
|
<div className="admin-brand-studio-field">
|
||||||
|
<span className="admin-brand-studio-kicker">形式</span>
|
||||||
<AdminSegmented
|
<AdminSegmented
|
||||||
role="group"
|
role="group"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -300,71 +327,35 @@ export default function BrandSeoPanel({
|
|||||||
{ key: "text", label: "纯文字" },
|
{ key: "text", label: "纯文字" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow
|
|
||||||
label="品牌预览"
|
|
||||||
hintBelow={dirty ? "页眉效果预览;站点仍显示已保存版本,保存后对访客生效" : "页眉效果预览,保存后对访客生效"}
|
|
||||||
>
|
|
||||||
<div className="admin-brand-lockup-preview">
|
|
||||||
<div className="admin-brand-lockup-preview-bar">
|
|
||||||
<BrandLockup
|
|
||||||
mode={mark}
|
|
||||||
size={logoSize}
|
|
||||||
fit={logoFit}
|
|
||||||
frame
|
|
||||||
name={initial.site_name}
|
|
||||||
wordmark={initial.site_wordmark}
|
|
||||||
slogan={initial.site_slogan}
|
|
||||||
logoLight={previewOf(light)}
|
|
||||||
logoDark={previewOf(dark)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
{mark !== "text" ? (
|
{mark !== "text" ? (
|
||||||
<>
|
<>
|
||||||
<AdminSettingsRow label="Logo 比例">
|
<div className="admin-brand-studio-field">
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Logo 比例">
|
<span className="admin-brand-studio-kicker">比例</span>
|
||||||
|
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
|
||||||
{BRAND_LOGO_SIZES.map((item) => {
|
{BRAND_LOGO_SIZES.map((item) => {
|
||||||
const on = logoSize === item.id;
|
const on = logoSize === item.id;
|
||||||
const thumbH = 16;
|
|
||||||
const thumbW = Math.round((item.w / item.h) * thumbH);
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl px-3 py-2 text-left inline-flex items-center gap-2"
|
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
||||||
style={{
|
|
||||||
background: on ? "var(--accent-soft)" : "var(--panel)",
|
|
||||||
color: on ? "var(--accent)" : "var(--ink)",
|
|
||||||
boxShadow: on ? "inset 0 0 0 1px var(--accent)" : "inset 0 0 0 1px var(--line)",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLogoSize(item.id);
|
setLogoSize(item.id);
|
||||||
if (saveError) setSaveError("");
|
if (saveError) setSaveError("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span className="admin-brand-size-chip-label">{item.label}</span>
|
||||||
aria-hidden
|
<span className="admin-brand-size-chip-px">
|
||||||
className="shrink-0 rounded-sm"
|
|
||||||
style={{
|
|
||||||
width: thumbW,
|
|
||||||
height: thumbH,
|
|
||||||
boxShadow: "inset 0 0 0 1.5px currentColor",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-[13px] font-medium">{item.label}</span>
|
|
||||||
<span className="meta text-[12px]">
|
|
||||||
{item.w}×{item.h}
|
{item.w}×{item.h}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
</div>
|
||||||
<AdminSettingsRow label="适配方式" hintBelow="图片如何铺进所选画幅">
|
<div className="admin-brand-studio-field">
|
||||||
|
<span className="admin-brand-studio-kicker">铺法</span>
|
||||||
<AdminSegmented
|
<AdminSegmented
|
||||||
role="group"
|
role="group"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -376,26 +367,16 @@ export default function BrandSeoPanel({
|
|||||||
}}
|
}}
|
||||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||||
/>
|
/>
|
||||||
</AdminSettingsRow>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<AdminSettingsRow label="画幅与铺法">
|
|
||||||
<p className="meta text-[12.5px]">纯文字页眉不显示 Logo。画幅和铺法只作用于图加文字和纯图。</p>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
)}
|
|
||||||
<AdminSettingsRow label="站点图标" hintBelow="设置不同主题的站点标识及浏览器图标" wide>
|
|
||||||
<div className="admin-brand-slots">
|
|
||||||
{SLOTS.map((s) =>
|
|
||||||
renderSlot(
|
|
||||||
s.id,
|
|
||||||
s.label,
|
|
||||||
s.hint,
|
|
||||||
s.swatch,
|
|
||||||
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-brand-studio-assets" aria-label="品牌素材">
|
||||||
|
{SLOTS.map((s) => renderSlot(s.id, s.label, s.swatch, s.compact))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BrandMediaPicker
|
<BrandMediaPicker
|
||||||
open={picker != null}
|
open={picker != null}
|
||||||
slot={picker || "logo_light"}
|
slot={picker || "logo_light"}
|
||||||
|
|||||||
382
frontend/app/admin/settings/FilterRules.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Download, Plus, Upload } from "lucide-react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
|
export type Rule = {
|
||||||
|
id: string;
|
||||||
|
word: string;
|
||||||
|
scopes: string[];
|
||||||
|
action: string;
|
||||||
|
enabled: boolean;
|
||||||
|
exceptions: string[];
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopes = [
|
||||||
|
["username", "用户名"],
|
||||||
|
["title", "帖子标题"],
|
||||||
|
["body", "正文"],
|
||||||
|
["comment", "评论"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const empty = (): Rule => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
word: "",
|
||||||
|
scopes: ["title", "body", "comment"],
|
||||||
|
action: "block",
|
||||||
|
enabled: true,
|
||||||
|
exceptions: [],
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function FilterRules({
|
||||||
|
rules,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
rules: Rule[];
|
||||||
|
onChange: (rules: Rule[]) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [editing, setEditing] = useState<Rule | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [importText, setImportText] = useState("");
|
||||||
|
const [replace, setReplace] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const filtered = rules.filter((r) => r.word.includes(query) || r.note.includes(query));
|
||||||
|
const pageCount = Math.max(1, Math.ceil(filtered.length / 12));
|
||||||
|
const pageItems = filtered.slice((page - 1) * 12, page * 12);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const known = new Set(rules.map((r) => r.word.normalize("NFKC").toLowerCase()));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const added: string[] = [];
|
||||||
|
let duplicate = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
for (const line of importText.split(/\r?\n/)) {
|
||||||
|
const word = line.trim();
|
||||||
|
if (!word) continue;
|
||||||
|
if ([...word].length > 80) {
|
||||||
|
invalid++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const n = word.normalize("NFKC").toLowerCase();
|
||||||
|
if (seen.has(n) || (!replace && known.has(n))) {
|
||||||
|
duplicate++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(n);
|
||||||
|
added.push(word);
|
||||||
|
}
|
||||||
|
return { added, duplicate, invalid };
|
||||||
|
}, [importText, rules, replace]);
|
||||||
|
|
||||||
|
const doImport = () => {
|
||||||
|
const next = preview.added.map((word) => ({ ...empty(), word }));
|
||||||
|
if ((replace ? 0 : rules.length) + next.length > 2000) {
|
||||||
|
setError("最多 2000 条规则");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(replace ? next : [...rules, ...next]);
|
||||||
|
setImportText("");
|
||||||
|
setConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportRules = () => {
|
||||||
|
const blob = new Blob([rules.map((r) => r.word).join("\n")], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "content-filter.txt";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = (on: boolean) => {
|
||||||
|
setSelected(on ? pageItems.map((r) => r.id) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-filter">
|
||||||
|
<div className="ops-filter-toolbar">
|
||||||
|
<input
|
||||||
|
className="field ops-input"
|
||||||
|
aria-label="搜索规则"
|
||||||
|
placeholder="搜索词语或备注"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="meta text-[12.5px] whitespace-nowrap">
|
||||||
|
{rules.length}/2000
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(empty())}>
|
||||||
|
<Plus size={14} /> 添加
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={exportRules}>
|
||||||
|
<Download size={14} /> 导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="ops-filter-bulk">
|
||||||
|
<span className="meta text-[12.5px]">已选 {selected.length}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => onChange(rules.map((r) => (selected.includes(r.id) ? { ...r, enabled: true } : r)))}
|
||||||
|
>
|
||||||
|
启用
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => onChange(rules.map((r) => (selected.includes(r.id) ? { ...r, enabled: false } : r)))}
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ops-text-btn" onClick={() => setSelected([])}>
|
||||||
|
取消选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ops-filter-table" role="table" aria-label="过滤规则">
|
||||||
|
<div className="ops-filter-head" role="row">
|
||||||
|
<label className="ops-filter-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pageItems.length > 0 && pageItems.every((r) => selected.includes(r.id))}
|
||||||
|
onChange={(e) => toggleSelectAll(e.target.checked)}
|
||||||
|
aria-label="全选本页"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span>词语</span>
|
||||||
|
<span>范围</span>
|
||||||
|
<span>处理</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{pageItems.map((r) => (
|
||||||
|
<div className="ops-filter-row" role="row" key={r.id}>
|
||||||
|
<label className="ops-filter-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(r.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelected((a) => (e.target.checked ? [...a, r.id] : a.filter((id) => id !== r.id)))
|
||||||
|
}
|
||||||
|
aria-label={`选择 ${r.word}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<strong className="ops-filter-word">{r.word}</strong>
|
||||||
|
<span className="meta text-[12.5px]">
|
||||||
|
{r.scopes.map((s) => scopes.find(([k]) => k === s)?.[1]).filter(Boolean).join("、")}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[12.5px]">{r.action === "block" ? "拦截" : "仅记录"}</span>
|
||||||
|
<span className={`ops-filter-state ${r.enabled ? "is-on" : ""}`}>{r.enabled ? "启用" : "停用"}</span>
|
||||||
|
<div className="ops-filter-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setEditing({ ...r, scopes: [...r.scopes], exceptions: [...r.exceptions] })}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-text-btn"
|
||||||
|
onClick={() => onChange(rules.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))}
|
||||||
|
>
|
||||||
|
{r.enabled ? "停用" : "启用"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!filtered.length && <p className="ops-hint ops-filter-empty">尚无规则</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageCount > 1 && (
|
||||||
|
<div className="ops-filter-pager">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="meta text-[12.5px]">
|
||||||
|
{page} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="ops-advanced ops-filter-import">
|
||||||
|
<summary>
|
||||||
|
<Upload size={14} aria-hidden /> 从 TXT 导入
|
||||||
|
</summary>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt,text/plain"
|
||||||
|
aria-label="导入 TXT"
|
||||||
|
className="mt-3"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 300000) {
|
||||||
|
setError("TXT 文件上限 300 KB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportText(await file.text());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
className="field mt-3 ops-input"
|
||||||
|
aria-label="导入词语"
|
||||||
|
value={importText}
|
||||||
|
onChange={(e) => setImportText(e.target.value)}
|
||||||
|
placeholder="每行一个词语"
|
||||||
|
/>
|
||||||
|
<label className="ops-filter-import-opt">
|
||||||
|
<input type="checkbox" checked={replace} onChange={(e) => setReplace(e.target.checked)} />
|
||||||
|
覆盖全部规则(默认合并)
|
||||||
|
</label>
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
新增 {preview.added.length} · 重复 {preview.duplicate} · 无效 {preview.invalid}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={!preview.added.length}
|
||||||
|
onClick={() => (replace ? setConfirm(true) : doImport())}
|
||||||
|
>
|
||||||
|
应用到表单
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="alert-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<Modal open title="编辑过滤规则" onClose={() => setEditing(null)} maxWidthClass="max-w-xl">
|
||||||
|
<div className="ops-filter-editor">
|
||||||
|
<label>
|
||||||
|
词语
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="field"
|
||||||
|
maxLength={80}
|
||||||
|
value={editing.word}
|
||||||
|
onChange={(e) => setEditing({ ...editing, word: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<fieldset>
|
||||||
|
<legend>适用范围</legend>
|
||||||
|
{scopes.map(([k, l]) => (
|
||||||
|
<label key={k} className="mr-3 inline-flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.scopes.includes(k)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
scopes: e.target.checked
|
||||||
|
? [...editing.scopes, k]
|
||||||
|
: editing.scopes.filter((s) => s !== k),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{l}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<label>
|
||||||
|
命中处理
|
||||||
|
<select
|
||||||
|
className="field ops-select"
|
||||||
|
value={editing.action}
|
||||||
|
onChange={(e) => setEditing({ ...editing, action: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="block">拦截提交</option>
|
||||||
|
<option value="log">仅记录</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
例外短语(每行一个)
|
||||||
|
<textarea
|
||||||
|
className="field"
|
||||||
|
rows={3}
|
||||||
|
value={editing.exceptions.join("\n")}
|
||||||
|
onChange={(e) => setEditing({ ...editing, exceptions: e.target.value.split("\n") })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
备注
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
value={editing.note}
|
||||||
|
onChange={(e) => setEditing({ ...editing, note: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.enabled}
|
||||||
|
onChange={(e) => setEditing({ ...editing, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
<div className="ops-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!editing.word.trim() || !editing.scopes.length}
|
||||||
|
onClick={() => {
|
||||||
|
const value = {
|
||||||
|
...editing,
|
||||||
|
word: editing.word.trim(),
|
||||||
|
exceptions: editing.exceptions.map((s) => s.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
onChange(
|
||||||
|
rules.some((r) => r.id === value.id)
|
||||||
|
? rules.map((r) => (r.id === value.id ? value : r))
|
||||||
|
: [...rules, value]
|
||||||
|
);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
应用到表单
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirm}
|
||||||
|
title="覆盖当前词库"
|
||||||
|
message={`将用 ${preview.added.length} 条导入规则替换现有 ${rules.length} 条;保存本页后生效。`}
|
||||||
|
onConfirm={doImport}
|
||||||
|
onCancel={() => setConfirm(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
import { ArrowDown, ArrowUp, ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
BRAND_LIMITS,
|
BRAND_LIMITS,
|
||||||
|
FOOTER_LINKS_ALIGNS,
|
||||||
footerURLAllowed,
|
footerURLAllowed,
|
||||||
isExternalFooterURL,
|
isExternalFooterURL,
|
||||||
type FooterLink,
|
type FooterLink,
|
||||||
|
type FooterLinksAlign,
|
||||||
} from "@/lib/brand";
|
} from "@/lib/brand";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
@@ -16,6 +18,10 @@ function clone(links: FooterLink[]): FooterLink[] {
|
|||||||
return links.map((l) => ({ ...l }));
|
return links.map((l) => ({ ...l }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zoneOf(links: FooterLink[], align: FooterLinksAlign): FooterLink[] {
|
||||||
|
return links.filter((l) => (l.align || "right") === align && l.label.trim() && l.url.trim());
|
||||||
|
}
|
||||||
|
|
||||||
export default function FooterLinksPanel({
|
export default function FooterLinksPanel({
|
||||||
initial,
|
initial,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -32,6 +38,10 @@ export default function FooterLinksPanel({
|
|||||||
const saveGen = useRef(0);
|
const saveGen = useRef(0);
|
||||||
|
|
||||||
const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
|
const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
|
||||||
|
const left = zoneOf(links, "left");
|
||||||
|
const center = zoneOf(links, "center");
|
||||||
|
const right = zoneOf(links, "right");
|
||||||
|
const hasPreview = left.length + center.length + right.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(dirty);
|
onDirtyChange?.(dirty);
|
||||||
@@ -66,6 +76,7 @@ export default function FooterLinksPanel({
|
|||||||
label: item.label.trim(),
|
label: item.label.trim(),
|
||||||
url: item.url.trim(),
|
url: item.url.trim(),
|
||||||
new_tab: item.new_tab,
|
new_tab: item.new_tab,
|
||||||
|
align: item.align || "right",
|
||||||
}));
|
}));
|
||||||
const ticket = ++saveGen.current;
|
const ticket = ++saveGen.current;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -77,7 +88,7 @@ export default function FooterLinksPanel({
|
|||||||
setSavedLinks(clone(res.footer_links));
|
setSavedLinks(clone(res.footer_links));
|
||||||
setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
|
setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
|
||||||
onSaved?.(res);
|
onSaved?.(res);
|
||||||
toast("页脚已保存", "ok");
|
toast("已保存", "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (ticket !== saveGen.current) return;
|
if (ticket !== saveGen.current) return;
|
||||||
const msg = e instanceof Error ? e.message : "保存失败";
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
@@ -99,33 +110,150 @@ export default function FooterLinksPanel({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderPreviewLinks = (items: FooterLink[]) =>
|
||||||
|
items.map((item) => (
|
||||||
|
<span key={`${item.align}-${item.label}-${item.url}`} className="admin-footer-preview-link">
|
||||||
|
{item.label.trim()}
|
||||||
|
{item.new_tab ? <ExternalLink size={11} aria-hidden /> : null}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSettingsGroup
|
<AdminSettingsGroup
|
||||||
title="页脚"
|
id="settings-footer"
|
||||||
description="备案、协议等链接。保存后出现在站点页脚右侧。"
|
title="页脚链接"
|
||||||
footer={
|
footer={
|
||||||
<AdminSettingsActions
|
<AdminSettingsActions
|
||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveLabel="保存页脚"
|
saveLabel="保存"
|
||||||
error={error}
|
error={error}
|
||||||
onSave={() => void save()}
|
onSave={() => void save()}
|
||||||
onDiscard={discard}
|
onDiscard={discard}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AdminSettingsRow
|
<div className="admin-footer-studio">
|
||||||
label="页脚链接"
|
<div className="admin-footer-preview" aria-label="页脚预览">
|
||||||
hintBelow="用于 ICP 备案、用户协议等,最多 8 条。外链默认新标签打开。"
|
<div className="admin-footer-preview-zone admin-footer-preview-zone-left">
|
||||||
wide
|
<span className="admin-footer-preview-copy">
|
||||||
|
© {new Date().getFullYear()} {initial.site_name}
|
||||||
|
</span>
|
||||||
|
{left.length > 0 ? <nav className="admin-footer-preview-links">{renderPreviewLinks(left)}</nav> : null}
|
||||||
|
</div>
|
||||||
|
<div className="admin-footer-preview-zone admin-footer-preview-zone-center">
|
||||||
|
{center.length > 0 ? (
|
||||||
|
<nav className="admin-footer-preview-links">{renderPreviewLinks(center)}</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="admin-footer-preview-zone admin-footer-preview-zone-right">
|
||||||
|
{right.length > 0 ? (
|
||||||
|
<nav className="admin-footer-preview-links">{renderPreviewLinks(right)}</nav>
|
||||||
|
) : !hasPreview ? (
|
||||||
|
<span className="admin-footer-preview-empty meta text-[12px]">暂无链接</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-footer-table" role="table" aria-label="页脚链接列表">
|
||||||
|
<div className="admin-footer-table-head" role="row">
|
||||||
|
<span className="admin-footer-col-idx" aria-hidden>
|
||||||
|
#
|
||||||
|
</span>
|
||||||
|
<span>文案</span>
|
||||||
|
<span>地址</span>
|
||||||
|
<span className="admin-footer-col-align">位置</span>
|
||||||
|
<span className="admin-footer-col-open">打开方式</span>
|
||||||
|
<span className="admin-footer-col-ops">
|
||||||
|
<span className="sr-only">操作</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{links.length === 0 ? (
|
||||||
|
<p className="admin-footer-empty">暂无链接,可添加备案号、用户协议等</p>
|
||||||
|
) : (
|
||||||
|
links.map((item, i) => (
|
||||||
|
<div key={i} className="admin-footer-table-row" role="row">
|
||||||
|
<span className="admin-footer-col-idx meta">{i + 1}</span>
|
||||||
|
<div className="admin-field-shell admin-footer-cell-label">
|
||||||
|
<input
|
||||||
|
id={`footer-label-${i}`}
|
||||||
|
className="admin-field-input"
|
||||||
|
maxLength={BRAND_LIMITS.footerLabel}
|
||||||
|
value={item.label}
|
||||||
|
onChange={(e) => update(i, { label: e.target.value })}
|
||||||
|
placeholder="如:京 ICP 备…"
|
||||||
|
aria-label={`第 ${i + 1} 条文案`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-shell admin-footer-cell-url">
|
||||||
|
<input
|
||||||
|
id={`footer-url-${i}`}
|
||||||
|
className="admin-field-input"
|
||||||
|
maxLength={BRAND_LIMITS.footerURL}
|
||||||
|
value={item.url}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
const wasExt = isExternalFooterURL(item.url);
|
||||||
|
const nowExt = isExternalFooterURL(v);
|
||||||
|
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
||||||
|
}}
|
||||||
|
placeholder="https://… 或 /about"
|
||||||
|
aria-label={`第 ${i + 1} 条地址`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-footer-col-align">
|
||||||
|
<div
|
||||||
|
className="admin-footer-align-toggle"
|
||||||
|
role="group"
|
||||||
|
aria-label={`第 ${i + 1} 条位置`}
|
||||||
|
>
|
||||||
|
{FOOTER_LINKS_ALIGNS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
className={`admin-footer-align-opt${(item.align || "right") === opt.id ? " is-on" : ""}`}
|
||||||
|
aria-pressed={(item.align || "right") === opt.id}
|
||||||
|
onClick={() => update(i, { align: opt.id })}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-footer-col-open">
|
||||||
|
<div
|
||||||
|
className="admin-footer-open-toggle"
|
||||||
|
role="group"
|
||||||
|
aria-label={`第 ${i + 1} 条打开方式`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`admin-footer-open-opt${!item.new_tab ? " is-on" : ""}`}
|
||||||
|
aria-pressed={!item.new_tab}
|
||||||
|
onClick={() => update(i, { new_tab: false })}
|
||||||
|
>
|
||||||
|
本页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
|
||||||
|
aria-pressed={item.new_tab}
|
||||||
|
onClick={() => update(i, { new_tab: true })}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} aria-hidden />
|
||||||
|
新标签
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-footer-col-ops">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label="上移"
|
||||||
|
disabled={i === 0}
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
>
|
>
|
||||||
<div className="admin-settings-control-long flex flex-col gap-3">
|
|
||||||
{links.map((item, i) => (
|
|
||||||
<div key={i} className="admin-footer-item">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="meta text-[12px]">第 {i + 1} 条</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button type="button" className="admin-icon-btn" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>
|
|
||||||
<ArrowUp size={14} />
|
<ArrowUp size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -147,69 +275,24 @@ export default function FooterLinksPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AdminField
|
))
|
||||||
id={`footer-label-${i}`}
|
)}
|
||||||
label="文案"
|
|
||||||
maxLength={BRAND_LIMITS.footerLabel}
|
|
||||||
value={item.label}
|
|
||||||
onChange={(v) => update(i, { label: v })}
|
|
||||||
placeholder="京 ICP 备xxxxxxxx号"
|
|
||||||
/>
|
|
||||||
<AdminField
|
|
||||||
id={`footer-url-${i}`}
|
|
||||||
label="地址"
|
|
||||||
maxLength={BRAND_LIMITS.footerURL}
|
|
||||||
value={item.url}
|
|
||||||
onChange={(v) => {
|
|
||||||
const wasExt = isExternalFooterURL(item.url);
|
|
||||||
const nowExt = isExternalFooterURL(v);
|
|
||||||
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
|
||||||
}}
|
|
||||||
placeholder="https://beian.miit.gov.cn/ 或 /about"
|
|
||||||
/>
|
|
||||||
<label className="flex items-center gap-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={item.new_tab}
|
|
||||||
onChange={(e) => update(i, { new_tab: e.target.checked })}
|
|
||||||
/>
|
|
||||||
新标签打开
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line btn-sm self-start"
|
className="btn btn-line btn-sm self-start"
|
||||||
disabled={links.length >= BRAND_LIMITS.footerLinks}
|
disabled={links.length >= BRAND_LIMITS.footerLinks}
|
||||||
onClick={() => setLinks((prev) => [...prev, { label: "", url: "", new_tab: false }])}
|
onClick={() =>
|
||||||
|
setLinks((prev) => [...prev, { label: "", url: "", new_tab: false, align: "right" }])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Plus size={14} /> 新增链接
|
<Plus size={14} /> 新增链接
|
||||||
|
<span className="meta text-[12px] ml-1">
|
||||||
|
{links.length}/{BRAND_LIMITS.footerLinks}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
|
||||||
|
|
||||||
<AdminSettingsRow label="页脚预览" hintBelow="当前编辑预览,保存后页脚才会更新">
|
|
||||||
<div className="admin-footer-preview">
|
|
||||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
|
|
||||||
<span className="meta text-[12px] whitespace-nowrap">
|
|
||||||
© {new Date().getFullYear()} {initial.site_name}
|
|
||||||
</span>
|
|
||||||
<span className="meta text-[12px] whitespace-nowrap">版本:— · 页面:— · 模板:—</span>
|
|
||||||
</div>
|
|
||||||
{links.filter((l) => l.label.trim() && l.url.trim()).length > 0 ? (
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
|
|
||||||
{links
|
|
||||||
.filter((l) => l.label.trim() && l.url.trim())
|
|
||||||
.map((item) => (
|
|
||||||
<span key={`${item.label}-${item.url}`} className="text-[12px] break-all" style={{ color: "var(--ink-2)" }}>
|
|
||||||
{item.label.trim()}
|
|
||||||
{item.new_tab ? " ↗" : ""}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
frontend/app/admin/settings/ModuleSettings.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
|
import OperationsPanel, { type ModuleName } from "./OperationsPanel";
|
||||||
|
|
||||||
|
/** 运营模块页:保存单元离开保护 */
|
||||||
|
export default function ModuleSettings({ name }: { name: Exclude<ModuleName, "security"> }) {
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const leave = useUnsavedGuard(dirty);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-settings-stack">
|
||||||
|
<OperationsPanel name={name} onDirtyChange={setDirty} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={leave.leaveOpen}
|
||||||
|
title="有未保存的修改"
|
||||||
|
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
||||||
|
confirmLabel="离开"
|
||||||
|
danger
|
||||||
|
onCancel={leave.cancelLeave}
|
||||||
|
onConfirm={leave.confirmLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1075
frontend/app/admin/settings/OperationsPanel.tsx
Normal file
@@ -1,864 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
GitBranch,
|
|
||||||
Globe,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Loader2,
|
|
||||||
MessageSquare,
|
|
||||||
MessagesSquare,
|
|
||||||
Paperclip,
|
|
||||||
RotateCcw,
|
|
||||||
UserPlus,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
|
||||||
import {
|
|
||||||
AdminExtChipEditor,
|
|
||||||
AdminPageHeader,
|
|
||||||
AdminSettingsActions,
|
|
||||||
AdminSettingsGroup,
|
|
||||||
AdminSettingsPage,
|
|
||||||
AdminSettingsRow,
|
|
||||||
AdminSettingsWork,
|
|
||||||
AdminStepper,
|
|
||||||
AdminSwitch,
|
|
||||||
} from "@/components/admin";
|
|
||||||
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
|
||||||
import { toast } from "@/lib/toast";
|
|
||||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
|
||||||
import BasicIdentityPanel from "./BasicIdentityPanel";
|
|
||||||
import BrandSeoPanel from "./BrandSeoPanel";
|
|
||||||
import FooterLinksPanel from "./FooterLinksPanel";
|
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
|
||||||
|
|
||||||
function extsEqual(a: string[], b: string[]) {
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
return a.every((v, i) => v === b[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 与后端 DefaultTimelineGitAdapterJSON 对齐的内置默认适配器 */
|
|
||||||
const DEFAULT_TIMELINE_GIT_IMPORT = `{
|
|
||||||
"max_pages": 10,
|
|
||||||
"max_commits": 100,
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"id": "github",
|
|
||||||
"host": "github.com",
|
|
||||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
|
|
||||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
|
||||||
"api_url": "https://api.github.com/repos/{owner}/{repo}/commits",
|
|
||||||
"commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}",
|
|
||||||
"query": { "sha": "{ref}", "per_page": "100", "page": "{page}" },
|
|
||||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" },
|
|
||||||
"pagination": "link_header",
|
|
||||||
"item": {
|
|
||||||
"sha": "sha",
|
|
||||||
"date": "commit.committer.date",
|
|
||||||
"message": "commit.message",
|
|
||||||
"source_url": "html_url"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gitea",
|
|
||||||
"host": "*",
|
|
||||||
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
|
|
||||||
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
|
|
||||||
"api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits",
|
|
||||||
"commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
|
|
||||||
"query": { "sha": "{ref}", "limit": "50", "page": "{page}" },
|
|
||||||
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" },
|
|
||||||
"pagination": "query_page",
|
|
||||||
"item": {
|
|
||||||
"sha": "sha",
|
|
||||||
"date": "created",
|
|
||||||
"message": "commit.message",
|
|
||||||
"source_url": "html_url"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`;
|
|
||||||
|
|
||||||
type SectionId = "basic" | "content" | "gitimport";
|
|
||||||
|
|
||||||
const NAV: { id: SectionId; label: string }[] = [
|
|
||||||
{ id: "basic", label: "基本信息" },
|
|
||||||
{ id: "content", label: "内容与互动" },
|
|
||||||
{ id: "gitimport", label: "提交导入" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const COMING_SOON = ["安全与限流", "邮件", "对象存储", "敏感词", "系统维护"];
|
|
||||||
|
|
||||||
function parseSection(hash: string): SectionId {
|
|
||||||
const id = hash.replace(/^#/, "");
|
|
||||||
if (id === "content" || id === "gitimport" || id === "basic") return id;
|
|
||||||
return "basic";
|
|
||||||
}
|
|
||||||
|
|
||||||
function InstantNote({ saving }: { saving?: boolean }) {
|
|
||||||
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
|
|
||||||
const [live, setLive] = useState(initial);
|
|
||||||
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
|
||||||
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
|
||||||
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
|
||||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
|
|
||||||
const [togglingReg, setTogglingReg] = useState(false);
|
|
||||||
const [togglingComments, setTogglingComments] = useState(false);
|
|
||||||
const [togglingMessages, setTogglingMessages] = useState(false);
|
|
||||||
const [togglingLink, setTogglingLink] = useState(false);
|
|
||||||
const [active, setActive] = useState<SectionId>("basic");
|
|
||||||
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
|
||||||
|
|
||||||
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
|
|
||||||
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
|
|
||||||
const [savedExts, setSavedExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
|
|
||||||
const [attMaxMB, setAttMaxMB] = useState(initial.attachment_max_mb || 20);
|
|
||||||
const [attMaxCount, setAttMaxCount] = useState(initial.attachment_max_count || 10);
|
|
||||||
const [imageMaxMB, setImageMaxMB] = useState(initial.image_max_mb || 5);
|
|
||||||
const [togglingExtLimit, setTogglingExtLimit] = useState(false);
|
|
||||||
const [savingLimits, setSavingLimits] = useState(false);
|
|
||||||
const [savingExts, setSavingExts] = useState(false);
|
|
||||||
const confirmedLimits = useRef({
|
|
||||||
attachment_max_mb: initial.attachment_max_mb || 20,
|
|
||||||
attachment_max_count: initial.attachment_max_count || 10,
|
|
||||||
image_max_mb: initial.image_max_mb || 5,
|
|
||||||
});
|
|
||||||
const limitsReq = useRef(0);
|
|
||||||
|
|
||||||
const [gitImportJSON, setGitImportJSON] = useState("");
|
|
||||||
const [savedGitImport, setSavedGitImport] = useState("");
|
|
||||||
const [gitImportLoading, setGitImportLoading] = useState(false);
|
|
||||||
const [gitImportLoaded, setGitImportLoaded] = useState(false);
|
|
||||||
const [savingGitImport, setSavingGitImport] = useState(false);
|
|
||||||
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
|
|
||||||
const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
||||||
|
|
||||||
const [basicDirty, setBasicDirty] = useState(false);
|
|
||||||
const [brandDirty, setBrandDirty] = useState(false);
|
|
||||||
const [seoDirty, setSeoDirty] = useState(false);
|
|
||||||
const [pendingSection, setPendingSection] = useState<SectionId | null>(null);
|
|
||||||
|
|
||||||
const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
|
|
||||||
const gitImportDirty = gitImportJSON !== savedGitImport;
|
|
||||||
const gitPreview = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(gitImportJSON) as {
|
|
||||||
max_pages?: unknown;
|
|
||||||
max_commits?: unknown;
|
|
||||||
sources?: unknown;
|
|
||||||
};
|
|
||||||
if (!Array.isArray(parsed.sources)) return null;
|
|
||||||
const sources = parsed.sources
|
|
||||||
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
|
|
||||||
.map((s) => ({
|
|
||||||
id: typeof s.id === "string" ? s.id : "—",
|
|
||||||
host: typeof s.host === "string" ? s.host : "*",
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
maxPages: typeof parsed.max_pages === "number" ? parsed.max_pages : null,
|
|
||||||
maxCommits: typeof parsed.max_commits === "number" ? parsed.max_commits : null,
|
|
||||||
sources,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [gitImportJSON]);
|
|
||||||
|
|
||||||
const tabDirty =
|
|
||||||
active === "basic" ? basicDirty || brandDirty || seoDirty : active === "content" ? extsDirty : gitImportDirty;
|
|
||||||
|
|
||||||
const leave = useUnsavedGuard(tabDirty);
|
|
||||||
|
|
||||||
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
|
||||||
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
|
||||||
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
|
||||||
|
|
||||||
const loadGitImport = async () => {
|
|
||||||
if (gitImportLoading) return;
|
|
||||||
setGitImportLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiGetAdminSettings();
|
|
||||||
const raw = res.timeline_git_import?.trim() || DEFAULT_TIMELINE_GIT_IMPORT;
|
|
||||||
let pretty = raw;
|
|
||||||
try {
|
|
||||||
pretty = JSON.stringify(JSON.parse(raw), null, 2);
|
|
||||||
} catch {
|
|
||||||
/* 保持原文 */
|
|
||||||
}
|
|
||||||
setGitImportJSON(pretty);
|
|
||||||
setSavedGitImport(pretty);
|
|
||||||
setGitImportLoaded(true);
|
|
||||||
setGitValidateMsg(null);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "加载适配器失败");
|
|
||||||
} finally {
|
|
||||||
setGitImportLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadGitImport();
|
|
||||||
// 仅挂载时拉取一次
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const apply = () => setActive(parseSection(window.location.hash));
|
|
||||||
apply();
|
|
||||||
if (!window.location.hash) {
|
|
||||||
history.replaceState(null, "", `${window.location.pathname}${window.location.search}#basic`);
|
|
||||||
}
|
|
||||||
window.addEventListener("hashchange", apply);
|
|
||||||
return () => window.removeEventListener("hashchange", apply);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const goSection = (id: SectionId) => {
|
|
||||||
if (id === active) return;
|
|
||||||
setActive(id);
|
|
||||||
const next = `${window.location.pathname}${window.location.search}#${id}`;
|
|
||||||
history.replaceState(null, "", next);
|
|
||||||
document.getElementById("main")?.scrollTo({ top: 0 });
|
|
||||||
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
|
|
||||||
void loadGitImport();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectTab = (id: SectionId) => {
|
|
||||||
if (id === active) return;
|
|
||||||
if (tabDirty) {
|
|
||||||
setPendingSection(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goSection(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
|
|
||||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End" && e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
let next = i;
|
|
||||||
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length;
|
|
||||||
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
|
|
||||||
else if (e.key === "Home") next = 0;
|
|
||||||
else next = NAV.length - 1;
|
|
||||||
selectTab(NAV[next].id);
|
|
||||||
navRefs.current[next]?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateGitImportClient = (): boolean => {
|
|
||||||
try {
|
|
||||||
JSON.parse(gitImportJSON);
|
|
||||||
setGitValidateMsg({ ok: true, text: "JSON 语法有效(服务端保存时还会做结构校验)" });
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
setGitValidateMsg({
|
|
||||||
ok: false,
|
|
||||||
text: e instanceof Error ? `JSON 无效:${e.message}` : "JSON 无效",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveGitImport = async () => {
|
|
||||||
if (savingGitImport) return;
|
|
||||||
if (!validateGitImportClient()) {
|
|
||||||
toast("请先修正 JSON 语法");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snap = gitImportJSON;
|
|
||||||
setSavingGitImport(true);
|
|
||||||
try {
|
|
||||||
await apiUpdateSiteSettings({ timeline_git_import: snap });
|
|
||||||
const res = await apiGetAdminSettings();
|
|
||||||
const raw = res.timeline_git_import?.trim() || snap;
|
|
||||||
let pretty = raw;
|
|
||||||
try {
|
|
||||||
pretty = JSON.stringify(JSON.parse(raw), null, 2);
|
|
||||||
} catch {
|
|
||||||
/* 保持 */
|
|
||||||
}
|
|
||||||
setSavedGitImport(pretty);
|
|
||||||
setGitImportJSON((cur) => (cur === snap ? pretty : cur));
|
|
||||||
toast("提交导入适配器已保存", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSavingGitImport(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreGitDefault = async () => {
|
|
||||||
setSavingGitImport(true);
|
|
||||||
try {
|
|
||||||
let pretty = DEFAULT_TIMELINE_GIT_IMPORT;
|
|
||||||
try {
|
|
||||||
pretty = JSON.stringify(JSON.parse(DEFAULT_TIMELINE_GIT_IMPORT), null, 2);
|
|
||||||
} catch {
|
|
||||||
/* 保持 */
|
|
||||||
}
|
|
||||||
await apiUpdateSiteSettings({ timeline_git_import: pretty });
|
|
||||||
setGitImportJSON(pretty);
|
|
||||||
setSavedGitImport(pretty);
|
|
||||||
setGitValidateMsg(null);
|
|
||||||
setRestoreGitOpen(false);
|
|
||||||
toast("已恢复默认适配器", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "恢复失败");
|
|
||||||
} finally {
|
|
||||||
setSavingGitImport(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRegister = async () => {
|
|
||||||
if (togglingReg) return;
|
|
||||||
const next = !allowRegister;
|
|
||||||
setTogglingReg(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ allow_register: next });
|
|
||||||
setAllowRegister(res.allow_register);
|
|
||||||
toast(res.allow_register ? "已开放注册" : "已关闭注册", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setTogglingReg(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleComments = async () => {
|
|
||||||
if (togglingComments) return;
|
|
||||||
const next = !allowComments;
|
|
||||||
setTogglingComments(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ allow_comments: next });
|
|
||||||
setAllowComments(res.allow_comments);
|
|
||||||
toast(res.allow_comments ? "已开放评论" : "已关闭评论", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setTogglingComments(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMessages = async () => {
|
|
||||||
if (togglingMessages) return;
|
|
||||||
const next = !allowMessages;
|
|
||||||
setTogglingMessages(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ allow_messages: next });
|
|
||||||
setAllowMessages(res.allow_messages);
|
|
||||||
toast(res.allow_messages ? "已开放消息" : "已关闭消息", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setTogglingMessages(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePostLinkNewTab = async () => {
|
|
||||||
if (togglingLink) return;
|
|
||||||
const next = !postLinkNewTab;
|
|
||||||
setTogglingLink(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
|
|
||||||
setPostLinkNewTab(res.post_link_new_tab !== false);
|
|
||||||
toast(res.post_link_new_tab !== false ? "帖子与评论外链将在新标签打开" : "帖子与评论外链将在当前页打开", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setTogglingLink(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExtLimit = async () => {
|
|
||||||
if (togglingExtLimit) return;
|
|
||||||
const next = !extLimit;
|
|
||||||
setTogglingExtLimit(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ attachment_ext_limit: next });
|
|
||||||
setExtLimit(res.attachment_ext_limit !== false);
|
|
||||||
toast(res.attachment_ext_limit !== false ? "已开启附件类型限制" : "已关闭附件类型限制", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setTogglingExtLimit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveUploadLimits = async (patch: {
|
|
||||||
attachment_max_mb?: number;
|
|
||||||
attachment_max_count?: number;
|
|
||||||
image_max_mb?: number;
|
|
||||||
}) => {
|
|
||||||
const ticket = ++limitsReq.current;
|
|
||||||
setSavingLimits(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings(patch);
|
|
||||||
if (ticket !== limitsReq.current) return;
|
|
||||||
confirmedLimits.current = {
|
|
||||||
attachment_max_mb: res.attachment_max_mb,
|
|
||||||
attachment_max_count: res.attachment_max_count,
|
|
||||||
image_max_mb: res.image_max_mb,
|
|
||||||
};
|
|
||||||
setAttMaxMB(res.attachment_max_mb);
|
|
||||||
setAttMaxCount(res.attachment_max_count);
|
|
||||||
setImageMaxMB(res.image_max_mb);
|
|
||||||
toast("上传限额已保存", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
if (ticket !== limitsReq.current) return;
|
|
||||||
setAttMaxMB(confirmedLimits.current.attachment_max_mb);
|
|
||||||
setAttMaxCount(confirmedLimits.current.attachment_max_count);
|
|
||||||
setImageMaxMB(confirmedLimits.current.image_max_mb);
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
if (ticket === limitsReq.current) setSavingLimits(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const limitsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const pendingLimits = useRef<typeof confirmedLimits.current>({ ...confirmedLimits.current });
|
|
||||||
const scheduleLimitSave = (patch: {
|
|
||||||
attachment_max_mb?: number;
|
|
||||||
attachment_max_count?: number;
|
|
||||||
image_max_mb?: number;
|
|
||||||
}) => {
|
|
||||||
pendingLimits.current = { ...pendingLimits.current, ...patch };
|
|
||||||
if (limitsTimer.current) clearTimeout(limitsTimer.current);
|
|
||||||
limitsTimer.current = setTimeout(() => {
|
|
||||||
const next = pendingLimits.current;
|
|
||||||
void saveUploadLimits({
|
|
||||||
attachment_max_mb: next.attachment_max_mb,
|
|
||||||
attachment_max_count: next.attachment_max_count,
|
|
||||||
image_max_mb: next.image_max_mb,
|
|
||||||
});
|
|
||||||
}, 450);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (limitsTimer.current) clearTimeout(limitsTimer.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveExts = async () => {
|
|
||||||
if (savingExts || !extsDirty) return;
|
|
||||||
const snap = [...exts];
|
|
||||||
setSavingExts(true);
|
|
||||||
try {
|
|
||||||
const res = await apiUpdateSiteSettings({ attachment_exts: snap });
|
|
||||||
setSavedExts([...res.attachment_exts]);
|
|
||||||
setExts((cur) => (extsEqual(cur, snap) ? [...res.attachment_exts] : cur));
|
|
||||||
toast("附件扩展名已保存", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
|
||||||
} finally {
|
|
||||||
setSavingExts(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const leaveOpen = leave.leaveOpen || pendingSection != null;
|
|
||||||
const confirmLeave = () => {
|
|
||||||
if (pendingSection) {
|
|
||||||
const next = pendingSection;
|
|
||||||
setPendingSection(null);
|
|
||||||
goSection(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
leave.confirmLeave();
|
|
||||||
};
|
|
||||||
const cancelLeave = () => {
|
|
||||||
setPendingSection(null);
|
|
||||||
leave.cancelLeave();
|
|
||||||
};
|
|
||||||
|
|
||||||
const nav = (
|
|
||||||
<nav className="admin-settings-nav" aria-label="设置分类">
|
|
||||||
{NAV.map((item, i) => {
|
|
||||||
const isActive = active === item.id;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={item.id}
|
|
||||||
ref={(el) => {
|
|
||||||
navRefs.current[i] = el;
|
|
||||||
}}
|
|
||||||
href={`#${item.id}`}
|
|
||||||
className="admin-settings-nav-link"
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
selectTab(item.id);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => onNavKeyDown(e, i)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<details className="admin-settings-nav-soon">
|
|
||||||
<summary className="admin-settings-nav-soon-summary">未开放模块</summary>
|
|
||||||
<ul className="admin-settings-nav-soon-list">
|
|
||||||
{COMING_SOON.map((name) => (
|
|
||||||
<li key={name}>{name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminSettingsPage className={active === "gitimport" ? "is-fill" : undefined}>
|
|
||||||
<AdminPageHeader
|
|
||||||
icon={Globe}
|
|
||||||
title="站点设置"
|
|
||||||
description="名称、关键词、Logo 与页脚在「基本信息」;主题色请到「外观」调整"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminSettingsWork nav={nav}>
|
|
||||||
<div id={`settings-panel-${active}`} className="admin-settings-stack">
|
|
||||||
{active === "basic" && (
|
|
||||||
<>
|
|
||||||
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
|
||||||
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
|
||||||
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === "content" && (
|
|
||||||
<>
|
|
||||||
<AdminSettingsGroup
|
|
||||||
title="内容与互动"
|
|
||||||
description="开关改完立即写入,无需再点保存。"
|
|
||||||
>
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<UserPlus size={15} /> 开放注册
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow="关闭后游客无法提交注册,已有账号仍可登录。"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AdminSwitch
|
|
||||||
checked={allowRegister}
|
|
||||||
disabled={togglingReg}
|
|
||||||
onChange={() => void toggleRegister()}
|
|
||||||
label="开放注册"
|
|
||||||
/>
|
|
||||||
<InstantNote saving={togglingReg} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<MessageSquare size={15} /> 开放评论
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow="关闭后前台不展示评论入口、评论数量与已有评论。"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AdminSwitch
|
|
||||||
checked={allowComments}
|
|
||||||
disabled={togglingComments}
|
|
||||||
onChange={() => void toggleComments()}
|
|
||||||
label="开放评论"
|
|
||||||
/>
|
|
||||||
<InstantNote saving={togglingComments} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<MessagesSquare size={15} /> 开放消息
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow="关闭后前台不展示私聊与群聊入口;已有会话仍可由后台监管。"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AdminSwitch
|
|
||||||
checked={allowMessages}
|
|
||||||
disabled={togglingMessages}
|
|
||||||
onChange={() => void toggleMessages()}
|
|
||||||
label="开放消息"
|
|
||||||
/>
|
|
||||||
<InstantNote saving={togglingMessages} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<ExternalLink size={15} /> 外链新标签打开
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow="开启后,帖子与评论中的外链在新标签打开;关闭则当前页跳转。"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AdminSwitch
|
|
||||||
checked={postLinkNewTab}
|
|
||||||
disabled={togglingLink}
|
|
||||||
onChange={() => void togglePostLinkNewTab()}
|
|
||||||
label="外链新标签打开"
|
|
||||||
/>
|
|
||||||
<InstantNote saving={togglingLink} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
</AdminSettingsGroup>
|
|
||||||
|
|
||||||
<AdminSettingsGroup
|
|
||||||
title="附件与上传"
|
|
||||||
description="限额停止输入约 0.5 秒后写入。扩展名白名单需点保存。"
|
|
||||||
footer={
|
|
||||||
<AdminSettingsActions
|
|
||||||
dirty={extsDirty}
|
|
||||||
saving={savingExts}
|
|
||||||
saveLabel="保存扩展名"
|
|
||||||
saveDisabled={!extsDirty || savingExts || !extLimit}
|
|
||||||
onSave={() => void saveExts()}
|
|
||||||
onDiscard={() => setExts([...savedExts])}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Paperclip size={15} /> 附件单文件上限
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow={
|
|
||||||
attMaxMB >= 50
|
|
||||||
? "发帖附件体积上限。当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大。"
|
|
||||||
: "发帖附件体积上限。过大可能拖垮进程,反代与 Next 请求体上限须不少于该值。"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<AdminStepper
|
|
||||||
value={attMaxMB}
|
|
||||||
min={1}
|
|
||||||
max={999999}
|
|
||||||
unit="MB"
|
|
||||||
ariaLabel="附件单文件上限"
|
|
||||||
disabled={savingLimits}
|
|
||||||
onChange={(n) => {
|
|
||||||
setAttMaxMB(n);
|
|
||||||
scheduleLimitSave({ attachment_max_mb: n });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<InstantNote saving={savingLimits} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Paperclip size={15} /> 每帖附件个数
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow="单帖(含草稿)最多可绑定的附件数量。"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<AdminStepper
|
|
||||||
value={attMaxCount}
|
|
||||||
min={1}
|
|
||||||
max={20}
|
|
||||||
unit="个"
|
|
||||||
ariaLabel="每帖附件个数"
|
|
||||||
disabled={savingLimits}
|
|
||||||
onChange={(n) => {
|
|
||||||
setAttMaxCount(n);
|
|
||||||
scheduleLimitSave({ attachment_max_count: n });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<InstantNote saving={savingLimits} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<ImageIcon size={15} /> 正文插图上限
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow={
|
|
||||||
imageMaxMB >= 10
|
|
||||||
? "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图 ≥10MB 时解码更耗内存。"
|
|
||||||
: "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<AdminStepper
|
|
||||||
value={imageMaxMB}
|
|
||||||
min={1}
|
|
||||||
max={999999}
|
|
||||||
unit="MB"
|
|
||||||
ariaLabel="正文插图上限"
|
|
||||||
disabled={savingLimits}
|
|
||||||
onChange={(n) => {
|
|
||||||
setImageMaxMB(n);
|
|
||||||
scheduleLimitSave({ image_max_mb: n });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<InstantNote saving={savingLimits} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
|
|
||||||
<AdminSettingsRow
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Paperclip size={15} /> 限制附件扩展名
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
hintBelow={
|
|
||||||
extLimit
|
|
||||||
? "仅允许下方列表中的扩展名。"
|
|
||||||
: "任意格式可传,仍受体积上限与下载沙箱约束。"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AdminSwitch
|
|
||||||
checked={extLimit}
|
|
||||||
disabled={togglingExtLimit}
|
|
||||||
onChange={() => void toggleExtLimit()}
|
|
||||||
label="限制附件扩展名"
|
|
||||||
/>
|
|
||||||
<InstantNote saving={togglingExtLimit} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
|
|
||||||
<AdminSettingsRow label="允许的扩展名" hintBelow="列表变更需点保存,不会随开关立即写入。" wide>
|
|
||||||
<div className="admin-settings-control-long">
|
|
||||||
<AdminExtChipEditor value={exts} onChange={setExts} disabled={!extLimit || savingExts} />
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
</AdminSettingsGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === "gitimport" && (
|
|
||||||
<AdminSettingsGroup
|
|
||||||
className="is-fill"
|
|
||||||
title="提交导入适配器"
|
|
||||||
description="用 JSON 描述 GitHub / Gitea 等匹配规则。保存时服务端会做结构校验,不会执行任意代码。"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<AdminSettingsActions
|
|
||||||
dirty={gitImportDirty}
|
|
||||||
saving={savingGitImport}
|
|
||||||
saveLabel="保存导入适配器"
|
|
||||||
onSave={() => void saveGitImport()}
|
|
||||||
onDiscard={() => {
|
|
||||||
setGitImportJSON(savedGitImport);
|
|
||||||
setGitValidateMsg(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-line btn-sm"
|
|
||||||
disabled={gitImportLoading || savingGitImport}
|
|
||||||
onClick={() => {
|
|
||||||
if (validateGitImportClient()) toast("语法校验通过", "ok");
|
|
||||||
else toast("JSON 语法无效");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={14} /> 校验
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-line btn-sm"
|
|
||||||
disabled={savingGitImport}
|
|
||||||
onClick={() => setRestoreGitOpen(true)}
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} /> 恢复默认
|
|
||||||
</button>
|
|
||||||
</AdminSettingsActions>
|
|
||||||
{(gitPreview || gitImportLoaded) ? (
|
|
||||||
<div className="admin-settings-git-summary" aria-label="规则摘要">
|
|
||||||
<span className="admin-settings-git-summary-kicker">规则摘要</span>
|
|
||||||
{gitPreview ? (
|
|
||||||
<>
|
|
||||||
<span>
|
|
||||||
最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交
|
|
||||||
</span>
|
|
||||||
{gitPreview.sources.map((s) => (
|
|
||||||
<span key={`${s.id}-${s.host}`} className="admin-settings-git-summary-src">
|
|
||||||
<GitBranch size={12} aria-hidden />
|
|
||||||
<strong>{s.id}</strong>
|
|
||||||
<span>{s.host}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>JSON 无法解析时不显示摘要,保存前请先校验语法。</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{gitImportLoading && !gitImportLoaded ? (
|
|
||||||
<div className="flex items-center gap-2 meta py-8 justify-center">
|
|
||||||
<Loader2 size={16} className="animate-spin" /> 加载适配器…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AdminSettingsRow
|
|
||||||
className="admin-settings-git-editor"
|
|
||||||
label="适配器 JSON"
|
|
||||||
htmlFor="git-import-json"
|
|
||||||
hint="声明式匹配规则。服务端只按规则拉取。"
|
|
||||||
wide
|
|
||||||
>
|
|
||||||
<div className="admin-settings-git-json-wrap">
|
|
||||||
<textarea
|
|
||||||
id="git-import-json"
|
|
||||||
className="j13-compose-field admin-settings-git-json font-mono text-[12.5px] leading-relaxed"
|
|
||||||
spellCheck={false}
|
|
||||||
value={gitImportJSON}
|
|
||||||
onChange={(e) => {
|
|
||||||
setGitImportJSON(e.target.value);
|
|
||||||
setGitValidateMsg(null);
|
|
||||||
}}
|
|
||||||
placeholder="粘贴或编辑适配器 JSON…"
|
|
||||||
/>
|
|
||||||
{gitValidateMsg ? (
|
|
||||||
<p
|
|
||||||
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
|
|
||||||
gitValidateMsg.ok ? "" : "alert-error !mt-2"
|
|
||||||
}`}
|
|
||||||
style={gitValidateMsg.ok ? { color: "var(--ok, #137333)" } : undefined}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
{gitValidateMsg.ok ? <CheckCircle2 size={14} className="mt-0.5 shrink-0" /> : null}
|
|
||||||
{gitValidateMsg.text}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</AdminSettingsRow>
|
|
||||||
)}
|
|
||||||
</AdminSettingsGroup>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AdminSettingsWork>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={restoreGitOpen}
|
|
||||||
title="恢复默认适配器"
|
|
||||||
message="将覆盖当前「提交导入」JSON 为内置 GitHub + Gitea 模板,并立即保存。确定继续?"
|
|
||||||
busy={savingGitImport}
|
|
||||||
confirmLabel="恢复并保存"
|
|
||||||
onCancel={() => !savingGitImport && setRestoreGitOpen(false)}
|
|
||||||
onConfirm={() => void restoreGitDefault()}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
open={leaveOpen}
|
|
||||||
title="有未保存的修改"
|
|
||||||
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
|
|
||||||
confirmLabel="离开"
|
|
||||||
danger
|
|
||||||
onCancel={cancelLeave}
|
|
||||||
onConfirm={confirmLeave}
|
|
||||||
/>
|
|
||||||
</AdminSettingsPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
97
frontend/app/admin/settings/SettingsShell.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminSettingsPage,
|
||||||
|
AdminSettingsWork,
|
||||||
|
} from "@/components/admin";
|
||||||
|
|
||||||
|
const NAV: { href: string; label: string }[] = [
|
||||||
|
{ href: "/admin/settings/basic", label: "基本信息" },
|
||||||
|
{ href: "/admin/settings/access", label: "互动与安全" },
|
||||||
|
{ href: "/admin/settings/mail", label: "邮件服务" },
|
||||||
|
{ href: "/admin/settings/storage", label: "文件与存储" },
|
||||||
|
{ href: "/admin/settings/filter", label: "内容过滤" },
|
||||||
|
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 旧 #hash / ?section= → 独立路由 */
|
||||||
|
const LEGACY: Record<string, string> = {
|
||||||
|
basic: "/admin/settings/basic",
|
||||||
|
content: "/admin/settings/access",
|
||||||
|
security: "/admin/settings/access",
|
||||||
|
access: "/admin/settings/access",
|
||||||
|
mail: "/admin/settings/mail",
|
||||||
|
storage: "/admin/settings/storage",
|
||||||
|
filter: "/admin/settings/filter",
|
||||||
|
maintenance: "/admin/settings/maintenance",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsShell({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const hash = window.location.hash.replace(/^#/, "").trim();
|
||||||
|
const section = new URLSearchParams(window.location.search).get("section") || hash;
|
||||||
|
if (!section) return;
|
||||||
|
const dest = LEGACY[section];
|
||||||
|
if (!dest || dest === pathname) return;
|
||||||
|
router.replace(dest);
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
|
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
|
||||||
|
if (
|
||||||
|
e.key !== "ArrowDown" &&
|
||||||
|
e.key !== "ArrowUp" &&
|
||||||
|
e.key !== "Home" &&
|
||||||
|
e.key !== "End" &&
|
||||||
|
e.key !== "ArrowRight" &&
|
||||||
|
e.key !== "ArrowLeft"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
let next = i;
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length;
|
||||||
|
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
|
||||||
|
else if (e.key === "Home") next = 0;
|
||||||
|
else next = NAV.length - 1;
|
||||||
|
navRefs.current[next]?.focus();
|
||||||
|
router.push(NAV[next].href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nav = (
|
||||||
|
<nav className="admin-settings-nav" aria-label="设置分类">
|
||||||
|
{NAV.map((item, i) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
ref={(el) => {
|
||||||
|
navRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
href={item.href}
|
||||||
|
className="admin-settings-nav-link"
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
onKeyDown={(e) => onNavKeyDown(e, i)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsPage>
|
||||||
|
<AdminPageHeader icon={Globe} title="站点设置" description="基本信息、互动安全与运营模块" />
|
||||||
|
<AdminSettingsWork nav={nav}>{children}</AdminSettingsWork>
|
||||||
|
</AdminSettingsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/app/admin/settings/access/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import AccessSettings from "../AccessSettings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "互动与安全",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AccessSettingsPage() {
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
return <AccessSettings initial={settings} />;
|
||||||
|
}
|
||||||
12
frontend/app/admin/settings/basic/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import BasicSettings from "../BasicSettings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "基本信息",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BasicSettingsPage() {
|
||||||
|
const settings = await getPublicSettingsCached();
|
||||||
|
return <BasicSettings initial={settings} />;
|
||||||
|
}
|
||||||
10
frontend/app/admin/settings/filter/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import ModuleSettings from "../ModuleSettings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "内容过滤",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FilterSettingsPage() {
|
||||||
|
return <ModuleSettings name="filter" />;
|
||||||
|
}
|
||||||
15
frontend/app/admin/settings/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { isSuperOrOwner } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
import SettingsShell from "./SettingsShell";
|
||||||
|
|
||||||
|
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||||
|
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
|
||||||
|
}
|
||||||
|
return <SettingsShell>{children}</SettingsShell>;
|
||||||
|
}
|
||||||
10
frontend/app/admin/settings/mail/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import ModuleSettings from "../ModuleSettings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "邮件服务",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MailSettingsPage() {
|
||||||
|
return <ModuleSettings name="mail" />;
|
||||||
|
}
|
||||||
10
frontend/app/admin/settings/maintenance/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import ModuleSettings from "../ModuleSettings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "维护与诊断",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MaintenanceSettingsPage() {
|
||||||
|
return <ModuleSettings name="maintenance" />;
|
||||||
|
}
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { redirect } from "next/navigation";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
|
||||||
import { isSuperOrOwner } from "@/lib/roles";
|
|
||||||
import Forbidden from "../Forbidden";
|
|
||||||
import SettingsAdmin from "./SettingsAdmin";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "站点设置",
|
title: "站点设置",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminSettingsPage() {
|
interface PageProps {
|
||||||
const cookie = authCookieHeader(await cookies());
|
searchParams: Promise<{ section?: string }>;
|
||||||
const me = await getMeCached(cookie || undefined);
|
|
||||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
|
||||||
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
|
|
||||||
}
|
}
|
||||||
const settings = await getPublicSettingsCached();
|
|
||||||
return <SettingsAdmin initial={settings} />;
|
const LEGACY: Record<string, string> = {
|
||||||
|
basic: "/admin/settings/basic",
|
||||||
|
content: "/admin/settings/access",
|
||||||
|
security: "/admin/settings/access",
|
||||||
|
access: "/admin/settings/access",
|
||||||
|
mail: "/admin/settings/mail",
|
||||||
|
storage: "/admin/settings/storage",
|
||||||
|
filter: "/admin/settings/filter",
|
||||||
|
maintenance: "/admin/settings/maintenance",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /admin/settings 与旧 ?section= 统一跳到子路由 */
|
||||||
|
export default async function SettingsIndexPage({ searchParams }: PageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const raw = typeof sp.section === "string" ? sp.section.trim() : "";
|
||||||
|
redirect(LEGACY[raw] || "/admin/settings/basic");
|
||||||
}
|
}
|
||||||
|
|||||||
10
frontend/app/admin/settings/storage/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import ModuleSettings from "../ModuleSettings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "文件与存储",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StorageSettingsPage() {
|
||||||
|
return <ModuleSettings name="storage" />;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import OperationalBanner from "@/components/OperationalBanner";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
||||||
@@ -22,7 +23,8 @@ import { accentStyleCSS } from "@/lib/theme";
|
|||||||
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
||||||
import { siteDocumentTitle } from "@/lib/brand";
|
import { siteDocumentTitle } from "@/lib/brand";
|
||||||
import { motionConfigFromSettings } from "@/lib/motion";
|
import { motionConfigFromSettings } from "@/lib/motion";
|
||||||
import { requestMetadataBase } from "@/lib/canonicalMeta";
|
import { peekPublicMetaSnapshot, publishPublicMetaSnapshot } from "@/lib/publicMetaSnapshot";
|
||||||
|
import { metadataBaseURL } from "@/lib/siteUrl";
|
||||||
|
|
||||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||||
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
||||||
@@ -47,15 +49,16 @@ const jetbrainsMono = JetBrains_Mono({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
// 必须同步:根 layout 若在此 await(拉设置 / headers),Next 流式 metadata
|
||||||
const settings = await getPublicSettingsCached();
|
// 会在等待期把标签临时写成 localhost:3000,开发态 RSC/HMR 一密就来回闪。
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
const settings = peekPublicMetaSnapshot();
|
||||||
const name = settings.site_name || "姜十三论坛";
|
const name = settings.site_name || "姜十三论坛";
|
||||||
const title = siteDocumentTitle(name, settings.site_slogan || "");
|
const title = siteDocumentTitle(name, settings.site_slogan || "");
|
||||||
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
|
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
|
||||||
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
|
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
|
||||||
const metadataBase = await requestMetadataBase();
|
const metadataBase = metadataBaseURL();
|
||||||
return {
|
return {
|
||||||
// 只提供解析相对 URL 的基址;Canonical 由各公开页自行声明,避免子路由继承首页
|
|
||||||
...(metadataBase ? { metadataBase } : {}),
|
...(metadataBase ? { metadataBase } : {}),
|
||||||
title: {
|
title: {
|
||||||
default: title,
|
default: title,
|
||||||
@@ -63,9 +66,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
},
|
},
|
||||||
description: desc,
|
description: desc,
|
||||||
keywords,
|
keywords,
|
||||||
...(settings.favicon_url
|
...(settings.favicon_url ? { icons: { icon: settings.favicon_url } } : {}),
|
||||||
? { icons: { icon: settings.favicon_url } }
|
|
||||||
: {}),
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description: desc,
|
description: desc,
|
||||||
@@ -94,6 +95,7 @@ export default async function RootLayout({
|
|||||||
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
||||||
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
|
publishPublicMetaSnapshot(settings);
|
||||||
const accent = settings.accent;
|
const accent = settings.accent;
|
||||||
const accentCSS = accentStyleCSS(accent || "");
|
const accentCSS = accentStyleCSS(accent || "");
|
||||||
const bgCfg = bgConfigFromSettings(settings);
|
const bgCfg = bgConfigFromSettings(settings);
|
||||||
@@ -147,7 +149,10 @@ export default async function RootLayout({
|
|||||||
</a>
|
</a>
|
||||||
<SiteBrandProvider initial={settings}>
|
<SiteBrandProvider initial={settings}>
|
||||||
<MotionProvider initial={motionCfg}>
|
<MotionProvider initial={motionCfg}>
|
||||||
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
|
<CommentsPolicyProvider
|
||||||
|
allowComments={settings.allow_comments !== false}
|
||||||
|
requireLogin={settings.comments_require_login === true}
|
||||||
|
>
|
||||||
<MessagesPolicyProvider allowMessages={settings.allow_messages !== false}>
|
<MessagesPolicyProvider allowMessages={settings.allow_messages !== false}>
|
||||||
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
|
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
|
||||||
<CodeFoldProvider
|
<CodeFoldProvider
|
||||||
@@ -160,8 +165,8 @@ export default async function RootLayout({
|
|||||||
unread={unread}
|
unread={unread}
|
||||||
chatUnread={settings.allow_messages !== false ? chatUnread : 0}
|
chatUnread={settings.allow_messages !== false ? chatUnread : 0}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
allowRegister={settings.allow_register}
|
|
||||||
>
|
>
|
||||||
|
<OperationalBanner />
|
||||||
{children}
|
{children}
|
||||||
</SiteChrome>
|
</SiteChrome>
|
||||||
</CodeFoldProvider>
|
</CodeFoldProvider>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MessagesSquare } from "lucide-react";
|
import { MessagesSquare } from "lucide-react";
|
||||||
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
|
import { apiLogin, apiMe } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||||
@@ -18,17 +18,6 @@ function safeRedirect(to: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RegisterHint() {
|
function RegisterHint() {
|
||||||
const [allow, setAllow] = useState(true);
|
|
||||||
useEffect(() => {
|
|
||||||
apiGetSettings()
|
|
||||||
.then((s) => setAllow(s.allow_register))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
if (!allow) {
|
|
||||||
return (
|
|
||||||
<p className="meta text-center mt-5">当前站点暂不开放注册</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<p className="meta text-center mt-5">
|
<p className="meta text-center mt-5">
|
||||||
还没有账号?
|
还没有账号?
|
||||||
@@ -133,6 +122,7 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center mt-4"><Link href="/reset-password">找回密码</Link></p>
|
||||||
<RegisterHint />
|
<RegisterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -452,8 +452,10 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 站点设置已在 layout 缓存;关评时不拉评论树,避免首屏 HTML 泄露历史评论
|
// 站点设置已在 layout 缓存;关评或「登录可见」且游客时不拉评论树,避免首屏泄露
|
||||||
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
const publicSettings = await getPublicSettingsCached();
|
||||||
|
const allowComments = publicSettings.allow_comments !== false;
|
||||||
|
const commentsRequireLogin = publicSettings.comments_require_login === true;
|
||||||
const emptyComments: CommentsResponse = {
|
const emptyComments: CommentsResponse = {
|
||||||
comments: [],
|
comments: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -461,12 +463,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
page: 1,
|
page: 1,
|
||||||
size: 20,
|
size: 20,
|
||||||
};
|
};
|
||||||
const [commentsData, me, profile, relatedData] = await Promise.all([
|
const meFirst = await getMeCached(cookie || undefined);
|
||||||
allowComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
|
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
|
||||||
getMeCached(cookie || undefined),
|
const [commentsData, profile, relatedData] = await Promise.all([
|
||||||
|
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
|
||||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||||
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
|
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
const me = meFirst;
|
||||||
const comments = commentsData.comments;
|
const comments = commentsData.comments;
|
||||||
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
||||||
const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
||||||
@@ -517,7 +521,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
|
absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
|
||||||
entityUrl;
|
entityUrl;
|
||||||
const authorName = post.user.nickname || post.user.username;
|
const authorName = post.user.nickname || post.user.username;
|
||||||
const visibleFloors: JsonLdVisibleComment[] = allowComments
|
const visibleFloors: JsonLdVisibleComment[] = canFetchComments
|
||||||
? comments
|
? comments
|
||||||
.filter((c) => !c.deleted && (c.content || "").trim())
|
.filter((c) => !c.deleted && (c.content || "").trim())
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
@@ -530,7 +534,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
const acceptedForLd: JsonLdVisibleComment | null =
|
const acceptedForLd: JsonLdVisibleComment | null =
|
||||||
allowComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
|
canFetchComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
|
||||||
? {
|
? {
|
||||||
id: acceptedAnswer.id,
|
id: acceptedAnswer.id,
|
||||||
content: acceptedAnswer.content,
|
content: acceptedAnswer.content,
|
||||||
@@ -565,7 +569,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
author: { id: post.user.id, name: authorName },
|
author: { id: post.user.id, name: authorName },
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
pageUrl,
|
pageUrl,
|
||||||
commentCount: allowComments ? post.comment_count : undefined,
|
commentCount: canFetchComments ? post.comment_count : undefined,
|
||||||
viewCount: post.view_count,
|
viewCount: post.view_count,
|
||||||
comments: visibleFloors,
|
comments: visibleFloors,
|
||||||
});
|
});
|
||||||
|
|||||||
225
frontend/app/register/RegisterClient.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { apiRegister, apiMe, apiOperations } from "@/lib/api";
|
||||||
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
|
|
||||||
|
/** 关闭注册时的说明页(提交失败切到关闭态时复用) */
|
||||||
|
export function RegisterClosedPanel({ notice }: { notice: string }) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||||
|
<div className="panel p-8 text-center">
|
||||||
|
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||||
|
暂不开放注册
|
||||||
|
</h1>
|
||||||
|
<p className="meta mt-2 text-[13px]">
|
||||||
|
{notice || "站点当前已关闭新用户注册,已有账号可直接登录。"}
|
||||||
|
</p>
|
||||||
|
<Link href="/login" className="btn btn-primary mt-6">
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterClient({
|
||||||
|
verifyEmail: initialVerifyEmail,
|
||||||
|
registerNotice,
|
||||||
|
}: {
|
||||||
|
verifyEmail: boolean;
|
||||||
|
registerNotice: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [registerClosed, setRegisterClosed] = useState(false);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [notice, setNotice] = useState(registerNotice);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sentMessage, setSentMessage] = useState("");
|
||||||
|
const verifyEmail = initialVerifyEmail;
|
||||||
|
|
||||||
|
// 已登录用户不必再注册,直接回首页
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasAuthCookieHint()) return;
|
||||||
|
apiMe()
|
||||||
|
.then((res) => {
|
||||||
|
if (res.user) router.replace("/");
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiRegister(username, email, password, code);
|
||||||
|
if (res.user) {
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
} else if (res.id) {
|
||||||
|
router.push("/login");
|
||||||
|
} else {
|
||||||
|
const msg = res.error || "注册失败";
|
||||||
|
if (msg.includes("关闭注册")) {
|
||||||
|
// 热关闭:补拉提示文案后切关闭态
|
||||||
|
try {
|
||||||
|
const s = await apiOperations<{ register_notice: string }>("/api/site-state");
|
||||||
|
setNotice(s.register_notice || notice);
|
||||||
|
} catch {
|
||||||
|
/* 沿用 SSR 提示 */
|
||||||
|
}
|
||||||
|
setRegisterClosed(true);
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (registerClosed) {
|
||||||
|
return <RegisterClosedPanel notice={notice} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||||
|
<div className="mb-7 text-center">
|
||||||
|
<span
|
||||||
|
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<UserPlus size={26} />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||||
|
创建账号
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
||||||
|
加入社区,开始你的第一帖
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="alert-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="reg-username">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reg-username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
className="field"
|
||||||
|
placeholder="3–32 位"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="reg-email">
|
||||||
|
{verifyEmail ? "邮箱(必填)" : "邮箱(可选)"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reg-email"
|
||||||
|
required={verifyEmail}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
className="field"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{verifyEmail && (
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="reg-code">
|
||||||
|
邮箱验证码
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 items-stretch">
|
||||||
|
<input
|
||||||
|
id="reg-code"
|
||||||
|
className="field min-w-0 flex-1"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line shrink-0"
|
||||||
|
disabled={sending || !email}
|
||||||
|
onClick={async () => {
|
||||||
|
setSending(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const r = await apiOperations<{ message: string }>("/api/auth/code", "POST", {
|
||||||
|
email,
|
||||||
|
purpose: "register",
|
||||||
|
});
|
||||||
|
setSentMessage(r.message);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? "正在排队…" : "发送验证码"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sentMessage && (
|
||||||
|
<p className="meta mt-2" role="status">
|
||||||
|
{sentMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="reg-password">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reg-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="field"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
||||||
|
{loading ? "注册中..." : "注册"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="meta text-center mt-5">
|
||||||
|
已有账号?
|
||||||
|
<Link href="/login" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,150 +1,18 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
import { getSiteStateCached } from "@/lib/serverData";
|
||||||
|
import RegisterClient, { RegisterClosedPanel } from "./RegisterClient";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export const metadata: Metadata = {
|
||||||
import { useRouter } from "next/navigation";
|
title: "注册",
|
||||||
import Link from "next/link";
|
|
||||||
import { UserPlus } from "lucide-react";
|
|
||||||
import { apiRegister, apiMe, apiGetSettings } from "@/lib/api";
|
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [registerClosed, setRegisterClosed] = useState(false);
|
|
||||||
|
|
||||||
// 已登录用户不必再注册,直接回首页
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasAuthCookieHint()) return;
|
|
||||||
apiMe()
|
|
||||||
.then((res) => {
|
|
||||||
if (res.user) router.replace("/");
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
apiGetSettings()
|
|
||||||
.then((s) => {
|
|
||||||
if (!s.allow_register) setRegisterClosed(true);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiRegister(username, email, password);
|
|
||||||
if (res.user) {
|
|
||||||
// 后端注册即签发登录态 cookie:直接进入社区,无需再手动登录
|
|
||||||
router.push("/");
|
|
||||||
router.refresh();
|
|
||||||
} else if (res.id) {
|
|
||||||
// 极端情况:注册成功但自动签发失败,引导去登录
|
|
||||||
router.push("/login");
|
|
||||||
} else {
|
|
||||||
const msg = res.error || "注册失败";
|
|
||||||
if (msg.includes("关闭注册")) setRegisterClosed(true);
|
|
||||||
setError(msg);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (registerClosed) {
|
/** SSR 直出开放/关闭态,避免先闪注册表单再切「暂不开放」 */
|
||||||
|
export default async function RegisterPage() {
|
||||||
|
const state = await getSiteStateCached();
|
||||||
|
if (!state.allow_register) {
|
||||||
|
return <RegisterClosedPanel notice={state.register_notice} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
<RegisterClient verifyEmail={state.verify_email} registerNotice={state.register_notice} />
|
||||||
<div className="panel p-8 text-center">
|
|
||||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
|
||||||
暂不开放注册
|
|
||||||
</h1>
|
|
||||||
<p className="meta mt-2 text-[13px]">站点当前已关闭新用户注册,已有账号可直接登录。</p>
|
|
||||||
<Link href="/login" className="btn btn-primary mt-6">
|
|
||||||
去登录
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
|
||||||
<div className="mb-7 text-center">
|
|
||||||
<span
|
|
||||||
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<UserPlus size={26} />
|
|
||||||
</span>
|
|
||||||
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
|
||||||
创建账号
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
|
||||||
加入社区,开始你的第一帖
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
|
|
||||||
{error && <div className="alert-error" role="alert">{error}</div>}
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="reg-username">用户名</label>
|
|
||||||
<input
|
|
||||||
id="reg-username"
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={3}
|
|
||||||
maxLength={32}
|
|
||||||
autoFocus
|
|
||||||
autoComplete="username"
|
|
||||||
className="field"
|
|
||||||
placeholder="3–32 位"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="reg-email">邮箱(可选)</label>
|
|
||||||
<input
|
|
||||||
id="reg-email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
autoComplete="email"
|
|
||||||
className="field"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="reg-password">密码</label>
|
|
||||||
<input
|
|
||||||
id="reg-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="field"
|
|
||||||
placeholder="至少 6 位"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
|
||||||
{loading ? "注册中..." : "注册"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="meta text-center mt-5">
|
|
||||||
已有账号?
|
|
||||||
<Link href="/login" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
|
|
||||||
去登录
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
169
frontend/app/reset-password/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import { apiOperations } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiOperations<{ password_reset: boolean }>("/api/site-state")
|
||||||
|
.then((s) => setEnabled(s.password_reset))
|
||||||
|
.catch(() => setError("暂时无法读取站点状态"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
if (sending || !email) return;
|
||||||
|
setSending(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const r = await apiOperations<{ message: string }>("/api/auth/code", "POST", {
|
||||||
|
email,
|
||||||
|
purpose: "reset",
|
||||||
|
});
|
||||||
|
setMessage(r.message);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const r = await apiOperations<{ message: string }>("/api/auth/reset-password", "POST", {
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setMessage(r.message);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto pt-8 sm:pt-14">
|
||||||
|
<div className="mb-7 text-center">
|
||||||
|
<span
|
||||||
|
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<KeyRound size={26} />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||||
|
找回密码
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
||||||
|
通过邮箱验证码重置登录密码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={resetPassword} className="panel p-6 sm:p-7 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="alert-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!enabled ? (
|
||||||
|
<p className="meta text-[13px]">站点暂未开放邮件找回密码。</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="reset-email">
|
||||||
|
邮箱
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reset-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="field"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="reset-code">
|
||||||
|
邮箱验证码
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 items-stretch">
|
||||||
|
<input
|
||||||
|
id="reset-code"
|
||||||
|
className="field min-w-0 flex-1"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line shrink-0"
|
||||||
|
disabled={sending || busy || !email}
|
||||||
|
onClick={() => void sendCode()}
|
||||||
|
>
|
||||||
|
{sending ? "正在排队…" : "发送验证码"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="reset-password">
|
||||||
|
新密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reset-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
maxLength={64}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="field"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-full justify-center mt-2"
|
||||||
|
disabled={busy || !code || password.length < 6}
|
||||||
|
>
|
||||||
|
{busy ? "处理中…" : "更新密码"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{message && (
|
||||||
|
<p className="meta" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="meta text-center mt-5">
|
||||||
|
<Link href="/login" className="font-semibold" style={{ color: "var(--accent)" }}>
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,7 +83,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
const allowComments = settings.allow_comments !== false;
|
const allowComments = settings.allow_comments !== false;
|
||||||
|
const commentsRequireLogin = settings.comments_require_login === true;
|
||||||
const allowMessages = settings.allow_messages !== false;
|
const allowMessages = settings.allow_messages !== false;
|
||||||
|
const canReadComments = allowComments && (!commentsRequireLogin || !!me.user);
|
||||||
|
|
||||||
let data: UserProfile;
|
let data: UserProfile;
|
||||||
try {
|
try {
|
||||||
@@ -108,12 +110,12 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
const currentUser = me.user;
|
const currentUser = me.user;
|
||||||
let effectiveTab: UserTab =
|
let effectiveTab: UserTab =
|
||||||
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
|
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
|
||||||
if (!allowComments && effectiveTab === "comments") effectiveTab = "posts";
|
if (!canReadComments && effectiveTab === "comments") effectiveTab = "posts";
|
||||||
|
|
||||||
// 评论 tab:SSR 直出对应分页
|
// 评论 tab:SSR 直出对应分页
|
||||||
let commentData: UserCommentsResponse | null = null;
|
let commentData: UserCommentsResponse | null = null;
|
||||||
let commentError = false;
|
let commentError = false;
|
||||||
if (allowComments && effectiveTab === "comments") {
|
if (canReadComments && effectiveTab === "comments") {
|
||||||
try {
|
try {
|
||||||
commentData = await fetchUserComments(id, page);
|
commentData = await fetchUserComments(id, page);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,7 +149,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
// 导航项(桌面左栏 / 移动 chips 共用定义)
|
// 导航项(桌面左栏 / 移动 chips 共用定义)
|
||||||
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
|
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
|
||||||
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: <FileText size={15} /> },
|
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: <FileText size={15} /> },
|
||||||
...(allowComments
|
...(canReadComments
|
||||||
? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> }]
|
? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> }]
|
||||||
: []),
|
: []),
|
||||||
{ key: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
|
{ key: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
|
||||||
@@ -157,7 +159,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
|
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
|
||||||
|
|
||||||
const profileQuery: Record<string, string | number> = {};
|
const profileQuery: Record<string, string | number> = {};
|
||||||
if (allowComments && effectiveTab === "comments") profileQuery.tab = "comments";
|
if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments";
|
||||||
if (page > 1) profileQuery.page = page;
|
if (page > 1) profileQuery.page = page;
|
||||||
const profileLd =
|
const profileLd =
|
||||||
effectiveTab === "settings" || effectiveTab === "security"
|
effectiveTab === "settings" || effectiveTab === "security"
|
||||||
@@ -274,7 +276,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
|
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
|
||||||
{" "}帖子
|
{" "}帖子
|
||||||
</span>
|
</span>
|
||||||
{allowComments && (
|
{canReadComments && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden className="mx-1.5">·</span>
|
<span aria-hidden className="mx-1.5">·</span>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ import {
|
|||||||
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
|
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { formatCommentTime, formatDateTime } from "@/lib/format";
|
import { formatCommentTime, formatDateTime } from "@/lib/format";
|
||||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider";
|
||||||
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
||||||
|
import CommentsLoginRequired from "@/components/CommentsLoginRequired";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Pagination from "./Pagination";
|
import Pagination from "./Pagination";
|
||||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||||
@@ -197,6 +198,7 @@ export default function CommentSection({
|
|||||||
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
|
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const commentsOpen = useAllowComments();
|
const commentsOpen = useAllowComments();
|
||||||
|
const commentsRequireLogin = useCommentsRequireLogin();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
// 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离
|
// 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离
|
||||||
const [replyTarget, setReplyTarget] = useState<{
|
const [replyTarget, setReplyTarget] = useState<{
|
||||||
@@ -1177,6 +1179,9 @@ export default function CommentSection({
|
|||||||
if (!commentsOpen) {
|
if (!commentsOpen) {
|
||||||
return <CommentsUnsupported />;
|
return <CommentsUnsupported />;
|
||||||
}
|
}
|
||||||
|
if (commentsRequireLogin && !user) {
|
||||||
|
return <CommentsLoginRequired redirect={`/post/${postId}#comments`} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">
|
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">
|
||||||
|
|||||||
30
frontend/components/CommentsLoginRequired.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { LogIn } from "lucide-react";
|
||||||
|
|
||||||
|
/** 站点开启「登录才可见评论」时,游客看到的评论区占位 */
|
||||||
|
export default function CommentsLoginRequired({ redirect }: { redirect: string }) {
|
||||||
|
const href = `/login?redirect=${encodeURIComponent(redirect)}`;
|
||||||
|
return (
|
||||||
|
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论">
|
||||||
|
<span
|
||||||
|
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--accent) 16%, transparent)",
|
||||||
|
color: "var(--accent)",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<LogIn size={22} />
|
||||||
|
</span>
|
||||||
|
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
登录后查看评论
|
||||||
|
</p>
|
||||||
|
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||||
|
本站评论仅对登录用户开放。
|
||||||
|
</p>
|
||||||
|
<Link href={href} className="btn btn-primary btn-sm mt-5 inline-flex">
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,42 +11,68 @@ import {
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||||
|
|
||||||
const AllowCommentsContext = createContext(true);
|
type CommentsPolicy = {
|
||||||
|
open: boolean;
|
||||||
|
requireLogin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommentsPolicyContext = createContext<CommentsPolicy>({
|
||||||
|
open: true,
|
||||||
|
requireLogin: false,
|
||||||
|
});
|
||||||
|
|
||||||
/** 前台是否开放评论(SSR 初值 + 后台开关热更新) */
|
/** 前台是否开放评论(SSR 初值 + 后台开关热更新) */
|
||||||
export function useAllowComments(): boolean {
|
export function useAllowComments(): boolean {
|
||||||
return useContext(AllowCommentsContext);
|
return useContext(CommentsPolicyContext).open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 评论是否仅登录可见 */
|
||||||
|
export function useCommentsRequireLogin(): boolean {
|
||||||
|
return useContext(CommentsPolicyContext).requireLogin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;开启则刷新以拉回评论。
|
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;
|
||||||
|
* 「登录可见」切换时刷新以重拉/清空评论树。
|
||||||
*/
|
*/
|
||||||
export default function CommentsPolicyProvider({
|
export default function CommentsPolicyProvider({
|
||||||
allowComments,
|
allowComments,
|
||||||
|
requireLogin = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
allowComments: boolean;
|
allowComments: boolean;
|
||||||
|
requireLogin?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(allowComments);
|
const [policy, setPolicy] = useState<CommentsPolicy>({
|
||||||
const openRef = useRef(open);
|
open: allowComments,
|
||||||
openRef.current = open;
|
requireLogin,
|
||||||
|
});
|
||||||
|
const policyRef = useRef(policy);
|
||||||
|
policyRef.current = policy;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(allowComments);
|
setPolicy({ open: allowComments, requireLogin });
|
||||||
}, [allowComments]);
|
}, [allowComments, requireLogin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||||
if (typeof data?.allow_comments !== "boolean") return;
|
if (!data) return;
|
||||||
if (openRef.current === data.allow_comments) return;
|
const prev = policyRef.current;
|
||||||
setOpen(data.allow_comments);
|
const nextOpen =
|
||||||
|
typeof data.allow_comments === "boolean" ? data.allow_comments : prev.open;
|
||||||
|
const nextRequire =
|
||||||
|
typeof data.comments_require_login === "boolean"
|
||||||
|
? data.comments_require_login
|
||||||
|
: prev.requireLogin;
|
||||||
|
if (nextOpen === prev.open && nextRequire === prev.requireLogin) return;
|
||||||
|
setPolicy({ open: nextOpen, requireLogin: nextRequire });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AllowCommentsContext.Provider value={open}>{children}</AllowCommentsContext.Provider>
|
<CommentsPolicyContext.Provider value={policy}>{children}</CommentsPolicyContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import pkg from "../package.json";
|
import pkg from "../package.json";
|
||||||
import type { FooterLink } from "@/lib/brand";
|
import type { FooterLink, FooterLinksAlign } from "@/lib/brand";
|
||||||
import FooterTiming from "./FooterTiming";
|
import FooterTiming from "./FooterTiming";
|
||||||
|
|
||||||
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
|
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
|
||||||
@@ -10,7 +10,29 @@ function realVersion(pkgVersion: string): string {
|
|||||||
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 极简页脚:左侧版权 / 版本 / 耗时,右侧备案等链接
|
function zoneLinks(links: FooterLink[], align: FooterLinksAlign): FooterLink[] {
|
||||||
|
return links.filter((item) => (item.align || "right") === align);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkGroup({ items }: { items: FooterLink[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="site-footer-links">
|
||||||
|
{items.map((item) => (
|
||||||
|
<a
|
||||||
|
key={`${item.align}-${item.label}-${item.url}`}
|
||||||
|
href={item.url}
|
||||||
|
className="meta text-[12px] hover:underline"
|
||||||
|
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极简页脚:版权 / 版本 / 耗时 + 左 / 中 / 右三栏链接
|
||||||
export default function Footer({
|
export default function Footer({
|
||||||
siteName = "姜十三社区",
|
siteName = "姜十三社区",
|
||||||
links = [],
|
links = [],
|
||||||
@@ -20,34 +42,33 @@ export default function Footer({
|
|||||||
}) {
|
}) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const brand = siteName.trim() || "姜十三社区";
|
const brand = siteName.trim() || "姜十三社区";
|
||||||
const visibleLinks = links.filter((item) => item.label.trim() && item.url.trim());
|
const visible = links.filter((item) => item.label.trim() && item.url.trim());
|
||||||
|
const left = zoneLinks(visible, "left");
|
||||||
|
const center = zoneLinks(visible, "center");
|
||||||
|
const right = zoneLinks(visible, "right");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
|
className="site-footer"
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid var(--line)",
|
borderTop: "1px solid var(--line)",
|
||||||
background: "var(--panel)",
|
background: "var(--panel)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap">
|
<div className="site-footer-inner max-w-[1440px] mx-auto px-4 sm:px-6 py-4">
|
||||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
|
<div className="site-footer-zone site-footer-zone-left">
|
||||||
|
<div className="site-footer-meta">
|
||||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||||
<FooterTiming version={realVersion(pkg.version)} />
|
<FooterTiming version={realVersion(pkg.version)} />
|
||||||
</div>
|
</div>
|
||||||
{visibleLinks.length > 0 ? (
|
<LinkGroup items={left} />
|
||||||
<nav aria-label="页脚链接" className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
|
</div>
|
||||||
{visibleLinks.map((item) => (
|
<div className="site-footer-zone site-footer-zone-center">
|
||||||
<a
|
<LinkGroup items={center} />
|
||||||
key={`${item.label}-${item.url}`}
|
</div>
|
||||||
href={item.url}
|
<div className="site-footer-zone site-footer-zone-right">
|
||||||
className="meta text-[12px] hover:underline"
|
<LinkGroup items={right} />
|
||||||
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
</div>
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default function Header({
|
|||||||
brandMark = "image_text",
|
brandMark = "image_text",
|
||||||
brandLogoSize = "sq",
|
brandLogoSize = "sq",
|
||||||
brandLogoFit = "contain",
|
brandLogoFit = "contain",
|
||||||
allowRegister = true,
|
|
||||||
}: {
|
}: {
|
||||||
initialUser: User | null;
|
initialUser: User | null;
|
||||||
initialUnread?: number;
|
initialUnread?: number;
|
||||||
@@ -60,7 +59,6 @@ export default function Header({
|
|||||||
brandMark?: BrandMark;
|
brandMark?: BrandMark;
|
||||||
brandLogoSize?: BrandLogoSize;
|
brandLogoSize?: BrandLogoSize;
|
||||||
brandLogoFit?: BrandLogoFit;
|
brandLogoFit?: BrandLogoFit;
|
||||||
allowRegister?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -334,11 +332,9 @@ export default function Header({
|
|||||||
<Link href="/login" className="btn btn-line btn-sm">
|
<Link href="/login" className="btn btn-line btn-sm">
|
||||||
<LogIn size={14} /> 登录
|
<LogIn size={14} /> 登录
|
||||||
</Link>
|
</Link>
|
||||||
{allowRegister && (
|
|
||||||
<Link href="/register" className="btn btn-primary btn-sm">
|
<Link href="/register" className="btn btn-primary btn-sm">
|
||||||
<UserPlus size={14} /> 注册
|
<UserPlus size={14} /> 注册
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -402,7 +398,7 @@ export default function Header({
|
|||||||
</MobileLink>
|
</MobileLink>
|
||||||
)}
|
)}
|
||||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||||
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
|
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||||
<ThemeToggle
|
<ThemeToggle
|
||||||
|
|||||||
17
frontend/components/OperationalBanner.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import {useEffect,useState} from "react";
|
||||||
|
import {usePathname} from "next/navigation";
|
||||||
|
import {apiOperations} from "@/lib/api";
|
||||||
|
export default function OperationalBanner(){
|
||||||
|
const path=usePathname();
|
||||||
|
const [notice,setNotice]=useState("");
|
||||||
|
useEffect(()=>{
|
||||||
|
let active=true;
|
||||||
|
const refresh=()=>{if(document.visibilityState!=="visible")return; void apiOperations<{maintenance:{mode:string;title:string;message:string}} >("/api/site-state").then(s=>{if(active)setNotice(s.maintenance.mode==="readonly" ? (s.maintenance.message || "站点暂时只读,可以浏览,暂不能提交或修改内容。") : "");}).catch(()=>{});};
|
||||||
|
refresh();const timer=setInterval(refresh,30000);
|
||||||
|
document.addEventListener("visibilitychange",refresh);
|
||||||
|
return ()=>{active=false;clearInterval(timer);document.removeEventListener("visibilitychange",refresh);};
|
||||||
|
},[path]);
|
||||||
|
if(path.startsWith("/admin") || !notice)return null;
|
||||||
|
return <div role="status" className="panel mx-auto my-3 w-full max-w-5xl p-4 text-sm"><strong>只读模式:</strong>{notice}</div>;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark } from "@/lib/brand";
|
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
|
||||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||||
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
|
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
|
||||||
|
|
||||||
@@ -52,29 +52,18 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyFavicon(url: string) {
|
function applyFavicon(url: string) {
|
||||||
const existing = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
|
// 只管理 #j13-favicon,禁止 remove 其它 icon 节点:
|
||||||
if (!url) {
|
// Next Metadata 注入的 <link rel="icon"> 由 React 持有,手动删会导致 removeChild 空指针。
|
||||||
document.getElementById("j13-favicon")?.remove();
|
|
||||||
const hasDefault = [...existing].some((el) => el.id !== "j13-favicon" && el.href.includes("favicon"));
|
|
||||||
if (!hasDefault) {
|
|
||||||
const link = document.createElement("link");
|
|
||||||
link.rel = "icon";
|
|
||||||
link.href = "/favicon.ico";
|
|
||||||
document.head.appendChild(link);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
existing.forEach((el) => {
|
|
||||||
if (el.id !== "j13-favicon") el.remove();
|
|
||||||
});
|
|
||||||
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
|
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
|
||||||
if (!link) {
|
if (!link) {
|
||||||
link = document.createElement("link");
|
link = document.createElement("link");
|
||||||
link.id = "j13-favicon";
|
link.id = "j13-favicon";
|
||||||
link.rel = "icon";
|
link.rel = "icon";
|
||||||
document.head.appendChild(link);
|
|
||||||
}
|
}
|
||||||
if (link.getAttribute("href") !== url) link.href = url;
|
const href = url || "/favicon.ico";
|
||||||
|
if (link.getAttribute("href") !== href) link.href = href;
|
||||||
|
// 挪到 head 末尾,优先于 SSR 注入的 icon
|
||||||
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function retitle(prev: SiteBrand, next: SiteBrand) {
|
function retitle(prev: SiteBrand, next: SiteBrand) {
|
||||||
@@ -132,7 +121,7 @@ export default function SiteBrandProvider({
|
|||||||
setBrand((prev) => {
|
setBrand((prev) => {
|
||||||
if (sameBrand(prev, next)) return prev;
|
if (sameBrand(prev, next)) return prev;
|
||||||
retitle(prev, next);
|
retitle(prev, next);
|
||||||
applyFavicon(next.favicon_url);
|
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [initial]);
|
}, [initial]);
|
||||||
@@ -150,11 +139,13 @@ export default function SiteBrandProvider({
|
|||||||
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
||||||
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
||||||
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
||||||
footer_links: Array.isArray(raw.footer_links) ? raw.footer_links : prev.footer_links,
|
footer_links: Array.isArray(raw.footer_links)
|
||||||
|
? normalizeFooterLinks(raw.footer_links)
|
||||||
|
: prev.footer_links,
|
||||||
};
|
};
|
||||||
if (sameBrand(prev, next)) return;
|
if (sameBrand(prev, next)) return;
|
||||||
retitle(prev, next);
|
retitle(prev, next);
|
||||||
applyFavicon(next.favicon_url);
|
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
|
||||||
brandRef.current = next;
|
brandRef.current = next;
|
||||||
setBrand(next);
|
setBrand(next);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export default function SiteChrome({
|
|||||||
unread,
|
unread,
|
||||||
chatUnread,
|
chatUnread,
|
||||||
theme,
|
theme,
|
||||||
allowRegister,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
initialIsAdmin: boolean;
|
initialIsAdmin: boolean;
|
||||||
@@ -48,7 +47,6 @@ export default function SiteChrome({
|
|||||||
unread: number;
|
unread: number;
|
||||||
chatUnread: number;
|
chatUnread: number;
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
allowRegister: boolean;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -103,7 +101,6 @@ export default function SiteChrome({
|
|||||||
brandMark={brand.brand_mark}
|
brandMark={brand.brand_mark}
|
||||||
brandLogoSize={brand.brand_logo_size}
|
brandLogoSize={brand.brand_logo_size}
|
||||||
brandLogoFit={brand.brand_logo_fit}
|
brandLogoFit={brand.brand_logo_fit}
|
||||||
allowRegister={allowRegister}
|
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
id="main"
|
id="main"
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function AdminSettingsRow({
|
|||||||
<div className="admin-settings-row-control">
|
<div className="admin-settings-row-control">
|
||||||
{children}
|
{children}
|
||||||
{hintBelow != null && hintBelow !== "" ? (
|
{hintBelow != null && hintBelow !== "" ? (
|
||||||
<p className="admin-settings-row-hint is-below">{hintBelow}</p>
|
<div className="admin-settings-row-hint is-below">{hintBelow}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{error != null && error !== "" ? (
|
{error != null && error !== "" ? (
|
||||||
<p className="admin-settings-row-error" id={errorId} role="alert">
|
<p className="admin-settings-row-error" id={errorId} role="alert">
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
type BrandMark,
|
type BrandMark,
|
||||||
type BrandSlot,
|
type BrandSlot,
|
||||||
type FooterLink,
|
type FooterLink,
|
||||||
|
type FooterLinksAlign,
|
||||||
} from "./brand";
|
} from "./brand";
|
||||||
import { CSRF_COOKIE } from "./cookies";
|
import { CSRF_COOKIE } from "./cookies";
|
||||||
|
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
|
||||||
import { emitForceLogout } from "./userEvents";
|
import { emitForceLogout } from "./userEvents";
|
||||||
|
|
||||||
export type { FooterLink };
|
export type { FooterLink, FooterLinksAlign };
|
||||||
|
|
||||||
// API 基础配置
|
// API 基础配置
|
||||||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||||||
@@ -684,6 +686,8 @@ export interface PublicSettings {
|
|||||||
allow_register: boolean;
|
allow_register: boolean;
|
||||||
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
||||||
allow_comments: boolean;
|
allow_comments: boolean;
|
||||||
|
/** 评论仅登录可见;缺省 false(游客可读) */
|
||||||
|
comments_require_login: boolean;
|
||||||
/** 全站是否开放私聊/群聊;关闭后前台不展示消息入口 */
|
/** 全站是否开放私聊/群聊;关闭后前台不展示消息入口 */
|
||||||
allow_messages: boolean;
|
allow_messages: boolean;
|
||||||
post_cooldown_hours: number;
|
post_cooldown_hours: number;
|
||||||
@@ -749,6 +753,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
|||||||
footer_links: [],
|
footer_links: [],
|
||||||
allow_register: true,
|
allow_register: true,
|
||||||
allow_comments: true,
|
allow_comments: true,
|
||||||
|
comments_require_login: false,
|
||||||
allow_messages: true,
|
allow_messages: true,
|
||||||
post_cooldown_hours: 24,
|
post_cooldown_hours: 24,
|
||||||
code_block_auto_fold: true,
|
code_block_auto_fold: true,
|
||||||
@@ -821,6 +826,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
|||||||
footer_links: normalizeFooterLinks(data?.footer_links),
|
footer_links: normalizeFooterLinks(data?.footer_links),
|
||||||
allow_register: data?.allow_register !== false,
|
allow_register: data?.allow_register !== false,
|
||||||
allow_comments: data?.allow_comments !== false,
|
allow_comments: data?.allow_comments !== false,
|
||||||
|
comments_require_login: data?.comments_require_login === true,
|
||||||
allow_messages: data?.allow_messages !== false,
|
allow_messages: data?.allow_messages !== false,
|
||||||
post_cooldown_hours:
|
post_cooldown_hours:
|
||||||
typeof data?.post_cooldown_hours === "number" && data.post_cooldown_hours >= 0
|
typeof data?.post_cooldown_hours === "number" && data.post_cooldown_hours >= 0
|
||||||
@@ -857,10 +863,52 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
|||||||
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
|
||||||
if (!res.ok) return { ...DEFAULT_PUBLIC_SETTINGS };
|
if (!res.ok) {
|
||||||
return normalizePublicSettings((await res.json()) as Partial<PublicSettings>);
|
const fallback = { ...DEFAULT_PUBLIC_SETTINGS };
|
||||||
|
publishPublicMetaSnapshot(fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalized = normalizePublicSettings((await res.json()) as Partial<PublicSettings>);
|
||||||
|
publishPublicMetaSnapshot(normalized);
|
||||||
|
return normalized;
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_PUBLIC_SETTINGS };
|
const fallback = { ...DEFAULT_PUBLIC_SETTINGS };
|
||||||
|
publishPublicMetaSnapshot(fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公开站点运行态(注册/找回/维护等);SSR 与客户端同源字段 */
|
||||||
|
export type PublicSiteState = {
|
||||||
|
allow_register: boolean;
|
||||||
|
register_notice: string;
|
||||||
|
verify_email: boolean;
|
||||||
|
password_reset: boolean;
|
||||||
|
site_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SITE_STATE: PublicSiteState = {
|
||||||
|
allow_register: true,
|
||||||
|
register_notice: "",
|
||||||
|
verify_email: false,
|
||||||
|
password_reset: false,
|
||||||
|
site_url: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchSiteState(): Promise<PublicSiteState> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/site-state`, ssrInit());
|
||||||
|
if (!res.ok) return { ...DEFAULT_SITE_STATE };
|
||||||
|
const data = (await res.json()) as Partial<PublicSiteState>;
|
||||||
|
return {
|
||||||
|
allow_register: data.allow_register !== false,
|
||||||
|
register_notice: typeof data.register_notice === "string" ? data.register_notice : "",
|
||||||
|
verify_email: data.verify_email === true,
|
||||||
|
password_reset: data.password_reset === true,
|
||||||
|
site_url: typeof data.site_url === "string" ? data.site_url : "",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SITE_STATE };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,12 +1002,12 @@ export async function apiLogin(username: string, password: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRegister(username: string, email: string, password: string) {
|
export async function apiRegister(username: string, email: string, password: string, code = "") {
|
||||||
const res = await fetch("/api/register", {
|
const res = await fetch("/api/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify({ username, email, password }),
|
body: JSON.stringify({ username, email, password, code }),
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -1878,19 +1926,6 @@ export async function apiAdminDeleteSitePage(id: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGetAdminSettings(): Promise<
|
|
||||||
PublicSettings & { timeline_git_import?: string }
|
|
||||||
> {
|
|
||||||
const res = await fetchWithRefresh("/api/admin/settings", { headers: clientHeaders() });
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data.error || "获取设置失败");
|
|
||||||
return {
|
|
||||||
...normalizePublicSettings(data as Partial<PublicSettings>),
|
|
||||||
timeline_git_import:
|
|
||||||
typeof data.timeline_git_import === "string" ? data.timeline_git_import : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 用户管理(管理员) =====
|
// ===== 用户管理(管理员) =====
|
||||||
|
|
||||||
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
|
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
|
||||||
@@ -2223,6 +2258,7 @@ export type UpdateSiteSettingsBody = {
|
|||||||
footer_links?: FooterLink[];
|
footer_links?: FooterLink[];
|
||||||
allow_register?: boolean;
|
allow_register?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
|
comments_require_login?: boolean;
|
||||||
allow_messages?: boolean;
|
allow_messages?: boolean;
|
||||||
post_cooldown_hours?: number;
|
post_cooldown_hours?: number;
|
||||||
code_block_auto_fold?: boolean;
|
code_block_auto_fold?: boolean;
|
||||||
@@ -2241,7 +2277,6 @@ export type UpdateSiteSettingsBody = {
|
|||||||
bg_site_mode?: string;
|
bg_site_mode?: string;
|
||||||
bg_admin_url?: string;
|
bg_admin_url?: string;
|
||||||
bg_admin_mode?: string;
|
bg_admin_mode?: string;
|
||||||
timeline_git_import?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||||
@@ -2980,3 +3015,10 @@ export async function apiChatUnreadSummary(): Promise<ChatUnreadSummaryResponse>
|
|||||||
if (!res.ok) return { total: 0, rooms: [] };
|
if (!res.ok) return { total: 0, rooms: [] };
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiOperations<T = Record<string, unknown>>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||||||
|
const res = await fetchWithRefresh(path, { method, cache: "no-store", headers: clientHeaders({ "Content-Type": "application/json" }), ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "操作失败,请重试");
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,25 @@ export type FooterLink = {
|
|||||||
label: string;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
new_tab: boolean;
|
new_tab: boolean;
|
||||||
|
/** 所属栏位:left / center / right;缺省 right */
|
||||||
|
align: FooterLinksAlign;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 页脚链接栏位 */
|
||||||
|
export type FooterLinksAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
|
export const FOOTER_LINKS_ALIGNS: { id: FooterLinksAlign; label: string }[] = [
|
||||||
|
{ id: "left", label: "左" },
|
||||||
|
{ id: "center", label: "中" },
|
||||||
|
{ id: "right", label: "右" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeFooterLinksAlign(raw: unknown): FooterLinksAlign {
|
||||||
|
const s = String(raw ?? "").trim();
|
||||||
|
if (s === "left" || s === "center" || s === "right") return s;
|
||||||
|
return "right";
|
||||||
|
}
|
||||||
|
|
||||||
export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
|
export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
|
||||||
|
|
||||||
/** 页眉品牌形式 */
|
/** 页眉品牌形式 */
|
||||||
@@ -135,7 +152,8 @@ export function normalizeFooterLinks(raw: unknown): FooterLink[] {
|
|||||||
if (!footerURLAllowed(url)) continue;
|
if (!footerURLAllowed(url)) continue;
|
||||||
const omitted = rec.new_tab === undefined || rec.new_tab === null;
|
const omitted = rec.new_tab === undefined || rec.new_tab === null;
|
||||||
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
|
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
|
||||||
out.push({ label, url, new_tab: newTab });
|
const align = normalizeFooterLinksAlign((rec as { align?: unknown }).align);
|
||||||
|
out.push({ label, url, new_tab: newTab, align });
|
||||||
if (out.length >= BRAND_LIMITS.footerLinks) break;
|
if (out.length >= BRAND_LIMITS.footerLinks) break;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
40
frontend/lib/publicMetaSnapshot.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** 进程内公开元信息快照:供根 layout 同步 generateMetadata,避免 await 触发标题闪成 localhost */
|
||||||
|
|
||||||
|
export type PublicMetaSnapshot = {
|
||||||
|
site_name: string;
|
||||||
|
site_slogan: string;
|
||||||
|
site_description: string;
|
||||||
|
site_keywords: string[];
|
||||||
|
favicon_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SNAP: PublicMetaSnapshot = {
|
||||||
|
site_name: "姜十三论坛",
|
||||||
|
site_slogan: "",
|
||||||
|
site_description: "姜十三论坛 - 技术分享与讨论社区",
|
||||||
|
site_keywords: [],
|
||||||
|
favicon_url: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
let snap: PublicMetaSnapshot = { ...DEFAULT_SNAP };
|
||||||
|
|
||||||
|
export function publishPublicMetaSnapshot(
|
||||||
|
s: Partial<PublicMetaSnapshot> | null | undefined,
|
||||||
|
): void {
|
||||||
|
if (!s) return;
|
||||||
|
const name = typeof s.site_name === "string" ? s.site_name.trim() : "";
|
||||||
|
snap = {
|
||||||
|
site_name: name || DEFAULT_SNAP.site_name,
|
||||||
|
site_slogan: typeof s.site_slogan === "string" ? s.site_slogan.trim() : snap.site_slogan,
|
||||||
|
site_description:
|
||||||
|
typeof s.site_description === "string" && s.site_description.trim()
|
||||||
|
? s.site_description.trim()
|
||||||
|
: snap.site_description,
|
||||||
|
site_keywords: Array.isArray(s.site_keywords) ? s.site_keywords : snap.site_keywords,
|
||||||
|
favicon_url: typeof s.favicon_url === "string" ? s.favicon_url.trim() : snap.favicon_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peekPublicMetaSnapshot(): PublicMetaSnapshot {
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
@@ -52,9 +52,10 @@ export interface RtSettingsData {
|
|||||||
brand_mark?: string;
|
brand_mark?: string;
|
||||||
brand_logo_size?: string;
|
brand_logo_size?: string;
|
||||||
brand_logo_fit?: string;
|
brand_logo_fit?: string;
|
||||||
footer_links?: { label: string; url: string; new_tab: boolean }[];
|
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
|
||||||
allow_register?: boolean;
|
allow_register?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
|
comments_require_login?: boolean;
|
||||||
allow_messages?: boolean;
|
allow_messages?: boolean;
|
||||||
post_cooldown_hours?: number;
|
post_cooldown_hours?: number;
|
||||||
code_block_auto_fold?: boolean;
|
code_block_auto_fold?: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { fetchMe, fetchPublicSettings } from "./api";
|
import { fetchMe, fetchPublicSettings, fetchSiteState } from "./api";
|
||||||
|
|
||||||
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
|
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
|
||||||
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
|
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
|
||||||
@@ -7,3 +7,6 @@ export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader
|
|||||||
|
|
||||||
// 同请求内去重:layout 注入主题 CSS 变量、后台页读取当前配置共用
|
// 同请求内去重:layout 注入主题 CSS 变量、后台页读取当前配置共用
|
||||||
export const getPublicSettingsCached = cache(() => fetchPublicSettings());
|
export const getPublicSettingsCached = cache(() => fetchPublicSettings());
|
||||||
|
|
||||||
|
// 同请求内去重:注册/找回密码页读取开放状态与提示文案
|
||||||
|
export const getSiteStateCached = cache(() => fetchSiteState());
|
||||||
|
|||||||
@@ -136,7 +136,21 @@ function withPathname(req: NextRequest, init?: { request?: { headers: Headers }
|
|||||||
return NextResponse.next({ request: { headers } });
|
return NextResponse.next({ request: { headers } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
// RFC 9309 规定爬虫文件必须是小写 /robots.txt。部分 SEO 检测工具会请求
|
||||||
|
// /Robots.txt、/ROBOTS.TXT 等大小写变体;生产环境(Linux)路径区分大小写,
|
||||||
|
// 这些请求会 404。内部改写到规范路径,对外仍只维护 robots.ts 一份内容。
|
||||||
|
function rewriteRobotsCase(req: NextRequest): NextResponse | null {
|
||||||
|
const path = req.nextUrl.pathname;
|
||||||
|
if (path === "/robots.txt" || !/^\/robots\.txt$/i.test(path)) return null;
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/robots.txt";
|
||||||
|
return NextResponse.rewrite(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessionMiddleware(req: NextRequest) {
|
||||||
|
const robotsRewrite = rewriteRobotsCase(req);
|
||||||
|
if (robotsRewrite) return robotsRewrite;
|
||||||
|
|
||||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||||
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
|
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
|
||||||
|
|
||||||
@@ -183,6 +197,131 @@ export async function middleware(req: NextRequest) {
|
|||||||
export const config = {
|
export const config = {
|
||||||
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
|
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
|
// 小写 /robots.txt 由 Metadata Route 直接响应,不进 middleware。
|
||||||
|
// 故意不排除 .txt:否则 /Robots.txt 到不了 rewriteRobotsCase。
|
||||||
|
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function escapeMaintenance(value: unknown): string {
|
||||||
|
return String(value ?? "").replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] || c),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteMaintState = {
|
||||||
|
maintenance?: {
|
||||||
|
mode: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
contact: string;
|
||||||
|
until: string;
|
||||||
|
retry_after: number;
|
||||||
|
};
|
||||||
|
bypass?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 维护态短缓存:Next 每次页面/RSC/预取都会进 middleware,开发态尤其密。
|
||||||
|
// 不带 Cookie 拉公开态(bypass 恒为 false),避免把管理员 bypass 错缓存给游客。
|
||||||
|
const SITE_STATE_TTL_MS = 3_000;
|
||||||
|
let siteStateCache: { at: number; state: SiteMaintState | null } | null = null;
|
||||||
|
let siteStateInflight: Promise<SiteMaintState | null> | null = null;
|
||||||
|
|
||||||
|
async function fetchPublicSiteState(): Promise<SiteMaintState | null> {
|
||||||
|
if (!API_BASE) return null;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${API_BASE}/api/site-state`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!upstream.ok) return null;
|
||||||
|
return (await upstream.json()) as SiteMaintState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCachedPublicSiteState(): Promise<SiteMaintState | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (siteStateCache && now - siteStateCache.at < SITE_STATE_TTL_MS) {
|
||||||
|
return siteStateCache.state;
|
||||||
|
}
|
||||||
|
if (!siteStateInflight) {
|
||||||
|
siteStateInflight = fetchPublicSiteState().finally(() => {
|
||||||
|
siteStateInflight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const state = await siteStateInflight;
|
||||||
|
siteStateCache = { at: Date.now(), state };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSiteStateBypass(cookie: string): Promise<boolean> {
|
||||||
|
if (!API_BASE || !cookie.trim()) return false;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${API_BASE}/api/site-state`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!upstream.ok) return false;
|
||||||
|
const state = (await upstream.json()) as SiteMaintState;
|
||||||
|
return !!state.bypass;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const response = await sessionMiddleware(req);
|
||||||
|
const path = req.nextUrl.pathname;
|
||||||
|
if (
|
||||||
|
path === "/login" ||
|
||||||
|
path === "/reset-password" ||
|
||||||
|
path === "/admin" ||
|
||||||
|
path.startsWith("/admin/") ||
|
||||||
|
/^\/robots\.txt$/i.test(path)
|
||||||
|
) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开维护态(短缓存);paused 时再带 Cookie 确认管理员 bypass
|
||||||
|
const state = await getCachedPublicSiteState();
|
||||||
|
if (state && state.maintenance?.mode !== "paused") {
|
||||||
|
response.headers.set("Cache-Control", "private, no-store");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie =
|
||||||
|
response.headers.get("x-middleware-request-cookie") || req.headers.get("cookie") || "";
|
||||||
|
if (await fetchSiteStateBypass(cookie)) {
|
||||||
|
response.headers.set("Cache-Control", "private, no-store");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = state?.maintenance;
|
||||||
|
const title = escapeMaintenance(m?.title || "站点暂时不可用");
|
||||||
|
const body =
|
||||||
|
'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' +
|
||||||
|
title +
|
||||||
|
"</title><style>body{font-family:system-ui,sans-serif;background:#f4f7f5;color:#24352b;margin:0;padding:8vh 24px}main{max-width:620px;margin:auto;background:white;border:1px solid #dce5df;border-radius:20px;padding:36px}p{line-height:1.8;white-space:pre-wrap}a{color:#236e49}@media(prefers-color-scheme:dark){body{background:#151c18;color:#e1eae4}main{background:#202b24;border-color:#3a4a40}a{color:#81cda3}}</style><main><h1>" +
|
||||||
|
title +
|
||||||
|
"</h1><p>" +
|
||||||
|
escapeMaintenance(m?.message || "请稍后重试。") +
|
||||||
|
"</p><p>" +
|
||||||
|
escapeMaintenance(m?.until ? "预计恢复:" + m.until : "") +
|
||||||
|
"</p><p>" +
|
||||||
|
escapeMaintenance(m?.contact) +
|
||||||
|
'</p><a href="/login?redirect=%2Fadmin%2Fsettings%2Fbasic">管理员登录</a></main></html>';
|
||||||
|
const paused = new NextResponse(body, {
|
||||||
|
status: 503,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"Retry-After": String(m?.retry_after || 300),
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const sc of readSetCookies(response)) paused.headers.append("Set-Cookie", sc);
|
||||||
|
return paused;
|
||||||
|
}
|
||||||
|
|||||||