- nav_links 增加 position(header/footer,迁移 v11),后台“导航链接”拆为头部/底部两张卡片,页脚链接支持多语言、新窗口与点分隔,删除硬编码的关于我们等链接 - site_settings 增加 favicon(迁移 v12),新增 PUT/DELETE /api/site/favicon,支持 ICO/PNG/SVG 等且不裁剪,引用计数与 Logo 一致自动管理 - 文件服务识别 SVG 并允许内联,统一附加 CSP(default-src 'none'; sandbox)防止存储型 XSS;Logo 维持仅栅格 - 前端 SiteInfoForm 增加 Favicon 上传/清空/预览,App.vue 动态更新 link rel=icon,三语文案补齐 - 补充位置与 Favicon 的接口/引用计数/安全头测试,重新生成 Swagger 文档
204 lines
6.5 KiB
Go
204 lines
6.5 KiB
Go
package file
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
|
|
"rill/internal/auth"
|
|
"rill/internal/config"
|
|
"rill/internal/httpx"
|
|
"rill/internal/model"
|
|
"rill/internal/utils"
|
|
)
|
|
|
|
// multipartOverhead 预留 multipart 边界与头部体积。
|
|
const multipartOverhead = 1 << 20
|
|
|
|
// ReadUpload 按配置限制请求体大小并读取 multipart 字段 file,失败时已写入响应。
|
|
func ReadUpload(c *gin.Context, cfg *config.Config) (*multipart.FileHeader, bool) {
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cfg.MaxUploadBytes()+multipartOverhead)
|
|
header, err := c.FormFile("file")
|
|
if err != nil {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
c.JSON(http.StatusRequestEntityTooLarge, httpx.ErrorResponse{Error: "file too large"})
|
|
return nil, false
|
|
}
|
|
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: file is required"})
|
|
return nil, false
|
|
}
|
|
return header, true
|
|
}
|
|
|
|
// RespondSaveError 将存储错误映射为 HTTP 响应。
|
|
func RespondSaveError(c *gin.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, ErrTooLarge):
|
|
c.JSON(http.StatusRequestEntityTooLarge, httpx.ErrorResponse{Error: err.Error()})
|
|
case errors.Is(err, ErrEmpty):
|
|
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()})
|
|
default:
|
|
httpx.RespondDBError(c, err)
|
|
}
|
|
}
|
|
|
|
// OperatorOf 构造操作人快照。
|
|
func OperatorOf(c *gin.Context, user model.User) Operator {
|
|
name := user.Nickname
|
|
if name == "" {
|
|
name = user.Username
|
|
}
|
|
return Operator{ID: &user.ID, Name: name, IP: utils.ClientIP(c)}
|
|
}
|
|
|
|
// @Summary Upload a file
|
|
// @Description Upload a file (multipart field file). Content is deduplicated by sha256; the returned file has ref_count 0 until a business reference is acquired. Size limit from storage.max_size_mb.
|
|
// @Tags user
|
|
// @Accept mpfd
|
|
// @Produce json
|
|
// @Param file formData file true "File content"
|
|
// @Success 201 {object} model.File
|
|
// @Failure 400 {object} httpx.ErrorResponse "invalid request or empty file"
|
|
// @Failure 413 {object} httpx.ErrorResponse "file too large"
|
|
// @Security BearerAuth
|
|
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
|
// @Failure 403 {object} httpx.ErrorResponse "account disabled"
|
|
// @Failure 500 {object} httpx.ErrorResponse
|
|
// @Router /files [post]
|
|
func Upload(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
current, ok := auth.CurrentUser(c)
|
|
if !ok {
|
|
httpx.RespondUnauthorized(c)
|
|
return
|
|
}
|
|
header, ok := ReadUpload(c, cfg)
|
|
if !ok {
|
|
return
|
|
}
|
|
src, err := header.Open()
|
|
if err != nil {
|
|
httpx.RespondServerError(c, err, "打开上传文件失败")
|
|
return
|
|
}
|
|
defer src.Close()
|
|
|
|
saved, err := Save(c.Request.Context(), db, cfg, OperatorOf(c, current), header.Filename, src)
|
|
if err != nil {
|
|
RespondSaveError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, saved)
|
|
}
|
|
}
|
|
|
|
// @Summary Delete a file
|
|
// @Description Delete a file physically and keep the record with status 0; uploader or admin only. Files still referenced (ref_count > 0) return 409.
|
|
// @Tags user
|
|
// @Produce json
|
|
// @Param id path int true "File ID" example(1)
|
|
// @Success 204 "Deleted"
|
|
// @Failure 400 {object} httpx.ErrorResponse "invalid id"
|
|
// @Failure 403 {object} httpx.ErrorResponse "permission denied or account disabled"
|
|
// @Failure 404 {object} httpx.ErrorResponse "record not found"
|
|
// @Failure 409 {object} httpx.ErrorResponse "file is in use"
|
|
// @Security BearerAuth
|
|
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
|
|
// @Failure 500 {object} httpx.ErrorResponse
|
|
// @Router /files/{id} [delete]
|
|
func Delete(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
current, ok := auth.CurrentUser(c)
|
|
if !ok {
|
|
httpx.RespondUnauthorized(c)
|
|
return
|
|
}
|
|
id, ok := httpx.ParseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
var record model.File
|
|
if err := db.WithContext(ctx).First(&record, id).Error; err != nil {
|
|
httpx.RespondGetError(c, err)
|
|
return
|
|
}
|
|
if record.UploaderID == nil || *record.UploaderID != current.ID {
|
|
if !current.IsAdmin() {
|
|
c.JSON(http.StatusForbidden, httpx.ErrorResponse{Error: "permission denied"})
|
|
return
|
|
}
|
|
}
|
|
if err := DeleteFile(ctx, db, cfg, record, OperatorOf(c, current)); err != nil {
|
|
if errors.Is(err, ErrFileInUse) {
|
|
c.JSON(http.StatusConflict, httpx.ErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
httpx.RespondDBError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// @Summary Get file content
|
|
// @Description Public file content. Images, videos, audio, PDF and plain text are served inline; other types are served as attachments. Disabled files return 404.
|
|
// @Tags public
|
|
// @Produce application/octet-stream
|
|
// @Param id path int true "File ID" example(1)
|
|
// @Success 200 {file} binary
|
|
// @Failure 400 {object} httpx.ErrorResponse "invalid id"
|
|
// @Failure 404 {object} httpx.ErrorResponse "record not found"
|
|
// @Failure 500 {object} httpx.ErrorResponse
|
|
// @Router /files/{id} [get]
|
|
func View(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
id, ok := httpx.ParseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var record model.File
|
|
if err := db.WithContext(c.Request.Context()).First(&record, id).Error; err != nil {
|
|
httpx.RespondGetError(c, err)
|
|
return
|
|
}
|
|
if record.Status != model.FileStatusEnabled {
|
|
c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: "record not found"})
|
|
return
|
|
}
|
|
|
|
handle, err := Open(cfg.Storage.Dir, record)
|
|
if err != nil {
|
|
if errors.Is(err, ErrFileNotFound) {
|
|
c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: "record not found"})
|
|
return
|
|
}
|
|
httpx.RespondDBError(c, err)
|
|
return
|
|
}
|
|
defer handle.Close()
|
|
|
|
contentType := record.MimeType
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
disposition := "attachment"
|
|
if CanInline(contentType) {
|
|
disposition = "inline"
|
|
}
|
|
c.Header("Content-Disposition", fmt.Sprintf("%s; filename*=UTF-8''%s", disposition, url.PathEscape(record.Name)))
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Header("Content-Security-Policy", cspHeader)
|
|
c.Header("Cache-Control", viewCacheControl)
|
|
c.DataFromReader(http.StatusOK, record.Size, contentType, handle, nil)
|
|
}
|
|
}
|