mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
9a18bea324
Move storage backend configuration from startup YAML to system_config-backed runtime configuration. Add local, S3-compatible, R2, MinIO, OSS, and WebDAV backend support. Add a storage migration async task using the existing task dispatch framework. Migration target config is carried in task payload, and maintenance mode is derived from task execution state. Split upload file management and storage operations, add the admin storage configuration tab, and update migrations and Swagger docs.
319 lines
9.1 KiB
Go
319 lines
9.1 KiB
Go
// Copyright 2025 linux.do
|
|
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package upload
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
|
"github.com/Rain-kl/Wavelet/internal/common"
|
|
"github.com/Rain-kl/Wavelet/internal/db"
|
|
"github.com/Rain-kl/Wavelet/internal/diskcache"
|
|
"github.com/Rain-kl/Wavelet/internal/logger"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/util"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ServeFileByID 根据 ID 获取并提供已上传的文件
|
|
// @Summary 获取已上传文件
|
|
// @Description 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回
|
|
// @Tags upload
|
|
// @Produce octet-stream
|
|
// @Param id path string true "文件 ID"
|
|
// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin"
|
|
// @Success 200 {file} file "成功获取文件内容"
|
|
// @Failure 400 {object} util.ResponseAny "文件 ID 格式错误"
|
|
// @Failure 401 {object} util.ResponseAny "未登录"
|
|
// @Failure 404 {object} util.ResponseAny "文件未找到"
|
|
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
|
// @Router /f/{id} [get]
|
|
func ServeFileByID(c *gin.Context) {
|
|
upload, err := getUploadRecordByID(c)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
if _, ok := err.(*strconv.NumError); ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload ID"})
|
|
return
|
|
}
|
|
c.AbortWithStatus(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 校验业务白名单与访问权限
|
|
if err := checkFileAccessPermission(c, upload); err != nil {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
|
|
return
|
|
}
|
|
|
|
ServeUpload(c, upload)
|
|
}
|
|
|
|
// getUploadRecordByID 从请求路径参数中解析文件 ID 并从数据库中检索处于 Pending 或 Used 状态的上传记录。
|
|
// 同时会自动设置通用的安全响应头。
|
|
func getUploadRecordByID(c *gin.Context) (*model.Upload, error) {
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Header("Content-Security-Policy", "sandbox")
|
|
|
|
idStr := c.Param("id")
|
|
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var upload model.Upload
|
|
if err := db.DB(c.Request.Context()).
|
|
Where("id = ? AND status IN (?, ?)", uploadID, model.UploadStatusPending, model.UploadStatusUsed).
|
|
First(&upload).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &upload, nil
|
|
}
|
|
|
|
// fileTypeCategory 定义文件的大类,便于未来扩展不同的处理方式
|
|
type fileTypeCategory string
|
|
|
|
const (
|
|
fileTypeImage fileTypeCategory = "image"
|
|
fileTypeVideo fileTypeCategory = "video"
|
|
fileTypeAudio fileTypeCategory = "audio"
|
|
fileTypeOther fileTypeCategory = "other"
|
|
)
|
|
|
|
// getFileTypeCategory 判断并返回文件的大类
|
|
func getFileTypeCategory(upload *model.Upload) fileTypeCategory {
|
|
mime := strings.ToLower(upload.MimeType)
|
|
ext := strings.ToLower(upload.Extension)
|
|
|
|
if strings.HasPrefix(mime, "image/") || isImageExtension(ext) {
|
|
return fileTypeImage
|
|
}
|
|
if strings.HasPrefix(mime, "video/") {
|
|
return fileTypeVideo
|
|
}
|
|
if strings.HasPrefix(mime, "audio/") {
|
|
return fileTypeAudio
|
|
}
|
|
return fileTypeOther
|
|
}
|
|
|
|
// ServeUpload 将已存在的文件内容读取并流式响应给客户端,支持本地和 S3/CDN 驱动,并可选支持 WebP 图片压缩与本地缓存。
|
|
func ServeUpload(c *gin.Context, upload *model.Upload) {
|
|
// 设置通用的缓存控制响应头
|
|
setCacheHeaders(c, upload)
|
|
|
|
category := getFileTypeCategory(upload)
|
|
quality := normalizeImageQuality(c.Query("quality"))
|
|
|
|
switch category {
|
|
case fileTypeImage:
|
|
// 如果是图片且不是原图质量,则提供压缩优化后的图片预览
|
|
if quality != imageQualityOrigin {
|
|
serveCompressedImage(c, upload, quality)
|
|
return
|
|
}
|
|
// 请求原图质量时,退化到默认提供原文件
|
|
fallthrough
|
|
|
|
default:
|
|
// 默认提供原文件,并执行协商缓存校验
|
|
serveOriginalWithConditionalCheck(c, upload)
|
|
}
|
|
}
|
|
|
|
func setCacheHeaders(c *gin.Context, upload *model.Upload) {
|
|
if isFilePublic(c.Request.Context(), upload.Type) {
|
|
c.Header("Cache-Control", "public, max-age=31536000")
|
|
} else {
|
|
c.Header("Cache-Control", "private, no-cache")
|
|
}
|
|
}
|
|
|
|
func serveOriginalWithConditionalCheck(c *gin.Context, upload *model.Upload) {
|
|
etag := fmt.Sprintf(`W/"%s"`, upload.Hash)
|
|
c.Header("ETag", etag)
|
|
|
|
if c.GetHeader("If-None-Match") == etag {
|
|
c.AbortWithStatus(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
serveOriginal(c, upload)
|
|
}
|
|
|
|
func serveCompressedImage(c *gin.Context, upload *model.Upload, quality string) {
|
|
etag := fmt.Sprintf(`W/"%s-%s"`, upload.Hash, quality)
|
|
c.Header("ETag", etag)
|
|
|
|
if c.GetHeader("If-None-Match") == etag {
|
|
c.AbortWithStatus(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
webpBytes, _, err := ensureCompressedImageCache(c.Request.Context(), upload, quality)
|
|
if err != nil {
|
|
if len(webpBytes) > 0 {
|
|
logger.WarnF(c.Request.Context(), "failed to cache compressed image: %v", err)
|
|
c.Data(http.StatusOK, "image/webp", webpBytes)
|
|
return
|
|
}
|
|
logger.ErrorF(c.Request.Context(), "failed to prepare compressed image cache: %v", err)
|
|
serveOriginal(c, upload)
|
|
return
|
|
}
|
|
|
|
c.Data(http.StatusOK, "image/webp", webpBytes)
|
|
}
|
|
|
|
func ensureCompressedImageCache(
|
|
ctx context.Context,
|
|
upload *model.Upload,
|
|
quality string,
|
|
) ([]byte, bool, error) {
|
|
cache := diskcache.GetGlobalCache()
|
|
cacheKey := imageCompressionCacheKey(upload, quality)
|
|
webpBytes, err := cache.Get(cacheKey)
|
|
if err == nil {
|
|
return webpBytes, true, nil
|
|
}
|
|
if !errors.Is(err, diskcache.ErrCacheMiss) {
|
|
return nil, false, fmt.Errorf("read compressed image cache: %w", err)
|
|
}
|
|
|
|
origBytes, err := getOriginalFileBytes(ctx, upload)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("read original image: %w", err)
|
|
}
|
|
|
|
webpBytes, err = CompressImageToWebP(bytes.NewReader(origBytes), quality)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("compress image to WebP: %w", err)
|
|
}
|
|
|
|
if err := cache.Set(cacheKey, webpBytes, diskcache.NoExpiration); err != nil {
|
|
return webpBytes, false, fmt.Errorf("write compressed image cache: %w", err)
|
|
}
|
|
|
|
return webpBytes, false, nil
|
|
}
|
|
|
|
func imageCompressionCacheKey(upload *model.Upload, quality string) string {
|
|
return fmt.Sprintf(
|
|
"upload_webp_v1_%d_%d_%d_%s_%s",
|
|
upload.ID,
|
|
upload.UpdatedAt.UnixNano(),
|
|
upload.FileSize,
|
|
upload.Hash,
|
|
quality,
|
|
)
|
|
}
|
|
|
|
func normalizeImageQuality(quality string) string {
|
|
switch strings.ToLower(quality) {
|
|
case imageQualityLow, imageQualityMedium, imageQualityHigh:
|
|
return strings.ToLower(quality)
|
|
default:
|
|
return imageQualityOrigin
|
|
}
|
|
}
|
|
|
|
// serveOriginal 原始文件的流式响应逻辑
|
|
func serveOriginal(c *gin.Context, upload *model.Upload) {
|
|
obj, err := openStoredObject(c.Request.Context(), upload)
|
|
if err != nil {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = obj.Body.Close() }()
|
|
c.DataFromReader(http.StatusOK, obj.ContentLength, obj.ContentType, obj.Body, nil)
|
|
}
|
|
|
|
// getOriginalFileBytes 获取原始文件所有字节
|
|
func getOriginalFileBytes(ctx context.Context, upload *model.Upload) ([]byte, error) {
|
|
obj, err := openStoredObject(ctx, upload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = obj.Body.Close() }()
|
|
return io.ReadAll(obj.Body)
|
|
}
|
|
|
|
// isFilePublic 校验文件类型是否在公开访问白名单中
|
|
func isFilePublic(ctx context.Context, uploadType string) bool {
|
|
var sc model.SystemConfig
|
|
var whitelist []string
|
|
if err := sc.GetByKey(ctx, model.ConfigKeyFileAccessWhitelist); err == nil && sc.Value != "" {
|
|
if err := json.Unmarshal([]byte(sc.Value), &whitelist); err != nil {
|
|
// 降级使用逗号分隔解析
|
|
parts := strings.Split(sc.Value, ",")
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
whitelist = append(whitelist, p)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// 默认兜底白名单为 avatar
|
|
whitelist = []string{"avatar"}
|
|
}
|
|
|
|
for _, w := range whitelist {
|
|
if strings.EqualFold(w, uploadType) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error {
|
|
var currUser *model.User
|
|
var err error
|
|
if u, ok := util.GetFromContext[*model.User](c, oauth.UserObjKey); ok && u != nil {
|
|
currUser = u
|
|
} else {
|
|
currUser, err = oauth.GetUserFromRequest(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if currUser.ID != ownerID {
|
|
return errors.New("forbidden: cross-user access denied")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkFileAccessPermission 校验文件是否可以被当前请求访问
|
|
func checkFileAccessPermission(c *gin.Context, upload *model.Upload) error {
|
|
// 1. 私有文件校验(优先级高于当前白名单逻辑)
|
|
if upload.AccessMode == 0 {
|
|
return checkPrivateFileOwner(c, upload.UserID)
|
|
}
|
|
|
|
// 2. 如果类型为公开的则再进行校验白名单
|
|
if !isFilePublic(c.Request.Context(), upload.Type) {
|
|
// 必须进行鉴权
|
|
if _, ok := util.GetFromContext[*model.User](c, oauth.UserObjKey); !ok {
|
|
if _, err := oauth.GetUserFromRequest(c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|