feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS

站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 05:27:33 +08:00
parent 5f7193042f
commit d784b0ea7a
68 changed files with 2588 additions and 768 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/router"
"github.com/freefire/jiang13-bbs/service"
"github.com/freefire/jiang13-bbs/version"
)
func main() {
@@ -33,7 +34,7 @@ func main() {
}
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("[jiang13-bbs] 服务启动于 %s (dev=%v workPath=%s dataDir=%s)", addr, cfg.DevMode, cfg.WorkPath, cfg.DataDir)
log.Printf("[jiang13-bbs] 服务启动于 %s (version=%s commit=%s dev=%v workPath=%s dataDir=%s)", addr, version.Version, version.Commit, cfg.DevMode, cfg.WorkPath, cfg.DataDir)
if err := r.Run(addr); err != nil {
log.Fatalf("服务启动失败: %v", err)
}

View File

@@ -12,12 +12,14 @@ import (
// Config 应用全局配置
type Config struct {
WorkPath string
Port int
DataDir string
JWTSecret string
DBDSN string
DevMode bool
WorkPath string
Port int
DataDir string
JWTSecret string
DBDSN string
DevMode bool
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
}
// Parse 解析配置:环境变量 > app.ini > 默认值。
@@ -84,6 +86,19 @@ func Parse() (*Config, error) {
if v := os.Getenv("DEV_MODE"); v != "" {
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
}
if v := strings.TrimSpace(os.Getenv("SITE_URL")); v != "" {
cfg.SiteURL = strings.TrimRight(v, "/")
}
if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" {
cfg.CORSOrigins = splitCSVOrigins(v)
}
if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" {
abs, err := filepath.Abs(v)
if err != nil {
return nil, fmt.Errorf("解析 DATA_DIR 失败: %w", err)
}
cfg.DataDir = abs
}
if err := rejectStrayDataDir(cfg.DataDir); err != nil {
return nil, err
@@ -108,6 +123,40 @@ func Parse() (*Config, error) {
return cfg, nil
}
// AllowOrigin 供 CORS 中间件判断浏览器 Origin。
// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。
func (c *Config) AllowOrigin(origin string) bool {
if origin == "" {
return false
}
origin = strings.TrimRight(origin, "/")
if c.DevMode {
return origin == "http://localhost:3000" || origin == "http://127.0.0.1:3000"
}
if c.SiteURL != "" && origin == c.SiteURL {
return true
}
for _, o := range c.CORSOrigins {
if origin == o {
return true
}
}
return false
}
func splitCSVOrigins(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.TrimRight(strings.TrimSpace(p), "/")
if s != "" {
out = append(out, s)
}
}
return out
}
// resolveWorkPath 定位后端根目录(app.ini 与 data/ 所在处)。
// 优先级:JIANG13_WORK_PATH > 从 cwd 向上找 app.ini > 源码树 backend/ > cwd。
func resolveWorkPath() (string, error) {

View File

@@ -88,3 +88,54 @@ func TestRejectStrayDataDir(t *testing.T) {
t.Fatal(err)
}
}
func TestAllowOriginDevAndProd(t *testing.T) {
dev := &Config{DevMode: true}
if !dev.AllowOrigin("http://localhost:3000") || !dev.AllowOrigin("http://127.0.0.1:3000") {
t.Fatal("开发态应放行 next dev Origin")
}
if dev.AllowOrigin("https://bbs.example.com") {
t.Fatal("开发态不应放行生产域名")
}
prod := &Config{
DevMode: false,
SiteURL: "https://bbs.example.com",
CORSOrigins: []string{"https://mirror.example.com"},
}
if !prod.AllowOrigin("https://bbs.example.com/") {
t.Fatal("生产态应放行 SITE_URL")
}
if !prod.AllowOrigin("https://mirror.example.com") {
t.Fatal("生产态应放行 CORS_ORIGINS")
}
if prod.AllowOrigin("http://localhost:3000") {
t.Fatal("生产态不应放行 localhost")
}
}
func TestParseSiteURLAndDataDir(t *testing.T) {
work := t.TempDir()
data := t.TempDir()
t.Setenv("JIANG13_WORK_PATH", work)
t.Setenv("DEV_MODE", "false")
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")
cfg, err := Parse()
if err != nil {
t.Fatal(err)
}
if cfg.DevMode {
t.Fatal("DEV_MODE=false 应关闭开发态")
}
if cfg.SiteURL != "https://bbs.example.com" {
t.Fatalf("SITE_URL 未去尾斜杠: %q", cfg.SiteURL)
}
if len(cfg.CORSOrigins) != 2 || cfg.CORSOrigins[0] != "https://a.example.com" || cfg.CORSOrigins[1] != "https://b.example.com" {
t.Fatalf("CORS_ORIGINS 解析错误: %#v", cfg.CORSOrigins)
}
assertPath(t, cfg.DataDir, data)
}

View File

@@ -26,6 +26,8 @@ func (h *Handlers) AdminListUsers(c *gin.Context) {
Keyword: strings.TrimSpace(c.Query("q")),
Role: c.Query("role"),
Status: c.Query("status"),
Sort: c.Query("sort"),
Dir: c.Query("order"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})

View File

@@ -4,14 +4,23 @@ import (
"net/http"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/version"
"github.com/gin-gonic/gin"
)
// Health 健康检查
// Health 健康检查(官方运行时 Docker HEALTHCHECK 与反代 /health)
func (h *Handlers) Health(c *gin.Context) {
if err := model.PingDB(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()})
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "error",
"error": err.Error(),
"version": version.Version,
})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"version": version.Version,
"commit": version.Commit,
})
}

