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" }