feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
46
backend/service/admin_user_sort_test.go
Normal file
46
backend/service/admin_user_sort_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
167
backend/service/bg.go
Normal 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/"))
|
||||
}
|
||||
56
backend/service/bg_test.go
Normal file
56
backend/service/bg_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user