View File

@@ -41,8 +41,10 @@ type updateSettingsRequest struct {
AttachmentMaxMB *int `json:"attachment_max_mb"`
AttachmentMaxCount *int `json:"attachment_max_count"`
ImageMaxMB *int `json:"image_max_mb"`
CustomCSS *string `json:"custom_css"`
CustomJS *string `json:"custom_js"`
BgSiteURL *string `json:"bg_site_url"`
BgSiteMode *string `json:"bg_site_mode"`
BgAdminURL *string `json:"bg_admin_url"`
BgAdminMode *string `json:"bg_admin_mode"`
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
}
@@ -68,8 +70,10 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"attachment_max_mb": saved.AttachmentMaxMB,
"attachment_max_count": saved.AttachmentMaxCount,
"image_max_mb": saved.ImageMaxMB,
"custom_css": saved.CustomCSS,
"custom_js": saved.CustomJS,
"bg_site_url": saved.BgSiteURL,
"bg_site_mode": saved.BgSiteMode,
"bg_admin_url": saved.BgAdminURL,
"bg_admin_mode": saved.BgAdminMode,
}
}
@@ -80,8 +84,9 @@ func (req *updateSettingsRequest) hasAny() bool {
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.CustomCSS != nil ||
req.CustomJS != nil || req.TimelineGitImport != nil
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil ||
req.BgSiteURL != nil || req.BgSiteMode != nil ||
req.BgAdminURL != nil || req.BgAdminMode != nil || req.TimelineGitImport != nil
}
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
@@ -269,20 +274,32 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.CustomCSS != nil {
if err := h.Setting.SetCustomCSS(*req.CustomCSS); err != nil {
if req.BgSiteURL != nil {
if err := h.applyBgURL(service.BgSurfaceSite, *req.BgSiteURL); err != nil {
writeBgSettingError(c, err)
return
}
}
if req.BgAdminURL != nil {
if err := h.applyBgURL(service.BgSurfaceAdmin, *req.BgAdminURL); err != nil {
writeBgSettingError(c, err)
return
}
}
if req.BgSiteMode != nil {
if err := h.Setting.SetBgSiteMode(*req.BgSiteMode); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 CSS 过长或含非法闭合标签"})
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.CustomJS != nil {
if err := h.Setting.SetCustomJS(*req.CustomJS); err != nil {
if req.BgAdminMode != nil {
if err := h.Setting.SetBgAdminMode(*req.BgAdminMode); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 JS 过长或含非法闭合标签"})
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
@@ -318,3 +335,46 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
}
c.JSON(http.StatusOK, out)
}
func writeBgSettingError(c *gin.Context, err error) {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "背景图地址无效"})
return
}
if errors.Is(err, service.ErrBgFileMissing) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
}
// applyBgURL 写入对应轨的背景 URL,并删除不再被引用的旧文件
func (h *Handlers) applyBgURL(surface, url string) error {
u, ok := service.NormalizeBgURL(url)
if !ok {
return service.ErrInvalidSiteSetting
}
if u != "" && !h.Upload.BackgroundFileExists(u) {
return service.ErrBgFileMissing
}
oldSite, err := h.Setting.BgSiteURL()
if err != nil {
return err
}
oldAdmin, err := h.Setting.BgAdminURL()
if err != nil {
return err
}
if surface == service.BgSurfaceAdmin {
if err := h.Setting.SetBgAdminURL(u); err != nil {
return err
}
h.Upload.RemoveBackgroundIfUnused(oldAdmin, oldSite, u)
return nil
}
if err := h.Setting.SetBgSiteURL(u); err != nil {
return err
}
h.Upload.RemoveBackgroundIfUnused(oldSite, u, oldAdmin)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -165,3 +166,67 @@ func (h *Handlers) DeleteAttachment(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),并立即绑定前台或后台
func (h *Handlers) UploadBackground(c *gin.Context) {
surface, ok := service.NormalizeBgSurface(c.PostForm("surface"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"})
return
}
limit := int64(service.BackgroundMaxBytes + 4096)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
fh, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"})
return
}
if fh.Size > service.BackgroundMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
defer f.Close()
url, err := h.Upload.SaveBackground(f)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.applyBgURL(surface, url); err != nil {
writeBgSettingError(c, err)
return
}
saved, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
payload := settingsPayload(saved)
h.Hub.BroadcastAll(realtime.Envelope{
Type: realtime.EventSettingsChanged,
Data: payload,
})
out := settingsPayload(saved)
out["ok"] = true
out["url"] = url
c.JSON(http.StatusOK, out)
}

View File

@@ -270,7 +270,8 @@ func dropLegacyRefreshTokenColumn(db *gorm.DB) error {
}
// ensureOwnerRole 若无站长,则把 id 最小的旧管理员升级为站长;
// 连管理员都没有的全新库,把 id=1 的初始账号设为站长
// 连管理员都没有则升级 id 最小的账号。空库(尚无用户)直接返回,等首次注册。
// 禁止 GORM Update+Limit:无 WHERE 主键时会生成全表 UPDATE 并报 WHERE conditions required。
func ensureOwnerRole(db *gorm.DB) error {
var ownerCount int64
if err := db.Model(&User{}).Where("role = ?", RoleOwner).Count(&ownerCount).Error; err != nil {
@@ -279,16 +280,20 @@ func ensureOwnerRole(db *gorm.DB) error {
if ownerCount > 0 {
return nil
}
res := db.Model(&User{}).Where("role = ?", RoleAdmin).
Order("id ASC").Limit(1).Update("role", RoleOwner)
if res.Error != nil {
return res.Error
var candidate User
err := db.Select("id").Where("role = ?", RoleAdmin).Order("id ASC").First(&candidate).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = db.Select("id").Order("id ASC").First(&candidate).Error
}
if res.RowsAffected == 0 {
if err := db.Model(&User{}).Order("id ASC").Limit(1).
Update("role", RoleOwner).Error; err != nil {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if err := db.Model(&User{}).Where("id = ?", candidate.ID).Update("role", RoleOwner).Error; err != nil {
return err
}
log.Println("[model] 已迁移初始账号为站长角色(owner)")
return nil

View File

@@ -31,9 +31,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 全局安全响应头
r.Use(middleware.SecurityHeaders())
// CORS
// CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域)
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"},
AllowOriginFunc: cfg.AllowOrigin,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
@@ -266,6 +266,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 站点外观设置(超级管理员/站长);含 timeline_git_import
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground)
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))

View File

@@ -2,10 +2,13 @@ package service
import (
"errors"
"fmt"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 管理员用户操作的业务护栏(handler 层映射为 4xx,前端只展示消息)
@@ -86,6 +89,43 @@ type AdminUserListQuery struct {
Keyword string // 用户名 / 昵称 / 邮箱模糊匹配
Role string // "" 全部 | "staff" 管理团队 | 具体角色枚举
Status string // "" 全部 | "banned" 已封禁 | "normal" 正常
Sort string // 白名单列:id / role / status / points / last_login / counts / created_at / online
Dir string // asc | desc;非法值按 desc
}
// adminUserOrderSQL 生成 ORDER BY 表达式。sort/dir 均走白名单,默认 id DESC(与历史行为一致)。
func adminUserOrderSQL(sort, dir string) string {
ord := "DESC"
if strings.EqualFold(strings.TrimSpace(dir), "asc") {
ord = "ASC"
}
switch strings.TrimSpace(sort) {
case "role":
return fmt.Sprintf(
"CASE users.role WHEN 'owner' THEN 100 WHEN 'super_admin' THEN 80 WHEN 'admin' THEN 50 WHEN 'board_admin' THEN 30 ELSE 0 END %s, users.id DESC",
ord,
)
case "status":
return fmt.Sprintf("users.banned %s, users.id DESC", ord)
case "points":
return fmt.Sprintf("users.points %s, users.id DESC", ord)
case "created_at":
return fmt.Sprintf("users.created_at %s, users.id DESC", ord)
case "online":
return fmt.Sprintf("users.last_seen_at %s NULLS LAST, users.id DESC", ord)
case "last_login":
return fmt.Sprintf(
"(SELECT MAX(created_at) FROM login_logs WHERE login_logs.user_id = users.id AND login_logs.success = true) %s NULLS LAST, users.id DESC",
ord,
)
case "counts":
return fmt.Sprintf(
"(SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id AND posts.status = '%s' AND posts.deleted_at IS NULL) %s, users.id DESC",
model.ContentStatusPublished, ord,
)
default:
return "users.id " + ord
}
}
// AdminUserListResult 分页结果 + 汇总
@@ -129,7 +169,9 @@ func (s *AdminUserService) List(q AdminUserListQuery) (*AdminUserListResult, err
}
var users []model.User
if err := query.Order("id DESC").
if err := query.Clauses(clause.OrderBy{
Expression: clause.Expr{SQL: adminUserOrderSQL(q.Sort, q.Dir)},
}).
Offset((q.Page - 1) * q.Size).Limit(q.Size).
Find(&users).Error; err != nil {
return nil, err

View File

@@ -0,0 +1,46 @@
package service
import (
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
)
func TestAdminUserOrderSQLDefault(t *testing.T) {
got := adminUserOrderSQL("", "")
if got != "users.id DESC" {
t.Fatalf("默认排序应为 id DESC,得到 %q", got)
}
if adminUserOrderSQL("unknown", "drop table") != "users.id DESC" {
t.Fatalf("非法 sort/dir 必须回落到 id DESC")
}
}
func TestAdminUserOrderSQLWhitelist(t *testing.T) {
cases := []struct {
sort, dir, wantSub string
asc bool
}{
{"id", "asc", "users.id ASC", true},
{"points", "desc", "users.points DESC, users.id DESC", false},
{"status", "ASC", "users.banned ASC, users.id DESC", true},
{"created_at", "", "users.created_at DESC, users.id DESC", false},
{"online", "asc", "users.last_seen_at ASC NULLS LAST", true},
{"last_login", "desc", "FROM login_logs", false},
{"role", "asc", "WHEN 'owner' THEN 100", true},
{"counts", "desc", "posts.status = '" + model.ContentStatusPublished + "'", false},
}
for _, tc := range cases {
got := adminUserOrderSQL(tc.sort, tc.dir)
if !strings.Contains(got, tc.wantSub) {
t.Fatalf("sort=%s dir=%s 应包含 %q,得到 %q", tc.sort, tc.dir, tc.wantSub, got)
}
if strings.ContainsAny(tc.sort+tc.dir, ";") {
t.Fatalf("用例本身不应含注入字符")
}
if tc.asc && !strings.Contains(got, "ASC") {
t.Fatalf("sort=%s 应为 ASC:%q", tc.sort, got)
}
}
}

View File

@@ -98,12 +98,21 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
return nil, err
}
var n int64
if err := s.db.Model(&model.User{}).Count(&n).Error; err != nil {
return nil, err
}
role := model.RoleUser
if n == 0 {
role = model.RoleOwner
}
user := &model.User{
Username: username,
Email: email,
Password: string(hashed),
Nickname: username,
Role: model.RoleUser,
Role: role,
}
if err := s.db.Create(user).Error; err != nil {
return nil, err

167
backend/service/bg.go Normal file
View File

@@ -0,0 +1,167 @@
package service
import (
"errors"
"path/filepath"
"regexp"
"strings"
)
// 站点背景图(前台 / 后台分轨)
const (
SettingKeyBgSiteURL = "bg_site_url"
SettingKeyBgSiteMode = "bg_site_mode"
SettingKeyBgAdminURL = "bg_admin_url"
SettingKeyBgAdminMode = "bg_admin_mode"
BgSurfaceSite = "site"
BgSurfaceAdmin = "admin"
BgModeCover = "cover"
BgModeContain = "contain"
BgModeRepeat = "repeat"
BgModeRepeatX = "repeat-x"
BgModeRepeatY = "repeat-y"
BgModeStretch = "stretch"
BgModeCenter = "center"
DefaultBgMode = BgModeCover
)
// ErrBgFileMissing 设置了合法 URL 但磁盘上没有对应文件
var ErrBgFileMissing = errors.New("背景图文件不存在")
// 仅接受本站上传目录下的 32 位 hex 文件名,禁止外链与路径穿越(写入 CSS url())
var bgUploadURLRe = regexp.MustCompile(`^/uploads/backgrounds/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$`)
var bgModes = map[string]struct{}{
BgModeCover: {},
BgModeContain: {},
BgModeRepeat: {},
BgModeRepeatX: {},
BgModeRepeatY: {},
BgModeStretch: {},
BgModeCenter: {},
}
// NormalizeBgMode 校验铺放模式;空串视为默认 cover
func NormalizeBgMode(raw string) (string, bool) {
m := strings.ToLower(strings.TrimSpace(raw))
if m == "" {
return DefaultBgMode, true
}
if _, ok := bgModes[m]; !ok {
return "", false
}
return m, true
}
// NormalizeBgURL 校验背景图 URL;空串表示清除(走默认画布)
func NormalizeBgURL(raw string) (string, bool) {
u := strings.TrimSpace(raw)
if u == "" {
return "", true
}
u = strings.ToLower(u)
if !bgUploadURLRe.MatchString(u) {
return "", false
}
return u, true
}
// NormalizeBgSurface 前台 site / 后台 admin
func NormalizeBgSurface(raw string) (string, bool) {
s := strings.ToLower(strings.TrimSpace(raw))
if s == BgSurfaceSite || s == BgSurfaceAdmin {
return s, true
}
return "", false
}
func (s *SettingService) BgSiteURL() (string, error) {
return s.bgURL(SettingKeyBgSiteURL)
}
func (s *SettingService) BgAdminURL() (string, error) {
return s.bgURL(SettingKeyBgAdminURL)
}
func (s *SettingService) BgSiteMode() (string, error) {
return s.bgMode(SettingKeyBgSiteMode)
}
func (s *SettingService) BgAdminMode() (string, error) {
return s.bgMode(SettingKeyBgAdminMode)
}
func (s *SettingService) bgURL(key string) (string, error) {
v, found, err := s.getValue(key)
if err != nil || !found {
return "", err
}
u, ok := NormalizeBgURL(v)
if !ok {
return "", nil
}
return u, nil
}
func (s *SettingService) bgMode(key string) (string, error) {
v, found, err := s.getValue(key)
if err != nil || !found {
return DefaultBgMode, err
}
m, ok := NormalizeBgMode(v)
if !ok {
return DefaultBgMode, nil
}
return m, nil
}
// SetBgSiteURL 保存前台背景;空串删除键
func (s *SettingService) SetBgSiteURL(url string) error {
return s.setBgURL(SettingKeyBgSiteURL, url)
}
// SetBgAdminURL 保存后台背景;空串删除键
func (s *SettingService) SetBgAdminURL(url string) error {
return s.setBgURL(SettingKeyBgAdminURL, url)
}
func (s *SettingService) setBgURL(key, url string) error {
u, ok := NormalizeBgURL(url)
if !ok {
return ErrInvalidSiteSetting
}
if u == "" {
return s.deleteKey(key)
}
return s.putValue(key, u)
}
func (s *SettingService) SetBgSiteMode(mode string) error {
return s.setBgMode(SettingKeyBgSiteMode, mode)
}
func (s *SettingService) SetBgAdminMode(mode string) error {
return s.setBgMode(SettingKeyBgAdminMode, mode)
}
func (s *SettingService) setBgMode(key, mode string) error {
m, ok := NormalizeBgMode(mode)
if !ok {
return ErrInvalidSiteSetting
}
if m == DefaultBgMode {
return s.deleteKey(key)
}
return s.putValue(key, m)
}
// BgRelPath 把合法 URL 转成 uploads 根下的相对路径;非法返回空
func BgRelPath(url string) string {
u, ok := NormalizeBgURL(url)
if !ok || u == "" {
return ""
}
return filepath.FromSlash(strings.TrimPrefix(u, "/uploads/"))
}

View File

@@ -0,0 +1,56 @@
package service
import "testing"
func TestNormalizeBgURL(t *testing.T) {
okEmpty, ok := NormalizeBgURL(" ")
if !ok || okEmpty != "" {
t.Fatalf("empty should clear, got %q ok=%v", okEmpty, ok)
}
good := "/uploads/backgrounds/0123456789abcdef0123456789abcdef.webp"
got, ok := NormalizeBgURL(good)
if !ok || got != good {
t.Fatalf("want %s, got %q ok=%v", good, got, ok)
}
rejects := []string{
"http://evil.test/x.webp",
"/uploads/backgrounds/../avatars/x.webp",
"/uploads/images/0123456789abcdef0123456789abcdef.webp",
"/uploads/backgrounds/0123456789abcdef0123456789abcdef.gif",
`/uploads/backgrounds/0123456789abcdef0123456789abcdef.webp")`,
"/uploads/backgrounds/short.webp",
"javascript:alert(1)",
}
for _, u := range rejects {
if _, ok := NormalizeBgURL(u); ok {
t.Fatalf("should reject %q", u)
}
}
}
func TestNormalizeBgMode(t *testing.T) {
m, ok := NormalizeBgMode("")
if !ok || m != DefaultBgMode {
t.Fatalf("empty mode want cover, got %q ok=%v", m, ok)
}
if _, ok := NormalizeBgMode("zoom"); ok {
t.Fatal("unknown mode should fail")
}
for _, want := range []string{
BgModeCover, BgModeContain, BgModeRepeat, BgModeRepeatX, BgModeRepeatY, BgModeStretch, BgModeCenter,
} {
got, ok := NormalizeBgMode(" " + want + " ")
if !ok || got != want {
t.Fatalf("mode %s: got %q ok=%v", want, got, ok)
}
}
}
func TestNormalizeBgSurface(t *testing.T) {
if s, ok := NormalizeBgSurface("SITE"); !ok || s != BgSurfaceSite {
t.Fatalf("site: %q %v", s, ok)
}
if _, ok := NormalizeBgSurface("frontend"); ok {
t.Fatal("frontend is not a surface")
}
}

View File

@@ -1,19 +0,0 @@
package service
import "testing"
func TestSanitizeCustomSnippet(t *testing.T) {
got, err := sanitizeCustomSnippet(" body{color:red} ", "</style", MaxCustomCSSRunes)
if err != nil || got != "body{color:red}" {
t.Fatalf("trim got %q err=%v", got, err)
}
if _, err = sanitizeCustomSnippet("a</style>b", "</style", MaxCustomCSSRunes); err == nil {
t.Fatal("css closer should fail")
}
if _, err = sanitizeCustomSnippet("a</SCRIPT>b", "</script", MaxCustomJSRunes); err == nil {
t.Fatal("js closer should fail")
}
if _, err = sanitizeCustomSnippet("ok", "</script", MaxCustomJSRunes); err != nil {
t.Fatalf("valid js: %v", err)
}
}

View File

@@ -49,10 +49,6 @@ const (
SettingKeyAttachmentMaxCount = "attachment_max_count"
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
SettingKeyImageMaxMB = "image_max_mb"
// SettingKeyCustomCSS 全站自定义 CSS(外观页注入,空=无)
SettingKeyCustomCSS = "custom_css"
// SettingKeyCustomJS 全站自定义 JS(外观页注入,空=无)
SettingKeyCustomJS = "custom_js"
)
const (
@@ -74,9 +70,6 @@ const (
MinAttachmentMaxCount = 1
MaxAttachmentMaxCount = 20
MaxAttachmentExtCount = 80
MaxCustomCSSRunes = 48000
MaxCustomJSRunes = 48000
)
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -122,8 +115,10 @@ type PublicSiteSettings struct {
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
CustomCSS string `json:"custom_css"`
CustomJS string `json:"custom_js"`
BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"`
BgAdminMode string `json:"bg_admin_mode"`
}
// SettingService 站点级键值设置
@@ -198,6 +193,8 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
AttachmentMaxMB: DefaultAttachmentMaxMB,
AttachmentMaxCount: DefaultAttachmentMaxCount,
ImageMaxMB: DefaultImageMaxMB,
BgSiteMode: DefaultBgMode,
BgAdminMode: DefaultBgMode,
}
accent, err := s.AccentColor()
if err != nil {
@@ -326,17 +323,26 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
}
out.ImageMaxMB = imgMB
css, err := s.CustomCSS()
siteURL, err := s.BgSiteURL()
if err != nil {
return out, err
}
out.CustomCSS = css
js, err := s.CustomJS()
out.BgSiteURL = siteURL
siteMode, err := s.BgSiteMode()
if err != nil {
return out, err
}
out.CustomJS = js
out.BgSiteMode = siteMode
adminURL, err := s.BgAdminURL()
if err != nil {
return out, err
}
out.BgAdminURL = adminURL
adminMode, err := s.BgAdminMode()
if err != nil {
return out, err
}
out.BgAdminMode = adminMode
return out, nil
}
@@ -788,65 +794,3 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
}
return int64(mb) << 20, nil
}
func sanitizeCustomSnippet(s, closer string, maxRunes int) (string, error) {
s = strings.TrimSpace(s)
if strings.ContainsRune(s, 0) {
return "", ErrInvalidSiteSetting
}
if utf8.RuneCountInString(s) > maxRunes {
return "", ErrInvalidSiteSetting
}
if closer != "" && strings.Contains(strings.ToLower(s), closer) {
return "", ErrInvalidSiteSetting
}
return s, nil
}
// CustomCSS 全站自定义 CSS。缺行/空=无。
func (s *SettingService) CustomCSS() (string, error) {
v, found, err := s.getValue(SettingKeyCustomCSS)
if err != nil || !found {
return "", err
}
out, nerr := sanitizeCustomSnippet(v, "</style", MaxCustomCSSRunes)
if nerr != nil {
return "", nil
}
return out, nil
}
func (s *SettingService) SetCustomCSS(css string) error {
normalized, err := sanitizeCustomSnippet(css, "</style", MaxCustomCSSRunes)
if err != nil {
return err
}
if normalized == "" {
return s.deleteKey(SettingKeyCustomCSS)
}
return s.putValue(SettingKeyCustomCSS, normalized)
}
// CustomJS 全站自定义 JS。缺行/空=无。
func (s *SettingService) CustomJS() (string, error) {
v, found, err := s.getValue(SettingKeyCustomJS)
if err != nil || !found {
return "", err
}
out, nerr := sanitizeCustomSnippet(v, "</script", MaxCustomJSRunes)
if nerr != nil {
return "", nil
}
return out, nil
}
func (s *SettingService) SetCustomJS(js string) error {
normalized, err := sanitizeCustomSnippet(js, "</script", MaxCustomJSRunes)
if err != nil {
return err
}
if normalized == "" {
return s.deleteKey(SettingKeyCustomJS)
}
return s.putValue(SettingKeyCustomJS, normalized)
}

View File

@@ -29,6 +29,10 @@ const (
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
ImageMaxBytes = 5 << 20 // 5 MiB
ImageMaxDim = 4096
// 站点背景图(前台/后台墙纸)
BackgroundMaxBytes = 8 << 20 // 8 MiB
BackgroundMaxDim = 8192
)
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
@@ -64,7 +68,10 @@ func (s *UploadService) EnsureDir() error {
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
return err
}
return os.MkdirAll(filepath.Join(s.dir, "images"), 0o755)
if err := os.MkdirAll(filepath.Join(s.dir, "images"), 0o755); err != nil {
return err
}
return os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755)
}
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
@@ -267,6 +274,119 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
return att, nil
}
// SaveBackground 保存站点背景图到 uploads/backgrounds,不进用户媒体库
func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
if src == nil {
return "", errors.New("文件为空")
}
tooLarge := func() error {
return errors.New("背景图不能超过 8MB")
}
head := make([]byte, 12)
n, err := io.ReadFull(src, head)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return "", errors.New("读取图片失败")
}
if n == 0 {
return "", errors.New("文件为空")
}
format, err := detectImageFormat(head[:n])
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
return "", err
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return "", err
}
filename := hex.EncodeToString(nameBytes) + format.ext
fullPath := filepath.Join(s.dir, "backgrounds", filename)
tmp := fullPath + ".partial"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return "", err
}
remain := BackgroundMaxBytes + 1 - int64(n)
if remain < 0 {
_ = f.Close()
_ = os.Remove(tmp)
return "", tooLarge()
}
if _, err := f.Write(head[:n]); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return "", err
}
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return "", copyErr
}
total := int64(n) + writtenRest
if total > BackgroundMaxBytes {
_ = os.Remove(tmp)
return "", tooLarge()
}
rf, err := os.Open(tmp)
if err != nil {
_ = os.Remove(tmp)
return "", err
}
w, h, err := decodeImageSizeReader(rf, format.mime)
_ = rf.Close()
if err != nil {
_ = os.Remove(tmp)
return "", err
}
if w < 1 || h < 1 {
_ = os.Remove(tmp)
return "", errors.New("无效的图片尺寸")
}
if w > BackgroundMaxDim || h > BackgroundMaxDim {
_ = os.Remove(tmp)
return "", errors.New("背景图边长不能超过 8192px")
}
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return "", err
}
return "/uploads/backgrounds/" + filename, nil
}
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
func (s *UploadService) BackgroundFileExists(url string) bool {
rel := BgRelPath(url)
if rel == "" {
return false
}
abs := filepath.Join(s.dir, rel)
info, err := os.Stat(abs)
return err == nil && !info.IsDir()
}
// RemoveBackgroundIfUnused 旧墙纸不再被前台或后台引用时删除物理文件
func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL string) {
if oldURL == "" || oldURL == siteURL || oldURL == adminURL {
return
}
rel := BgRelPath(oldURL)
if rel == "" {
return
}
abs := filepath.Join(s.dir, rel)
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
log.Printf("[upload] 删除背景图失败 path=%s: %v", abs, err)
}
}
// UseAvatar 选用一张【本人历史上传】的头像
func (s *UploadService) UseAvatar(userID uint, url string) error {
url = strings.TrimSpace(url)

View File

@@ -0,0 +1,9 @@
// Package version 由构建注入发布号;本地 go run 为 dev。
package version
var (
// Version 与仓库根 VERSION 对齐,Docker / ldflags 写入。
Version = "dev"
// Commit 短 sha,未知则为 unknown。
Commit = "unknown"
)