开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 02:07:34 +08:00
parent 6f054a903c
commit 3b550f2124
24 changed files with 372 additions and 355 deletions

View File

@@ -4,8 +4,8 @@
# PostgreSQL 连接串 # PostgreSQL 连接串
DB_DSN=postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable DB_DSN=postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
# JWT 签名密钥(生产环境务必修改) # JWT 签名密钥(≥32 字符强随机;禁止 change-me 等占位,留空则自动 crypto/rand 生成)
JWT_SECRET=your-jwt-secret-change-me # JWT_SECRET=
# 服务端口 # 服务端口
HTTP_PORT=3001 HTTP_PORT=3001

View File

@@ -6,7 +6,7 @@ HTTP_PORT = 3001
DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
[security] [security]
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值 ; 留空则用 crypto/rand 生成并持久化到 data/.jwt_secret;生产务必显式指定 ≥32 字符强随机值
JWT_SECRET = JWT_SECRET =
; 32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。 ; 32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。
; 环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。 ; 环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。

View File

@@ -1,7 +1,10 @@
package config package config
import ( import (
"crypto/rand"
"encoding/base64"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -35,7 +38,7 @@ type appIniKey struct {
var appIniSchema = []appIniKey{ var appIniSchema = []appIniKey{
{section: "server", key: "HTTP_PORT", value: "3001"}, {section: "server", key: "HTTP_PORT", value: "3001"},
{section: "database", key: "DSN", value: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", comment: "PostgreSQL 连接串(部署时请修改账号密码)"}, {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: "JWT_SECRET", value: "", comment: "留空则用 crypto/rand 生成并持久化到 data/.jwt_secret;生产务必显式指定 ≥32 字符强随机值,禁止 change-me 等占位"},
{section: "security", key: "SETTINGS_MASTER_KEY", value: "", comment: "32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。\n环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。"}, {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: "paths", key: "DATA", value: "data"},
{section: "app", key: "DEV_MODE", value: "true"}, {section: "app", key: "DEV_MODE", value: "true"},
@@ -112,14 +115,28 @@ func Parse() (*Config, error) {
return nil, fmt.Errorf("创建数据目录失败: %w", err) return nil, fmt.Errorf("创建数据目录失败: %w", err)
} }
// JWT 密钥:留空则自动生成并持久化 // JWT 密钥:显式配置优先;留空则从 data/.jwt_secret 读取或用加密随机生成
if strings.TrimSpace(cfg.JWTSecret) == "" { secretFile := filepath.Join(cfg.DataDir, ".jwt_secret")
secretFile := filepath.Join(cfg.DataDir, ".jwt_secret") explicitSecret := strings.TrimSpace(cfg.JWTSecret) != ""
if !explicitSecret {
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 { if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
cfg.JWTSecret = string(data) cfg.JWTSecret = strings.TrimSpace(string(data))
} else { }
cfg.JWTSecret = generateSecret(32) }
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600) if isWeakJWTSecret(cfg.JWTSecret) {
if explicitSecret {
return nil, fmt.Errorf("JWT_SECRET 过弱、过短或为已知占位/确定性旧值,请设置至少 32 字符的强随机密钥(例如:openssl rand -base64 32)")
}
if cfg.JWTSecret != "" {
log.Println("[config] 检测到弱/确定性 .jwt_secret,正在轮换为加密随机密钥")
}
secret, err := generateSecret(32)
if err != nil {
return nil, fmt.Errorf("生成 JWT 密钥失败: %w", err)
}
cfg.JWTSecret = secret
if err := os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600); err != nil {
return nil, fmt.Errorf("写入 .jwt_secret 失败: %w", err)
} }
} }
@@ -306,11 +323,36 @@ func rejectStrayDataDir(dataDir string) error {
return nil return nil
} }
func generateSecret(n int) string { // generateSecret 用 crypto/rand 生成 URL-safe 密钥(约 n 字节熵)。
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" func generateSecret(n int) (string, error) {
b := make([]byte, n) if n < 32 {
for i := range b { n = 32
b[i] = chars[i%len(chars)]
} }
return string(b) b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// isWeakJWTSecret 拒绝过短、占位示例、以及历史上确定性 generateSecret 产物。
func isWeakJWTSecret(s string) bool {
s = strings.TrimSpace(s)
if len(s) < 32 {
return true
}
switch strings.ToLower(s) {
case "change-me",
"your-jwt-secret-change-me",
"change-me-to-a-long-random-string",
"secret",
"jwt-secret":
return true
}
// 旧版确定性循环字符表:abcdefghijklmnopqrstuvwxyzABCDEF...
const legacyCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
if len(s) <= len(legacyCharset) && s == legacyCharset[:len(s)] {
return true
}
return false
} }

View File

@@ -114,14 +114,61 @@ func TestAllowOriginDevAndProd(t *testing.T) {
} }
} }
const testJWTSecret = "unit-test-jwt-secret-32chars-min!!" // 满足 ≥32,非生产用
func TestIsWeakJWTSecret(t *testing.T) {
if !isWeakJWTSecret("") || !isWeakJWTSecret("short") {
t.Fatal("空/过短应判弱")
}
if !isWeakJWTSecret("abcdefghijklmnopqrstuvwxyzABCDEF") {
t.Fatal("旧确定性密钥应判弱")
}
if !isWeakJWTSecret("change-me") {
t.Fatal("占位值应判弱")
}
if isWeakJWTSecret(testJWTSecret) {
t.Fatal("测试强密钥不应判弱")
}
}
func TestGenerateSecretNotDeterministic(t *testing.T) {
a, err := generateSecret(32)
if err != nil {
t.Fatal(err)
}
b, err := generateSecret(32)
if err != nil {
t.Fatal(err)
}
if a == b {
t.Fatal("连续两次生成不应相同")
}
if isWeakJWTSecret(a) {
t.Fatalf("生成结果被误判为弱: %q", a)
}
}
func TestParseRejectsWeakExplicitSecret(t *testing.T) {
work := t.TempDir()
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = change-me\n"), 0600); err != nil {
t.Fatal(err)
}
t.Setenv("JIANG13_WORK_PATH", work)
t.Setenv("JWT_SECRET", "")
if _, err := Parse(); err == nil {
t.Fatal("显式弱密钥应拒绝启动")
}
}
func TestParseSettingsMasterKeyFromIni(t *testing.T) { func TestParseSettingsMasterKeyFromIni(t *testing.T) {
work := t.TempDir() work := t.TempDir()
key := "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" 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 { if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = "+testJWTSecret+"\nSETTINGS_MASTER_KEY = "+key+"\n"), 0600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Setenv("JIANG13_WORK_PATH", work) t.Setenv("JIANG13_WORK_PATH", work)
t.Setenv("SETTINGS_MASTER_KEY", "") t.Setenv("SETTINGS_MASTER_KEY", "")
t.Setenv("JWT_SECRET", "")
cfg, err := Parse() cfg, err := Parse()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -149,7 +196,7 @@ func TestParseSiteURLAndDataDir(t *testing.T) {
t.Setenv("SITE_URL", "https://bbs.example.com/") t.Setenv("SITE_URL", "https://bbs.example.com/")
t.Setenv("CORS_ORIGINS", " https://a.example.com ,https://b.example.com/ ") t.Setenv("CORS_ORIGINS", " https://a.example.com ,https://b.example.com/ ")
t.Setenv("DATA_DIR", data) t.Setenv("DATA_DIR", data)
t.Setenv("JWT_SECRET", "test-secret-not-for-prod") t.Setenv("JWT_SECRET", testJWTSecret)
cfg, err := Parse() cfg, err := Parse()
if err != nil { if err != nil {
@@ -210,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
func TestParseSiteURLFromIni(t *testing.T) { func TestParseSiteURLFromIni(t *testing.T) {
work := t.TempDir() 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" body := "[security]\nJWT_SECRET = "+testJWTSecret+"\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 { if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -59,27 +59,46 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
}) })
} }
// clearAuthCookies 清除当前契约下的三枚认证 cookie(名称随 ConfigureCookieNames,Path=/)。 // clearAuthCookies 清除当前契约下的认证 cookie,并顺带清历史 Path=/api/auth 与无前缀名。
func clearAuthCookies(c *gin.Context, secure bool) { func clearAuthCookies(c *gin.Context, secure bool) {
cookies := []struct { type ck struct {
name string name string
httpOnly bool httpOnly bool
}{ }
cookies := []ck{
{service.CookieName, true}, {service.CookieName, true},
{service.RefreshCookieName, true}, {service.RefreshCookieName, true},
{service.CSRFCookieName, false}, {service.CSRFCookieName, false},
// 历史无 __Host- 前缀(切换 DEV_MODE 后避免双 cookie)
{"j13_token", true},
{"j13_refresh", true},
{"j13_csrf", false},
{"__Host-j13_token", true},
{"__Host-j13_refresh", true},
{"__Host-j13_csrf", false},
} }
paths := []string{"/", "/api/auth"}
for _, ck := range cookies { for _, ck := range cookies {
http.SetCookie(c.Writer, &http.Cookie{ for _, path := range paths {
Name: ck.name, // __Host- 要求 Secure=true 且 Path=/;非 / 路径跳过 Host 前缀名
Value: "", if strings.HasPrefix(ck.name, "__Host-") && path != "/" {
Path: "/", continue
MaxAge: -1, }
HttpOnly: ck.httpOnly, sec := secure
Secure: secure, if strings.HasPrefix(ck.name, "__Host-") {
SameSite: http.SameSiteLaxMode, sec = true
Expires: time.Unix(0, 0), }
}) http.SetCookie(c.Writer, &http.Cookie{
Name: ck.name,
Value: "",
Path: path,
MaxAge: -1,
HttpOnly: ck.httpOnly,
Secure: sec,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
}
} }
} }
@@ -233,7 +252,7 @@ func (h *Handlers) Refresh(c *gin.Context) {
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。 // access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
func (h *Handlers) Logout(c *gin.Context) { func (h *Handlers) Logout(c *gin.Context) {
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" { if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
h.Auth.RevokeRefreshToken(refreshToken) _ = h.Auth.RevokeRefreshToken(refreshToken)
} }
clearAuthCookies(c, !h.Cfg.DevMode) clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{"message": "已登出"}) c.JSON(http.StatusOK, gin.H{"message": "已登出"})

View File

@@ -13,7 +13,6 @@ func (h *Handlers) Health(c *gin.Context) {
if err := model.PingDB(); err != nil { if err := model.PingDB(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "error", "status": "error",
"error": err.Error(),
"version": version.Version, "version": version.Version,
}) })
return return

View File

@@ -538,10 +538,11 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"recommended": rec}) c.JSON(http.StatusOK, gin.H{"recommended": rec})
} }
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false // requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准
func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool { func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool {
claims := middleware.CurrentUser(c) claims := middleware.CurrentUser(c)
if model.RoleLevel(model.Role(claims.Role)) < model.RoleLevel(model.RoleAdmin) { actor, err := h.Auth.LoadActor(claims.ID)
if err != nil || model.RoleLevel(actor.Role) < model.RoleLevel(model.RoleAdmin) {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"}) c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
return false return false
} }

View File

@@ -7,10 +7,8 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"time" "time"
"github.com/freefire/jiang13-bbs/markdown"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -68,20 +66,6 @@ func InitDB(dsn string) error {
return fmt.Errorf("自动迁移失败: %w", err) return fmt.Errorf("自动迁移失败: %w", err)
} }
// 旧帖整帖可见性 → 正文 [hide] 块(幂等)
if err := migratePostContentAccessToHideBlocks(db); err != nil {
return fmt.Errorf("正文隐藏块迁移失败: %w", err)
}
// 旧式 :::hide / :::timeline → 行级 BBCode(幂等)
if err := migrateLegacyDirectiveSyntax(db); err != nil {
return fmt.Errorf("短代码语法迁移失败: %w", err)
}
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
if err := backfillPointsFromCheckin(db); err != nil {
return fmt.Errorf("积分余额回填失败: %w", err)
}
// RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长; // RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长;
// 已存在 owner 时不动数据,保证幂等 // 已存在 owner 时不动数据,保证幂等
if err := ensureOwnerRole(db); err != nil { if err := ensureOwnerRole(db); err != nil {
@@ -310,46 +294,6 @@ func ensureOwnerRole(db *gorm.DB) error {
return nil return nil
} }
// backfillPointsFromCheckin 把历史签到积分写入 User.Points(幂等:仅 points=0 且有签到)
func backfillPointsFromCheckin(db *gorm.DB) error {
type row struct {
UserID uint
Total int
}
var rows []row
if err := db.Raw(`
SELECT c.user_id, COALESCE(SUM(c.points), 0)::int AS total
FROM checkins c
INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL AND u.points = 0
GROUP BY c.user_id
HAVING COALESCE(SUM(c.points), 0) > 0
`).Scan(&rows).Error; err != nil {
return err
}
for _, r := range rows {
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&User{}).Where("id = ? AND points = 0", r.UserID).
Update("points", r.Total).Error; err != nil {
return err
}
return tx.Create(&PointLedger{
UserID: r.UserID,
Delta: r.Total,
Balance: r.Total,
Reason: PointReasonMigrateCheckin,
Note: "历史签到积分回填",
}).Error
})
if err != nil {
return err
}
}
if len(rows) > 0 {
log.Printf("[model] 已回填 %d 名用户的签到积分余额", len(rows))
}
return nil
}
// seedDefaultBoards 写入默认板块 // seedDefaultBoards 写入默认板块
func seedDefaultBoards(db *gorm.DB) { func seedDefaultBoards(db *gorm.DB) {
defaults := []Board{ defaults := []Board{
@@ -455,134 +399,3 @@ func ensureDefaultChatMemberships(db *gorm.DB) error {
} }
return nil return nil
} }
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 [hide] 块(幂等)。
func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
var posts []Post
if err := db.Unscoped().
Where("content_access IN ? AND deleted_at IS NULL",
[]string{ContentAccessLogin, ContentAccessReply, ContentAccessPoints}).
Find(&posts).Error; err != nil {
return err
}
n := 0
for i := range posts {
p := &posts[i]
if markdown.HasHideBlocks(p.Content) {
continue
}
kind := NormalizeContentAccess(p.ContentAccess)
pts := 0
if kind == ContentAccessPoints {
pts = p.AccessPoints
if pts <= 0 {
pts = 1
}
}
wrapped := markdown.WrapContentAsHide(kind, pts, p.Content)
if err := db.Model(&Post{}).Where("id = ?", p.ID).
Update("content", wrapped).Error; err != nil {
return err
}
n++
}
if n > 0 {
log.Printf("[model] 旧帖可见性已迁入隐藏块:%d 篇", n)
}
return nil
}
// migrateLegacyDirectiveSyntax 将正文中的 :::hide / :::timeline 改写为 BBCode(幂等)。
func migrateLegacyDirectiveSyntax(db *gorm.DB) error {
type row struct {
ID uint
Content string
}
rewriteTable := func(table string, rows []row) (int, error) {
n := 0
for _, r := range rows {
if !strings.Contains(r.Content, ":::") {
continue
}
next := markdown.RewriteLegacyDirectives(r.Content)
if next == r.Content {
continue
}
if err := db.Table(table).Where("id = ?", r.ID).
Update("content", next).Error; err != nil {
return n, err
}
n++
}
return n, nil
}
var posts []row
if err := db.Model(&Post{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&posts).Error; err != nil {
return err
}
if n, err := rewriteTable("posts", posts); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 帖子短代码改写:%d 篇", n)
}
var comments []row
if err := db.Model(&Comment{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&comments).Error; err != nil {
return err
}
if n, err := rewriteTable("comments", comments); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 评论短代码改写:%d 条", n)
}
var anns []row
if err := db.Model(&Announcement{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&anns).Error; err != nil {
return err
}
if n, err := rewriteTable("announcements", anns); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 公告短代码改写:%d 条", n)
}
var pages []row
if err := db.Model(&SitePage{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&pages).Error; err != nil {
return err
}
if n, err := rewriteTable("site_pages", pages); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 站点页短代码改写:%d 篇", n)
}
var hist []struct {
ID uint
OldContent string
}
if err := db.Model(&CommentEditHistory{}).Select("id", "old_content").
Where("old_content LIKE ?", "%:::%").Find(&hist).Error; err != nil {
return err
}
nHist := 0
for _, r := range hist {
next := markdown.RewriteLegacyDirectives(r.OldContent)
if next == r.OldContent {
continue
}
if err := db.Model(&CommentEditHistory{}).Where("id = ?", r.ID).
Update("old_content", next).Error; err != nil {
return err
}
nHist++
}
if nHist > 0 {
log.Printf("[model] 评论历史短代码改写:%d 条", nHist)
}
return nil
}

