Files
jiang13-bbs/backend/handler/upload.go
2026-09-19 04:14:13 +08:00

312 lines
8.9 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"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/realtime"
"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)
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)
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),并立即绑定前台或后台
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)
}
// 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})
}
func brandTooLarge(slot string) string {
if slot == service.BrandSlotFavicon {
return "Favicon 不能超过 512KB"
}
return "Logo 不能超过 2MB"
}