开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
# PostgreSQL 连接串
|
||||
DB_DSN=postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
|
||||
|
||||
# JWT 签名密钥(生产环境务必修改)
|
||||
JWT_SECRET=your-jwt-secret-change-me
|
||||
# JWT 签名密钥(≥32 字符强随机;禁止 change-me 等占位,留空则自动 crypto/rand 生成)
|
||||
# JWT_SECRET=
|
||||
|
||||
# 服务端口
|
||||
HTTP_PORT=3001
|
||||
|
||||
@@ -6,7 +6,7 @@ HTTP_PORT = 3001
|
||||
DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
|
||||
|
||||
[security]
|
||||
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
|
||||
; 留空则用 crypto/rand 生成并持久化到 data/.jwt_secret;生产务必显式指定 ≥32 字符强随机值
|
||||
JWT_SECRET =
|
||||
; 32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。
|
||||
; 环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -35,7 +38,7 @@ type appIniKey struct {
|
||||
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: "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: "paths", key: "DATA", value: "data"},
|
||||
{section: "app", key: "DEV_MODE", value: "true"},
|
||||
@@ -112,14 +115,28 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("创建数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
// JWT 密钥:留空则自动生成并持久化
|
||||
if strings.TrimSpace(cfg.JWTSecret) == "" {
|
||||
// JWT 密钥:显式配置优先;留空则从 data/.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 {
|
||||
cfg.JWTSecret = string(data)
|
||||
} else {
|
||||
cfg.JWTSecret = generateSecret(32)
|
||||
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
|
||||
cfg.JWTSecret = strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func generateSecret(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[i%len(chars)]
|
||||
// generateSecret 用 crypto/rand 生成 URL-safe 密钥(约 n 字节熵)。
|
||||
func generateSecret(n int) (string, error) {
|
||||
if n < 32 {
|
||||
n = 32
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
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.Setenv("JIANG13_WORK_PATH", work)
|
||||
t.Setenv("SETTINGS_MASTER_KEY", "")
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
cfg, err := Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -149,7 +196,7 @@ func TestParseSiteURLAndDataDir(t *testing.T) {
|
||||
t.Setenv("SITE_URL", "https://bbs.example.com/")
|
||||
t.Setenv("CORS_ORIGINS", " https://a.example.com ,https://b.example.com/ ")
|
||||
t.Setenv("DATA_DIR", data)
|
||||
t.Setenv("JWT_SECRET", "test-secret-not-for-prod")
|
||||
t.Setenv("JWT_SECRET", testJWTSecret)
|
||||
|
||||
cfg, err := Parse()
|
||||
if err != nil {
|
||||
@@ -210,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
|
||||
|
||||
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"
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -59,28 +59,47 @@ 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) {
|
||||
cookies := []struct {
|
||||
type ck struct {
|
||||
name string
|
||||
httpOnly bool
|
||||
}{
|
||||
}
|
||||
cookies := []ck{
|
||||
{service.CookieName, true},
|
||||
{service.RefreshCookieName, true},
|
||||
{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 _, path := range paths {
|
||||
// __Host- 要求 Secure=true 且 Path=/;非 / 路径跳过 Host 前缀名
|
||||
if strings.HasPrefix(ck.name, "__Host-") && path != "/" {
|
||||
continue
|
||||
}
|
||||
sec := secure
|
||||
if strings.HasPrefix(ck.name, "__Host-") {
|
||||
sec = true
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: ck.name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Path: path,
|
||||
MaxAge: -1,
|
||||
HttpOnly: ck.httpOnly,
|
||||
Secure: secure,
|
||||
Secure: sec,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
@@ -233,7 +252,7 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
||||
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
|
||||
func (h *Handlers) Logout(c *gin.Context) {
|
||||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
||||
h.Auth.RevokeRefreshToken(refreshToken)
|
||||
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
||||
}
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
|
||||
|
||||
@@ -13,7 +13,6 @@ func (h *Handlers) Health(c *gin.Context) {
|
||||
if err := model.PingDB(); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
"version": version.Version,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -538,10 +538,11 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"recommended": rec})
|
||||
}
|
||||
|
||||
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false
|
||||
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准
|
||||
func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool {
|
||||
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": "仅管理员可操作"})
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/markdown"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -68,20 +66,6 @@ func InitDB(dsn string) error {
|
||||
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,通常即首个注册账号)升级为站长;
|
||||
// 已存在 owner 时不动数据,保证幂等
|
||||
if err := ensureOwnerRole(db); err != nil {
|
||||
@@ -310,46 +294,6 @@ func ensureOwnerRole(db *gorm.DB) error {
|
||||
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 写入默认板块
|
||||
func seedDefaultBoards(db *gorm.DB) {
|
||||
defaults := []Board{
|
||||
@@ -455,134 +399,3 @@ func ensureDefaultChatMemberships(db *gorm.DB) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -155,8 +155,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
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("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||
pubAPI.POST("/register", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||
pubAPI.POST("/login", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
|
||||
pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh)
|
||||
// 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验)
|
||||
|
||||
@@ -47,7 +47,8 @@ var (
|
||||
|
||||
// ConfigureCookieNames 生产环境(HTTPS)启用 __Host- 前缀:
|
||||
// 浏览器强制 Secure、Path=/、禁止 Domain,进一步收窄 cookie 作用面。
|
||||
// 必须在路由注册前调用一次;前端按 NODE_ENV=production 同步启用。
|
||||
// 必须在路由注册前调用一次;前端以 DEV_MODE 为准(与后端 !DevMode 对齐),
|
||||
// 未设置 DEV_MODE 时回退 NODE_ENV=production。
|
||||
func ConfigureCookieNames(useHostPrefix bool) {
|
||||
if !useHostPrefix {
|
||||
return
|
||||
@@ -84,43 +85,51 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
||||
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
||||
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
||||
|
||||
// Register 用户注册
|
||||
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user model.User
|
||||
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 := s.db.Model(&model.User{}).Count(&n).Error; err != nil {
|
||||
return nil, err
|
||||
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{
|
||||
user = model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: string(hashed),
|
||||
Nickname: username,
|
||||
Role: role,
|
||||
}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
return tx.Create(&user).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 access token + refresh token + user
|
||||
@@ -479,18 +488,22 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
||||
}
|
||||
|
||||
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
|
||||
func (s *AuthService) RevokeRefreshToken(token string) {
|
||||
func (s *AuthService) RevokeRefreshToken(token string) error {
|
||||
if token == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)).
|
||||
Updates(map[string]any{"revoked": true, "token_cipher": ""})
|
||||
return s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)).
|
||||
Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error
|
||||
}
|
||||
|
||||
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用)
|
||||
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) {
|
||||
s.db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false).
|
||||
Updates(map[string]any{"revoked": true, "token_cipher": ""})
|
||||
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) error {
|
||||
return revokeAllUserRefreshTokensTx(s.db, userID)
|
||||
}
|
||||
|
||||
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 物理清理过期或吊销超过保留期的行
|
||||
@@ -518,18 +531,18 @@ func StartRefreshTokenCleanup(db *gorm.DB) {
|
||||
}()
|
||||
}
|
||||
|
||||
// IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效
|
||||
// 用于:改密码、封禁用户、管理员强制下线
|
||||
// IncrementTokenVersion 递增用户 token 版本并吊销全部 refresh(单事务)
|
||||
func (s *AuthService) IncrementTokenVersion(userID uint) error {
|
||||
result := s.db.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("token_version", gorm.Expr("token_version + 1"))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).
|
||||
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 {
|
||||
var user model.User
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
result := s.db.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -38,7 +38,14 @@ func (o *Operations) ResetPassword(email, code, password string) error {
|
||||
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
|
||||
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 {
|
||||
|
||||
@@ -67,6 +67,12 @@ func (s *SettingService) ImportTimelineFromGit(urls []string, followPages bool)
|
||||
|
||||
client := &http.Client{
|
||||
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 {
|
||||
if len(via) >= 3 {
|
||||
return errors.New("重定向过多")
|
||||
@@ -656,6 +662,35 @@ func assertSafeHTTPSURL(u *url.URL) error {
|
||||
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 {
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
@@ -664,6 +699,10 @@ func isPublicIP(ip net.IP) bool {
|
||||
if ip4[0] == 169 && ip4[1] == 254 {
|
||||
return false
|
||||
}
|
||||
// CGNAT / 文档网段等
|
||||
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ POSTGRES_USER=jiang13
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_DB=jiang13
|
||||
|
||||
# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
# 生产必须显式设置 ≥32 字符强随机(openssl rand -base64 32)。
|
||||
# 占位值 change-me / 过短密钥会被拒绝启动;也可留空由 data/.jwt_secret 自动生成。
|
||||
JWT_SECRET=
|
||||
|
||||
# 正式站点 origin,生产使用 HTTPS;邮件 / Canonical / Sitemap 共用
|
||||
# 32 字节随机值的标准 Base64;不填写时禁止保存邮件/S3凭据。独立备份,禁止提交真实密钥。
|
||||
|
||||
@@ -15,6 +15,7 @@ ARG VERSION=dev
|
||||
ARG BACKEND_URL=http://api:3001
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production \
|
||||
DEV_MODE=false \
|
||||
NEXT_PUBLIC_APP_VERSION=${VERSION} \
|
||||
BACKEND_URL=${BACKEND_URL}
|
||||
RUN npm run build
|
||||
@@ -22,6 +23,7 @@ RUN npm run build
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
DEV_MODE=false \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
BACKEND_URL: http://api:3001
|
||||
SITE_URL: ${SITE_URL:-https://localhost}
|
||||
NODE_ENV: production
|
||||
DEV_MODE: "false"
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
ports:
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
# 仅服务端可见(middleware 与 SSR 优先使用)。
|
||||
# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址,
|
||||
# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。
|
||||
# BACKEND_URL=https://your-backend.example.com
|
||||
# 生产构建【必须】显式配置;缺省会直接失败,禁止烤进 localhost。
|
||||
# Docker 官方镜像构建期注入 http://api:3001。
|
||||
# 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 可不设。
|
||||
# NEXT_PUBLIC_APP_VERSION=0.1.0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
AdminPageHeader,
|
||||
@@ -19,32 +19,11 @@ const NAV: { href: string; label: string }[] = [
|
||||
{ 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" &&
|
||||
|
||||
@@ -5,24 +5,7 @@ export const metadata: Metadata = {
|
||||
title: "站点设置",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ section?: string }>;
|
||||
}
|
||||
|
||||
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");
|
||||
/** /admin/settings 统一进入基本信息子路由 */
|
||||
export default async function SettingsIndexPage() {
|
||||
redirect("/admin/settings/basic");
|
||||
}
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
"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>;
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { apiOperations } from "@/lib/api";
|
||||
import { realtime, RT_SETTINGS_CHANGED } from "@/lib/realtime";
|
||||
import {
|
||||
VISIBLE_RECONCILE_GAP_MS,
|
||||
onDocumentVisible,
|
||||
} from "@/lib/visibilityReconcile";
|
||||
|
||||
/** 只读维护条:WS settings:changed 优先,回前台再校准(无定时轮询) */
|
||||
export default function OperationalBanner() {
|
||||
const path = usePathname();
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,6 +378,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme
|
||||
/* ---------- 友情链接 ---------- */
|
||||
|
||||
function FriendLinks() {
|
||||
if (FRIEND_LINKS.length === 0) return null;
|
||||
return (
|
||||
<section className="j13-module" aria-label="友情链接">
|
||||
<p className="j13-module-title mb-3">
|
||||
|
||||
@@ -21,10 +21,19 @@ export type { FooterLink, FooterLinksAlign };
|
||||
// API 基础配置
|
||||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||||
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
||||
// SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置,
|
||||
// localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。
|
||||
const API_BASE =
|
||||
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
// SSR/middleware 直连后端:优先 BACKEND_URL;生产禁止静默回落 localhost。
|
||||
const API_BASE = (() => {
|
||||
const fromEnv =
|
||||
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 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染
|
||||
const SSR_TIMEOUT_MS = 8000;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// 认证 cookie 名(前后端必须严格一致):
|
||||
// 生产构建(HTTPS)启用 __Host- 前缀,浏览器强制 Secure + Path=/ + 无 Domain;
|
||||
// 后端按 !DevMode 同步启用(见 backend/service/auth.go ConfigureCookieNames)。
|
||||
const HOST_PREFIX = process.env.NODE_ENV === "production" ? "__Host-" : "";
|
||||
// 与后端 ConfigureCookieNames(!DevMode) 对齐——显式 DEV_MODE=true|1 禁用 __Host-;
|
||||
// 显式 DEV_MODE=false|0 启用;未设置时回退 NODE_ENV=production(官方镜像两者同时满足)。
|
||||
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 REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// 友情链接(站点级静态配置)。
|
||||
// TODO: 替换为真实友链名称与地址;后续如需后台可配,可升级为接口/设置项。
|
||||
// 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。
|
||||
export interface FriendLink {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const FRIEND_LINKS: FriendLink[] = [
|
||||
{ name: "大佬论坛", href: "#" },
|
||||
{ name: "Leo论坛", href: "#" },
|
||||
];
|
||||
export const FRIEND_LINKS: FriendLink[] = [];
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// 后端地址:开发环境兜底 localhost:3001,生产部署必须显式配置 BACKEND_URL
|
||||
// 后端地址:开发可兜底 localhost:3001;生产构建必须显式 BACKEND_URL(禁止静默烤进 localhost)
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
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 = {
|
||||
// 官方 Docker 运行时:最小产物,不含完整 node_modules。next dev 忽略此项。
|
||||
|
||||
Reference in New Issue
Block a user