开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if n < 32 {
|
||||||
|
n = 32
|
||||||
|
}
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
for i := range b {
|
if _, err := rand.Read(b); err != nil {
|
||||||
b[i] = chars[i%len(chars)]
|
return "", err
|
||||||
}
|
}
|
||||||
return string(b)
|
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) {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,29 +59,48 @@ 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 {
|
||||||
|
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{
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
Name: ck.name,
|
Name: ck.name,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: path,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
HttpOnly: ck.httpOnly,
|
HttpOnly: ck.httpOnly,
|
||||||
Secure: secure,
|
Secure: sec,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRequest 注册请求
|
// RegisterRequest 注册请求
|
||||||
type RegisterRequest struct {
|
type RegisterRequest struct {
|
||||||
@@ -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": "已登出"})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 仍校验)
|
||||||
|
|||||||
@@ -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 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
|
var n int64
|
||||||
if err := s.db.Model(&model.User{}).Count(&n).Error; err != nil {
|
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
role := model.RoleUser
|
role := model.RoleUser
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
role = model.RoleOwner
|
role = model.RoleOwner
|
||||||
}
|
}
|
||||||
|
user = model.User{
|
||||||
user := &model.User{
|
|
||||||
Username: username,
|
Username: username,
|
||||||
Email: email,
|
Email: email,
|
||||||
Password: string(hashed),
|
Password: string(hashed),
|
||||||
Nickname: username,
|
Nickname: username,
|
||||||
Role: role,
|
Role: role,
|
||||||
}
|
}
|
||||||
if err := s.db.Create(user).Error; err != nil {
|
return tx.Create(&user).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return user, nil
|
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 revokeAllUserRefreshTokensTx(tx, userID)
|
||||||
return nil
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
result := tx.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return errors.New("密码更新失败")
|
return errors.New("密码更新失败")
|
||||||
}
|
}
|
||||||
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
|
if err := tx.Model(&model.User{}).Where("id = ?", userID).
|
||||||
return s.IncrementTokenVersion(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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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凭据。独立备份,禁止提交真实密钥。
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" &&
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { apiOperations } from "@/lib/api";
|
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() {
|
export default function OperationalBanner() {
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
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);
|
const apply = (mode: string | undefined, message: string | undefined) => {
|
||||||
document.addEventListener("visibilitychange",refresh);
|
if (!active) return;
|
||||||
return ()=>{active=false;clearInterval(timer);document.removeEventListener("visibilitychange",refresh);};
|
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]);
|
}, [path]);
|
||||||
|
|
||||||
if (path.startsWith("/admin") || !notice) return null;
|
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>;
|
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() {
|
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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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: "#" },
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -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 忽略此项。
|
||||||
|
|||||||
Reference in New Issue
Block a user