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

@@ -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)