Files
OpenFlare/backend/plugins/domain/upload/filesrv/file_server.go
T
ryan 7e6b9e7c2f autoresearch iter 4: stop one disconnected client from failing a shared image flight
EnsureCompressedImageCache passed the arriving caller's request context into
the singleflight body, which runs once for every concurrent requester of that
cache key. If the first client disconnected, gin canceled the context, the
shared generation aborted, and every follower received that failure and fell
back to the uncompressed original. Detach cancellation with
context.WithoutCancel so trace values still propagate but the shared work
outlives any single requester.
2026-08-29 08:07:19 +08:00

362 lines
9.8 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package filesrv serves uploaded files with access control and image compression.
package filesrv
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/upload/cache"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"Wavelet/plugins/domain/upload/util"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
pkgcache "Wavelet/pkg/cache/disk"
"Wavelet/pkg/ginutil"
"Wavelet/plugins/domain/upload/repository"
uploadstorage "Wavelet/plugins/domain/upload/storage"
"github.com/gin-gonic/gin"
"golang.org/x/sync/singleflight"
)
var (
compressedImageFlight singleflight.Group
globalDiskCache *pkgcache.Cache
globalDiskCacheOnce sync.Once
)
func getGlobalDiskCache() *pkgcache.Cache {
globalDiskCacheOnce.Do(func() {
globalDiskCache = pkgcache.New("uploads/diskcache")
})
return globalDiskCache
}
type compressedImageCacheResult struct {
bytes []byte
cached bool
err error
}
type fileTypeCategory string
const (
fileTypeImage fileTypeCategory = "image"
fileTypeVideo fileTypeCategory = "video"
fileTypeAudio fileTypeCategory = "audio"
fileTypeOther fileTypeCategory = "other"
)
// 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} response.Any "文件 ID 格式错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "文件未找到"
// @Failure 500 {object} response.Any "服务内部错误"
// @Router /f/{id} [get]
func ServeFileByID(c *gin.Context) {
upload, err := GetUploadRecordByID(c)
if err != nil {
if AbortUploadRecordError(c, err) {
return
}
response.AbortInternal(c, shared.ErrInternalServerError)
return
}
if err := CheckFileAccessPermission(c, upload); err != nil {
response.AbortUnauthorized(c, shared.ErrUnauthorized)
return
}
ServeUpload(c, upload)
}
// AbortUploadRecordError maps an upload record lookup failure to its HTTP
// response and reports whether it handled the error. A missing record is 404
// and a malformed path ID is 400; anything else is left to the caller.
func AbortUploadRecordError(c *gin.Context, err error) bool {
var numErr *strconv.NumError
switch {
case repository.IsRecordNotFound(err):
response.AbortNotFound(c, shared.ErrFileRecordNotFound)
return true
case errors.As(err, &numErr):
response.AbortBadRequest(c, shared.ErrInvalidFileID)
return true
default:
return false
}
}
// GetUploadRecordByID 从请求路径参数中解析文件 ID 并从数据库中检索处于 Pending 或 Used 状态的上传记录。
func GetUploadRecordByID(c *gin.Context) (*models.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
}
upload, err := cache.GetUploadByID(c.Request.Context(), uploadID)
if err != nil {
return nil, err
}
return &upload, nil
}
func getFileTypeCategory(upload *models.Upload) fileTypeCategory {
mime := strings.ToLower(upload.MimeType)
ext := strings.ToLower(upload.Extension)
if strings.HasPrefix(mime, "image/") || util.IsImageExtension(ext) {
return fileTypeImage
}
if strings.HasPrefix(mime, "video/") {
return fileTypeVideo
}
if strings.HasPrefix(mime, "audio/") {
return fileTypeAudio
}
return fileTypeOther
}
// ServeUpload 将已存在的文件内容读取并流式响应给客户端。
func ServeUpload(c *gin.Context, upload *models.Upload) {
setCacheHeaders(c, upload)
category := getFileTypeCategory(upload)
quality := util.NormalizeImageQuality(c.Query("quality"))
switch category {
case fileTypeImage:
if quality != shared.ImageQualityOrigin {
serveCompressedImage(c, upload, quality)
return
}
fallthrough
default:
serveOriginalWithConditionalCheck(c, upload)
}
}
func setCacheHeaders(c *gin.Context, upload *models.Upload) {
if cache.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 *models.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 *models.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, hit, err := EnsureCompressedImageCache(c.Request.Context(), upload, quality)
if hit {
c.Header("X-Cache", "HIT")
} else {
c.Header("X-Cache", "MISS")
}
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)
}
// EnsureCompressedImageCache returns cached or freshly generated WebP bytes for an upload.
func EnsureCompressedImageCache(
ctx context.Context,
upload *models.Upload,
quality string,
) ([]byte, bool, error) {
cacheStore := getGlobalDiskCache()
cacheKey := ImageCompressionCacheKey(upload, quality)
webpBytes, err := cacheStore.Get(cacheKey)
if err == nil {
return webpBytes, true, nil
}
if !errors.Is(err, pkgcache.ErrCacheMiss) {
return nil, false, fmt.Errorf("read compressed image cache: %w", err)
}
// The flight body serves every concurrent requester for this key, so it must
// not die when whichever caller happened to arrive first disconnects.
flightCtx := context.WithoutCancel(ctx)
result, err, _ := compressedImageFlight.Do(cacheKey, func() (any, error) {
return generateCompressedImageCache(flightCtx, upload, quality, cacheKey)
})
if err != nil {
return nil, false, err
}
res := result.(compressedImageCacheResult)
return res.bytes, res.cached, res.err
}
func generateCompressedImageCache(
ctx context.Context,
upload *models.Upload,
quality string,
cacheKey string,
) (compressedImageCacheResult, error) {
cacheStore := getGlobalDiskCache()
webpBytes, err := cacheStore.Get(cacheKey)
if err == nil {
return compressedImageCacheResult{bytes: webpBytes, cached: true}, nil
}
if !errors.Is(err, pkgcache.ErrCacheMiss) {
return compressedImageCacheResult{}, fmt.Errorf("read compressed image cache: %w", err)
}
origBytes, err := getOriginalFileBytes(ctx, upload)
if err != nil {
return compressedImageCacheResult{}, fmt.Errorf("read original image: %w", err)
}
webpBytes, err = util.CompressImageToWebP(bytes.NewReader(origBytes), quality)
if err != nil {
return compressedImageCacheResult{}, fmt.Errorf("compress image to WebP: %w", err)
}
if err := cacheStore.Set(cacheKey, webpBytes, pkgcache.NoExpiration); err != nil {
return compressedImageCacheResult{
bytes: webpBytes,
err: fmt.Errorf("write compressed image cache: %w", err),
}, nil
}
return compressedImageCacheResult{bytes: webpBytes}, nil
}
// ImageCompressionCacheKey returns the disk cache key for a compressed upload image.
func ImageCompressionCacheKey(upload *models.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 serveOriginal(c *gin.Context, upload *models.Upload) {
obj, err := uploadstorage.OpenStoredObject(c.Request.Context(), upload)
if err != nil {
response.AbortNotFound(c, shared.ErrFileNotFound)
return
}
defer func() { _ = obj.Body.Close() }()
contentType := obj.ContentType
if upload.MimeType != "" {
contentType = upload.MimeType
}
c.DataFromReader(http.StatusOK, obj.ContentLength, contentType, obj.Body, nil)
}
func getOriginalFileBytes(ctx context.Context, upload *models.Upload) ([]byte, error) {
obj, err := uploadstorage.OpenStoredObject(ctx, upload)
if err != nil {
return nil, err
}
defer func() { _ = obj.Body.Close() }()
return io.ReadAll(obj.Body)
}
func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error {
var currUserID uint64
var isAdmin bool
if u, ok := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey); ok && u != nil {
currUserID = u.ID
isAdmin = u.IsAdmin
} else if authSvc := shared.GetAuthService(c); authSvc != nil {
u, err := authSvc.GetCurrentUser(c)
if err != nil {
return err
}
currUserID = u.ID
isAdmin = u.IsAdmin
} else {
return errors.New("unauthorized")
}
if isAdmin {
return nil
}
if currUserID != ownerID {
return errors.New("forbidden: cross-user access denied")
}
return nil
}
// CheckFileAccessPermission 校验文件是否可以被当前请求访问
func CheckFileAccessPermission(c *gin.Context, upload *models.Upload) error {
if upload.AccessMode == 0 {
return checkPrivateFileOwner(c, upload.UserID)
}
if cache.IsFilePublic(c.Request.Context(), upload.Type) {
return nil
}
if _, ok := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey); ok {
return nil
}
authSvc := shared.GetAuthService(c)
if authSvc == nil {
return nil
}
_, err := authSvc.GetCurrentUser(c)
return err
}