View File

@@ -155,8 +155,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode) pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword) 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.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login) pubAPI.POST("/login", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护) // refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh) pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh)
// 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验) // 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验)

View File

@@ -47,7 +47,8 @@ var (
// ConfigureCookieNames 生产环境(HTTPS)启用 __Host- 前缀: // ConfigureCookieNames 生产环境(HTTPS)启用 __Host- 前缀:
// 浏览器强制 Secure、Path=/、禁止 Domain,进一步收窄 cookie 作用面。 // 浏览器强制 Secure、Path=/、禁止 Domain,进一步收窄 cookie 作用面。
// 必须在路由注册前调用一次;前端按 NODE_ENV=production 同步启用。 // 必须在路由注册前调用一次;前端以 DEV_MODE 为准(与后端 !DevMode 对齐),
// 未设置 DEV_MODE 时回退 NODE_ENV=production。
func ConfigureCookieNames(useHostPrefix bool) { func ConfigureCookieNames(useHostPrefix bool) {
if !useHostPrefix { if !useHostPrefix {
return return
@@ -84,43 +85,51 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword( var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost) []byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
// Register 用户注册 // Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
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 { if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
return nil, err return nil, err
} }
// 检查用户名是否已存在
var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
return nil, errors.New("用户名已被使用")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var n int64 var user model.User
if err := s.db.Model(&model.User{}).Count(&n).Error; err != nil { err = s.db.Transaction(func(tx *gorm.DB) error {
// 与站长角色迁移共用锁空间,避免并发首注抢多 owner
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(0x6a31336f776e6572)).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("用户名已被使用")
}
var n int64
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
return err
}
role := model.RoleUser
if n == 0 {
role = model.RoleOwner
}
user = model.User{
Username: username,
Email: email,
Password: string(hashed),
Nickname: username,
Role: role,
}
return tx.Create(&user).Error
})
if err != nil {
return nil, err return nil, err
} }
role := model.RoleUser return &user, nil
if n == 0 {
role = model.RoleOwner
}
user := &model.User{
Username: username,
Email: email,
Password: string(hashed),
Nickname: username,
Role: role,
}
if err := s.db.Create(user).Error; err != nil {
return nil, err
}
return user, nil
} }
// Login 用户登录,返回 access token + refresh token + user // Login 用户登录,返回 access token + refresh token + user
@@ -479,18 +488,22 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
} }
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用) // RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
func (s *AuthService) RevokeRefreshToken(token string) { func (s *AuthService) RevokeRefreshToken(token string) error {
if token == "" { if token == "" {
return return nil
} }
s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)). return s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)).
Updates(map[string]any{"revoked": true, "token_cipher": ""}) Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error
} }
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用) // RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用)
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) { func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) error {
s.db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false). return revokeAllUserRefreshTokensTx(s.db, userID)
Updates(map[string]any{"revoked": true, "token_cipher": ""}) }
func revokeAllUserRefreshTokensTx(db *gorm.DB, userID uint) error {
return db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false).
Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error
} }
// PurgeExpiredRefreshTokens 物理清理过期或吊销超过保留期的行 // PurgeExpiredRefreshTokens 物理清理过期或吊销超过保留期的行
@@ -518,18 +531,18 @@ func StartRefreshTokenCleanup(db *gorm.DB) {
}() }()
} }
// IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效 // IncrementTokenVersion 递增用户 token 版本并吊销全部 refresh(单事务)
// 用于:改密码、封禁用户、管理员强制下线
func (s *AuthService) IncrementTokenVersion(userID uint) error { func (s *AuthService) IncrementTokenVersion(userID uint) error {
result := s.db.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("token_version", gorm.Expr("token_version + 1")) return s.db.Transaction(func(tx *gorm.DB) error {
if result.Error != nil { if err := tx.Model(&model.User{}).Where("id = ?", userID).
return result.Error UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
} return err
s.RevokeAllUserRefreshTokens(userID) }
return nil return revokeAllUserRefreshTokensTx(tx, userID)
})
} }
// ChangePassword 修改密码:校验旧密码,更新新密码,递增 token_version 使旧 token 失效 // ChangePassword 修改密码:校验旧密码,更新新密码,事务内使旧会话全部失效
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error { func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
var user model.User var user model.User
if err := s.db.First(&user, userID).Error; err != nil { if err := s.db.First(&user, userID).Error; err != nil {
@@ -545,15 +558,20 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
if err != nil { if err != nil {
return err return err
} }
result := s.db.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed)) return s.db.Transaction(func(tx *gorm.DB) error {
if result.Error != nil { result := tx.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
return result.Error if result.Error != nil {
} return result.Error
if result.RowsAffected == 0 { }
return errors.New("密码更新失败") if result.RowsAffected == 0 {
} return errors.New("密码更新失败")
// 递增 token_version,使所有旧 JWT 和 refresh token 失效 }
return s.IncrementTokenVersion(userID) if err := tx.Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
return err
}
return revokeAllUserRefreshTokensTx(tx, userID)
})
} }
// UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar) // UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar)

