feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": "获取用户列表失败"})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
9
backend/version/version.go
Normal file
9
backend/version/version.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package version 由构建注入发布号;本地 go run 为 dev。
|
||||
package version
|
||||
|
||||
var (
|
||||
// Version 与仓库根 VERSION 对齐,Docker / ldflags 写入。
|
||||
Version = "dev"
|
||||
// Commit 短 sha,未知则为 unknown。
|
||||
Commit = "unknown"
|
||||
)
|
||||
Reference in New Issue
Block a user