新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
440 lines
14 KiB
Go
440 lines
14 KiB
Go
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"
|
||
}
|