View File

@@ -38,7 +38,14 @@ func (o *Operations) ResetPassword(email, code, password string) error {
if len(users) != 1 { if len(users) != 1 {
return errors.New("账号不可用") 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 if e := tx.Model(&model.User{}).Where("id = ?", users[0].ID).Updates(map[string]any{
"password": string(hash),
"token_version": gorm.Expr("token_version + 1"),
}).Error; e != nil {
return e
}
return tx.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", users[0].ID, false).
Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error
}) })
} }
func (o *Operations) Diagnostics(ctx context.Context) map[string]any { func (o *Operations) Diagnostics(ctx context.Context) map[string]any {

View File

@@ -67,6 +67,12 @@ func (s *SettingService) ImportTimelineFromGit(urls []string, followPages bool)
client := &http.Client{ client := &http.Client{
Timeout: timelineGitHTTPTimeout, Timeout: timelineGitHTTPTimeout,
Transport: &http.Transport{
// 解析后按公网 IP 拨号,避免 DNS rebinding(与 SMTP/S3 safeDial 同思路)
DialContext: publicOnlyDial,
TLSHandshakeTimeout: timelineGitHTTPTimeout,
ForceAttemptHTTP2: true,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 { if len(via) >= 3 {
return errors.New("重定向过多") return errors.New("重定向过多")
@@ -656,6 +662,35 @@ func assertSafeHTTPSURL(u *url.URL) error {
return nil return nil
} }
// publicOnlyDial 先解析主机、拒绝私网,再按 IP 拨号,关闭 DNS rebinding 窗口。
func publicOnlyDial(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errors.New("连接地址无效")
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(ips) == 0 {
return nil, errors.New("地址解析失败")
}
var last error
dialer := &net.Dialer{Timeout: timelineGitHTTPTimeout}
for _, a := range ips {
if !isPublicIP(a.IP) {
last = errors.New("禁止访问内网地址")
continue
}
c, e := dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
if e == nil {
return c, nil
}
last = e
}
if last == nil {
last = errors.New("禁止访问内网地址")
}
return nil, last
}
func isPublicIP(ip net.IP) bool { func isPublicIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return false return false
@@ -664,6 +699,10 @@ func isPublicIP(ip net.IP) bool {
if ip4[0] == 169 && ip4[1] == 254 { if ip4[0] == 169 && ip4[1] == 254 {
return false return false
} }
// CGNAT / 文档网段等
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
return false
}
} }
return true return true
} }

View File

@@ -22,8 +22,9 @@ POSTGRES_USER=jiang13
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
POSTGRES_DB=jiang13 POSTGRES_DB=jiang13
# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。 # 生产必须显式设置 ≥32 字符强随机(openssl rand -base64 32)。
JWT_SECRET=change-me-to-a-long-random-string # 占位值 change-me / 过短密钥会被拒绝启动;也可留空由 data/.jwt_secret 自动生成。
JWT_SECRET=
# 正式站点 origin,生产使用 HTTPS;邮件 / Canonical / Sitemap 共用 # 正式站点 origin,生产使用 HTTPS;邮件 / Canonical / Sitemap 共用
# 32 字节随机值的标准 Base64;不填写时禁止保存邮件/S3凭据。独立备份,禁止提交真实密钥。 # 32 字节随机值的标准 Base64;不填写时禁止保存邮件/S3凭据。独立备份,禁止提交真实密钥。

View File

@@ -15,6 +15,7 @@ ARG VERSION=dev
ARG BACKEND_URL=http://api:3001 ARG BACKEND_URL=http://api:3001
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production \ NODE_ENV=production \
DEV_MODE=false \
NEXT_PUBLIC_APP_VERSION=${VERSION} \ NEXT_PUBLIC_APP_VERSION=${VERSION} \
BACKEND_URL=${BACKEND_URL} BACKEND_URL=${BACKEND_URL}
RUN npm run build RUN npm run build
@@ -22,6 +23,7 @@ RUN npm run build
FROM node:22-bookworm-slim AS runner FROM node:22-bookworm-slim AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
DEV_MODE=false \
PORT=3000 \ PORT=3000 \
HOSTNAME=0.0.0.0 \ HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1 \ NEXT_TELEMETRY_DISABLED=1 \

View File

@@ -67,6 +67,7 @@ services:
BACKEND_URL: http://api:3001 BACKEND_URL: http://api:3001
SITE_URL: ${SITE_URL:-https://localhost} SITE_URL: ${SITE_URL:-https://localhost}
NODE_ENV: production NODE_ENV: production
DEV_MODE: "false"
PORT: "3000" PORT: "3000"
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
ports: ports:

View File

@@ -6,9 +6,14 @@
NEXT_PUBLIC_API_URL=http://localhost:3001 NEXT_PUBLIC_API_URL=http://localhost:3001
# 仅服务端可见(middleware 与 SSR 优先使用)。 # 仅服务端可见(middleware 与 SSR 优先使用)。
# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址, # 生产构建【必须】显式配置;缺省会直接失败,禁止烤进 localhost。
# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。 # Docker 官方镜像构建期注入 http://api:3001。
# BACKEND_URL=https://your-backend.example.com # BACKEND_URL=http://localhost:3001
# 与后端 DEV_MODE 对齐:true 时 cookie 无 __Host- 前缀。
# 本地 next start 对着 DEV_MODE=true 的 Go 时请设为 true,避免 cookie 名分叉。
# 官方 Docker 写死 false。
DEV_MODE=true
# 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。 # 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。
# NEXT_PUBLIC_APP_VERSION=0.1.0 # NEXT_PUBLIC_APP_VERSION=0.1.0

View File

@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react"; import { useRef, type KeyboardEvent, type ReactNode } from "react";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import { import {
AdminPageHeader, AdminPageHeader,
@@ -19,32 +19,11 @@ const NAV: { href: string; label: string }[] = [
{ href: "/admin/settings/maintenance", 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 }) { export default function SettingsShell({ children }: { children: ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]); 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) => { const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
if ( if (
e.key !== "ArrowDown" && e.key !== "ArrowDown" &&

View File

@@ -5,24 +5,7 @@ export const metadata: Metadata = {
title: "站点设置", title: "站点设置",
}; };
interface PageProps { /** /admin/settings 统一进入基本信息子路由 */
searchParams: Promise<{ section?: string }>; export default async function SettingsIndexPage() {
} redirect("/admin/settings/basic");
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");
} }

View File

@@ -1,17 +1,56 @@
"use client"; "use client";
import {useEffect,useState} from "react";
import {usePathname} from "next/navigation"; import { useEffect, useState } from "react";
import {apiOperations} from "@/lib/api"; import { usePathname } from "next/navigation";
export default function OperationalBanner(){ import { apiOperations } from "@/lib/api";
const path=usePathname(); import { realtime, RT_SETTINGS_CHANGED } from "@/lib/realtime";
const [notice,setNotice]=useState(""); import {
useEffect(()=>{ VISIBLE_RECONCILE_GAP_MS,
let active=true; onDocumentVisible,
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(()=>{});}; } from "@/lib/visibilityReconcile";
refresh();const timer=setInterval(refresh,30000);
document.addEventListener("visibilitychange",refresh); /** 只读维护条:WS settings:changed 优先,回前台再校准(无定时轮询) */
return ()=>{active=false;clearInterval(timer);document.removeEventListener("visibilitychange",refresh);}; export default function OperationalBanner() {
},[path]); const path = usePathname();
if(path.startsWith("/admin") || !notice)return null; const [notice, setNotice] = useState("");
return <div role="status" className="panel mx-auto my-3 w-full max-w-5xl p-4 text-sm"><strong>只读模式:</strong>{notice}</div>;
useEffect(() => {
let active = true;
const apply = (mode: string | undefined, message: string | undefined) => {
if (!active) return;
setNotice(
mode === "readonly"
? message || "站点暂时只读,可以浏览,暂不能提交或修改内容。"
: ""
);
};
const refresh = () => {
if (document.visibilityState !== "visible") return;
void apiOperations<{
maintenance: { mode: string; title: string; message: string };
}>("/api/site-state")
.then((s) => apply(s.maintenance?.mode, s.maintenance?.message))
.catch(() => {});
};
refresh();
const offPush = realtime.on(RT_SETTINGS_CHANGED, () => refresh());
const offVisible = onDocumentVisible(refresh, VISIBLE_RECONCILE_GAP_MS);
return () => {
active = false;
offPush();
offVisible();
};
}, [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>
);
} }

View File

@@ -378,6 +378,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme
/* ---------- 友情链接 ---------- */ /* ---------- 友情链接 ---------- */
function FriendLinks() { function FriendLinks() {
if (FRIEND_LINKS.length === 0) return null;
return ( return (
<section className="j13-module" aria-label="友情链接"> <section className="j13-module" aria-label="友情链接">
<p className="j13-module-title mb-3"> <p className="j13-module-title mb-3">

View File

@@ -21,10 +21,19 @@ export type { FooterLink, FooterLinksAlign };
// API 基础配置 // API 基础配置
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端, // 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。 // 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
// SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置, // SSR/middleware 直连后端:优先 BACKEND_URL;生产禁止静默回落 localhost。
// localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。 const API_BASE = (() => {
const API_BASE = const fromEnv =
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "";
if (fromEnv) return fromEnv;
if (process.env.NODE_ENV === "production") {
if (typeof window === "undefined") {
throw new Error("生产环境必须设置 BACKEND_URL(SSR 直连后端)");
}
return "";
}
return "http://localhost:3001";
})();
// SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染 // SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染
const SSR_TIMEOUT_MS = 8000; const SSR_TIMEOUT_MS = 8000;

View File

@@ -1,7 +1,14 @@
// 认证 cookie 名(前后端必须严格一致): // 认证 cookie 名(前后端必须严格一致):
// 生产构建(HTTPS)启用 __Host- 前缀,浏览器强制 Secure + Path=/ + 无 Domain; // 与后端 ConfigureCookieNames(!DevMode) 对齐——显式 DEV_MODE=true|1 禁用 __Host-;
// 后端按 !DevMode 同步启用(见 backend/service/auth.go ConfigureCookieNames)。 // 显式 DEV_MODE=false|0 启用;未设置时回退 NODE_ENV=production(官方镜像两者同时满足)。
const HOST_PREFIX = process.env.NODE_ENV === "production" ? "__Host-" : ""; function hostCookiePrefix(): string {
const dm = (process.env.DEV_MODE || "").trim().toLowerCase();
if (dm === "true" || dm === "1") return "";
if (dm === "false" || dm === "0") return "__Host-";
return process.env.NODE_ENV === "production" ? "__Host-" : "";
}
const HOST_PREFIX = hostCookiePrefix();
export const TOKEN_COOKIE = `${HOST_PREFIX}j13_token`; export const TOKEN_COOKIE = `${HOST_PREFIX}j13_token`;
export const REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`; export const REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`;

View File

@@ -1,11 +1,7 @@
// 友情链接(站点级静态配置)。 // 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。
// TODO: 替换为真实友链名称与地址;后续如需后台可配,可升级为接口/设置项。
export interface FriendLink { export interface FriendLink {
name: string; name: string;
href: string; href: string;
} }
export const FRIEND_LINKS: FriendLink[] = [ export const FRIEND_LINKS: FriendLink[] = [];
{ name: "大佬论坛", href: "#" },
{ name: "Leo论坛", href: "#" },
];

View File

@@ -1,8 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
// 后端地址:开发环境兜底 localhost:3001,生产部署必须显式配置 BACKEND_URL // 后端地址:开发可兜底 localhost:3001;生产构建必须显式 BACKEND_URL(禁止静默烤进 localhost)
const isProd = process.env.NODE_ENV === "production";
const API_TARGET = const API_TARGET =
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; process.env.BACKEND_URL ||
process.env.NEXT_PUBLIC_API_URL ||
(isProd ? "" : "http://localhost:3001");
if (!API_TARGET) {
throw new Error(
"生产构建必须设置 BACKEND_URL(例如 Docker 内 http://api:3001),禁止回落 localhost"
);
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// 官方 Docker 运行时:最小产物,不含完整 node_modules。next dev 忽略此项。 // 官方 Docker 运行时:最小产物,不含完整 node_modules。next dev 忽略此项。