Files
jiang13-bbs/backend/handler/upload.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

440 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"errors"
"io"
"mime"
"net/http"
"path"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// UploadAvatar 上传裁剪后的头像(multipart 字段 file,内容必须是 WebP)
func (h *Handlers) UploadAvatar(c *gin.Context) {
claims := middleware.CurrentUser(c)
// 限制请求体:以裁剪后文件为依据,允许少量 multipart 边界开销
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.AvatarMaxBytes+4096)
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(裁剪后不能超过 2MB)或格式不正确"})
return
}
if fh.Size > service.AvatarMaxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "裁剪后的图片不能超过 2MB"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
defer f.Close()
// fh.Size 已受 MaxBytesReader 约束;读取时二次防御
data, err := io.ReadAll(io.LimitReader(f, service.AvatarMaxBytes+1))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
att, err := h.Upload.SaveAvatar(claims.ID, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP / GIF;除 GIF 外自动转存 WebP)
func (h *Handlers) UploadImage(c *gin.Context) {
claims := middleware.CurrentUser(c)
maxBytes, err := h.Setting.ImageMaxBytes()
if err != nil || maxBytes < 1 {
maxBytes = service.ImageMaxBytes
}
overhead := int64(4096)
limit := maxBytes + overhead
if c.Request.ContentLength > limit {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
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) {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"})
return
}
if fh.Size > maxBytes {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
defer f.Close()
att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source"))
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// UseAvatarRequest 选用历史头像请求
type UseAvatarRequest struct {
URL string `json:"url"`
}
// UseAvatar 选用本人历史上传的头像
func (h *Handlers) UseAvatar(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req UseAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数格式错误"})
return
}
if err := h.Upload.UseAvatar(claims.ID, req.URL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": req.URL})
}
// MyMedia 媒体库:本人上传的全部图片(头像、帖子插图等)
func (h *Handlers) MyMedia(c *gin.Context) {
claims := middleware.CurrentUser(c)
list, err := h.Upload.ListMedia(claims.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"attachments": nonNilSlice(list)})
}
// DeleteAttachment 彻底删除本人附件(DB + 物理文件)
func (h *Handlers) DeleteAttachment(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"})
return
}
if err := h.Upload.DeleteAttachment(claims.ID, uint(id)); err != nil {
if errors.Is(err, service.ErrAttachmentInUse) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),只落盘返回 URL,不写站点设置
func (h *Handlers) UploadBackground(c *gin.Context) {
if _, ok := service.NormalizeBgSurface(c.PostForm("surface")); !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
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
type backgroundFromMediaRequest struct {
AttachmentID uint `json:"attachment_id"`
Surface string `json:"surface"`
}
// UploadBackgroundFromMedia 把本人媒体库图片复制进 backgrounds,不写站点设置
func (h *Handlers) UploadBackgroundFromMedia(c *gin.Context) {
var req backgroundFromMediaRequest
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
return
}
if _, ok := service.NormalizeBgSurface(req.Surface); !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
url, err := h.Upload.CopyBackgroundFromMedia(claims.ID, req.AttachmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
// UploadBrand 超管上传 Logo / Favicon,只落盘返回 URL,不写站点设置
func (h *Handlers) UploadBrand(c *gin.Context) {
slot := c.PostForm("slot")
if _, ok := service.NormalizeBrandSlot(slot); !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定 logo_light、logo_dark 或 favicon"})
return
}
maxB := int64(service.BrandLogoMaxBytes)
if slot == service.BrandSlotFavicon {
maxB = service.BrandFaviconMaxBytes
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxB+4096)
fh, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": brandTooLarge(slot)})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"})
return
}
if fh.Size > maxB {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": brandTooLarge(slot)})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
defer f.Close()
url, err := h.Upload.SaveBrand(slot, f)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": brandTooLarge(slot)})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
type brandFromMediaRequest struct {
AttachmentID uint `json:"attachment_id"`
Slot string `json:"slot"`
}
// UploadBrandFromMedia 把本人媒体库图片复制进品牌目录,不写站点设置
func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
var req brandFromMediaRequest
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
url, err := h.Upload.CopyBrandFromMedia(claims.ID, req.AttachmentID, req.Slot)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
var req struct {
AttachmentID uint `json:"attachment_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil {
c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing})
return
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// brandAliasSlots 固定地址路径段 → 品牌槽位
var brandAliasSlots = map[string]string{
"logo-light": service.BrandSlotLight,
"logo-dark": service.BrandSlotDark,
"favicon": service.BrandSlotFavicon,
}
// BrandImage 品牌图固定对外地址(GET /api/brand/logo-light|logo-dark|favicon):
// 友链等外部引用此地址,站点换图后按设置解析到当前图,地址永不变。
func (h *Handlers) BrandImage(c *gin.Context) {
slot, ok := brandAliasSlots[c.Param("slot")]
if !ok {
c.Status(http.StatusNotFound)
return
}
var url string
var err error
switch slot {
case service.BrandSlotLight:
url, err = h.Setting.LogoLightURL()
case service.BrandSlotDark:
url, err = h.Setting.LogoDarkURL()
default:
url, err = h.Setting.FaviconURL()
}
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
if url == "" {
c.Status(http.StatusNotFound)
return
}
f, err := h.Upload.OpenBrandFile(url)
if err != nil {
c.Status(http.StatusNotFound)
return
}
defer f.Close()
// 扩展名定 Content-Type(SVG 不能靠嗅探);短缓存让换图在友链侧尽快生效
if ct := mime.TypeByExtension(path.Ext(url)); ct != "" {
c.Header("Content-Type", ct)
}
c.Header("Cache-Control", "public, max-age=3600")
http.ServeContent(c.Writer, c.Request, path.Base(url), time.Time{}, f)
}
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
items, counts, err := h.Upload.AdminMediaLibrary()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取媒体库失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "counts": counts, "total": len(items)})
}
// MediaLibraryThumb 媒体库网格缩略图(?u=/uploads/images/xx.webp),失败回退由前端处理
func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
data, err := h.Upload.MediaThumb(c.Query("u"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "缩略图不可用"})
return
}
c.Header("Cache-Control", "private, max-age=86400")
c.Data(http.StatusOK, "image/webp", data)
}
// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>):
// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底
func (h *Handlers) ImageVariant(c *gin.Context) {
w, err := strconv.Atoi(c.Query("w"))
if err != nil || !service.IsVariantWidth(w) {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"})
return
}
data, m, err := h.Upload.Variant(c.Query("u"), w)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"})
return
}
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, m, data)
}
func brandTooLarge(slot string) string {
if slot == service.BrandSlotFavicon {
return "Favicon 不能超过 512KB"
}
return "Logo 不能超过 2MB"
}