// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package upload import ( "archive/zip" "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "path/filepath" "strconv" "strings" "time" "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/db/idgen" "github.com/Rain-kl/Wavelet/internal/logger" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/storage" "github.com/Rain-kl/Wavelet/internal/util" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type batchDownloadRequest struct { IDs []string `json:"ids" binding:"required,min=1"` } // UploadFile 通用上传文件接口 // @Summary 上传文件 // @Description 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重 // @Tags upload // @Accept multipart/form-data // @Produce json // @Param file formData file true "要上传的文件" // @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)" // @Param metadata formData string false "额外的 JSON 格式元数据" // @Security SessionCookie // @Success 200 {object} util.ResponseAny{data=model.Upload} "上传成功" // @Failure 400 {object} util.ResponseAny "请求参数错误或文件受限" // @Failure 401 {object} util.ResponseAny "未登录" // @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/upload [post] // //nolint:revive func UploadFile(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") c.Header("Content-Security-Policy", "sandbox") // 限制请求体大小以防止 DoS c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) ctx := c.Request.Context() header, err := c.FormFile("file") if err != nil { c.JSON(http.StatusOK, util.Err(ErrNoFileSelected)) return } file, err := header.Open() if err != nil { c.JSON(http.StatusOK, util.Err(ErrOpenFileFailed)) return } defer func() { _ = file.Close() }() // 校验大小 if header.Size > maxUploadSize { c.JSON(http.StatusOK, util.Err(ErrGenericFileTooLarge)) return } // 2. 提取文件基本元数据 origName := header.Filename ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origName), ".")) if ext == "" { ext = "bin" } // 3. 校验文件后缀是否在允许的系统配置列表中 if errMsg := validateUploadExtension(ctx, ext); errMsg != "" { c.JSON(http.StatusOK, util.Err(errMsg)) return } // 4. 读取文件并计算 Hash hashWriter := sha256.New() var buf bytes.Buffer size, err := io.Copy(&buf, io.TeeReader(file, hashWriter)) if err != nil { c.JSON(http.StatusOK, util.Err(ErrProcessFileFailed)) return } fileHash := hex.EncodeToString(hashWriter.Sum(nil)) mimeType := detectMimeType(&buf, header, size) // 校验真实 MIME Type 是否与常见图片扩展名匹配,防止 Polyglot / HTML 注入攻击 if isImageExtension(ext) && !strings.HasPrefix(mimeType, "image/") { c.JSON(http.StatusOK, util.Err(ErrFileContentExtensionMismatch)) return } uploadType := c.DefaultPostForm("type", "generic") accessModeStr := c.PostForm("access_mode") var accessMode int if accessModeStr == "" { if uploadType == "avatar" { accessMode = 1 } else { accessMode = 0 } } else { var err error accessMode, err = strconv.Atoi(accessModeStr) if err != nil || (accessMode != 0 && accessMode != 1) { c.JSON(http.StatusOK, util.Err("无效的 access_mode 参数")) return } } // 6. 秒传匹配校验:校验数据库中是否存在相同 Hash 且大小一致的可用文件 handled, lookupErr := tryInstantUpload(ctx, c, currUser, fileHash, size, mimeType, ext, origName, accessMode) if handled { return } if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) { c.JSON(http.StatusOK, util.Err(ErrFileValidationFailed)) return } // 7. 解析可选元数据字段 meta, errMsg := parseUploadMetadata(c, mimeType) if errMsg != "" { c.JSON(http.StatusOK, util.Err(errMsg)) return } id := idgen.NextUint64ID() subPath := fmt.Sprintf("uploads/%s/%d.%s", time.Now().Format("2006/01/02"), id, ext) // 8. 写入当前活动存储驱动。 storageDriver, subPath, errMsg := storeUploadFile(ctx, subPath, size, mimeType, &buf, &meta) if errMsg != "" { c.JSON(http.StatusOK, util.Err(errMsg)) return } // 9. 保存文件记录至数据库 newUpload := model.Upload{ ID: id, UserID: currUser.ID, FileName: origName, FilePath: subPath, FileSize: size, MimeType: mimeType, Extension: ext, Hash: fileHash, StorageDriver: storageDriver, Type: uploadType, Status: model.UploadStatusUsed, AccessMode: accessMode, Metadata: meta, } if err := saveUploadRecord(ctx, &newUpload, storageDriver, subPath); err != "" { c.JSON(http.StatusOK, util.Err(err)) return } c.JSON(http.StatusOK, util.OK(newUpload)) } // DownloadFile 通用单文件下载接口 // @Summary 下载单文件 // @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 // @Tags admin // @Produce octet-stream // @Param id path string true "文件 ID" // @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin" // @Security SessionCookie // @Success 200 {file} file "成功下载文件" // @Failure 400 {object} util.ResponseAny "参数错误" // @Failure 404 {object} util.ResponseAny "文件不存在" // @Failure 500 {object} util.ResponseAny "服务内部错误" // @Router /api/v1/admin/uploads/download/{id} [get] func DownloadFile(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.StatusOK, util.Err(ErrInvalidFileID)) return } c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed)) return } // 校验文件访问权限 if err := checkFileAccessPermission(c, upload); err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) return } fileName := upload.FileName quality := normalizeImageQuality(c.Query("quality")) isImage := strings.HasPrefix(strings.ToLower(upload.MimeType), "image/") || isImageExtension(strings.ToLower(upload.Extension)) if quality != imageQualityOrigin && isImage { ext := filepath.Ext(fileName) if ext != "" { fileName = strings.TrimSuffix(fileName, ext) + ".webp" } else { fileName += ".webp" } } // 设置下载 Attachment 响应头 (支持 UTF-8 中文文件名转义) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(fileName))) ServeUpload(c, upload) } // BatchDownloadFiles 批量打包 ZIP 下载接口 // @Summary 批量打包下载 // @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 // @Tags admin // @Accept json // @Produce octet-stream // @Param request body upload.batchDownloadRequest true "包含文件 ID 数组 of string 的请求体" // @Security SessionCookie // @Success 200 {file} file "成功下载打包后的 ZIP" // @Failure 400 {object} util.ResponseAny "参数错误" // @Failure 500 {object} util.ResponseAny "打包失败" // @Router /api/v1/admin/uploads/download/batch [post] func BatchDownloadFiles(c *gin.Context) { ctx := c.Request.Context() var req batchDownloadRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, util.Err(ErrInvalidBatchDownloadRequest)) return } // 转换 ID 列表 var ids []uint64 for _, idStr := range req.IDs { id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { c.JSON(http.StatusOK, util.Err(fmt.Sprintf(ErrInvalidIDValueFormat, idStr))) return } ids = append(ids, id) } // 查库获取所有匹配且正常的文件记录 var uploads []model.Upload if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil { c.JSON(http.StatusOK, util.Err(ErrRetrieveUploadRecordsFailed)) return } if len(uploads) == 0 { c.JSON(http.StatusOK, util.Err(ErrNoValidFilesForArchive)) return } // 设置 ZIP 格式流的响应头 c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", "attachment; filename=\"batch_download.zip\"") // 开启实时 ZIP 压缩器并直接输出给 Response Writer zipWriter := zip.NewWriter(c.Writer) defer func() { _ = zipWriter.Close() }() // 用于解决 ZIP 内部文件名称发生碰撞冲突的问题 usedNames := make(map[string]int) for _, upload := range uploads { // 校验文件访问权限 if err := checkFileAccessPermission(c, &upload); err != nil { logger.WarnF(ctx, "Batch download: skip file %d due to permission denied: %v", upload.ID, err) continue } // 校验防冲突重命名逻辑 fileName := upload.FileName if count, exists := usedNames[fileName]; exists { usedNames[fileName] = count + 1 ext := filepath.Ext(fileName) base := strings.TrimSuffix(fileName, ext) fileName = fmt.Sprintf("%s_%d%s", base, count, ext) } else { usedNames[fileName] = 1 } // 在 ZIP 包内建新条目 zipFileEntry, err := zipWriter.Create(fileName) if err != nil { logger.ErrorF(ctx, "ZIP 添加条目失败 [%s]: %v", fileName, err) continue } // 打开底层文件数据源 var rc io.ReadCloser obj, err := openStoredObject(ctx, &upload) if err != nil { logger.ErrorF(ctx, "打包时读取文件失败: %v", err) continue } rc = obj.Body // 流式拷贝到 ZIP entry _, err = io.Copy(zipFileEntry, rc) _ = rc.Close() if err != nil { logger.ErrorF(ctx, "写入 ZIP 流失败: %v", err) } } } // validateUploadExtension 校验文件后缀是否在系统允许的上传扩展名列表中 func validateUploadExtension(ctx context.Context, ext string) string { var sc model.SystemConfig if err := sc.GetByKey(ctx, model.ConfigKeyUploadAllowedExtensions); err == nil && sc.Value != "" { allowedExts := strings.Split(strings.ToLower(sc.Value), ",") allowed := false for _, allowedExt := range allowedExts { if strings.TrimSpace(allowedExt) == ext { allowed = true break } } if !allowed { return ErrUnsupportedFormat } } return "" } // tryInstantUpload 尝试秒传:若数据库已存在相同 Hash 且大小一致的可用文件,直接生成新记录 func tryInstantUpload(ctx context.Context, c *gin.Context, currUser *model.User, fileHash string, size int64, mimeType, ext, origName string, accessMode int) (bool, error) { var existing model.Upload err := db.DB(ctx).Where("hash = ? AND file_size = ? AND status IN (?, ?)", fileHash, size, model.UploadStatusPending, model.UploadStatusUsed).First(&existing).Error if err != nil { return false, err } if storageReadOnly(ctx) { c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly)) return true, nil } id := idgen.NextUint64ID() newUpload := model.Upload{ ID: id, UserID: currUser.ID, FileName: origName, FilePath: existing.FilePath, FileSize: size, MimeType: mimeType, Extension: ext, Hash: fileHash, StorageDriver: existing.StorageDriver, Type: c.DefaultPostForm("type", "generic"), Status: model.UploadStatusUsed, AccessMode: accessMode, Metadata: existing.Metadata, } if err := db.DB(ctx).Create(&newUpload).Error; err != nil { c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed)) return true, err } logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath) c.JSON(http.StatusOK, util.OK(newUpload)) return true, nil } // storeUploadFile 将文件写入当前活动存储驱动。 func storeUploadFile(ctx context.Context, subPath string, size int64, mimeType string, buf *bytes.Buffer, meta *model.UploadMetadata) (string, string, string) { if storageReadOnly(ctx) { return "", "", ErrStorageReadOnly } driver, backend, err := storage.Active(ctx) if err != nil { logger.ErrorF(ctx, "初始化活动存储失败: %v", err) return "", "", ErrSaveFileFailed } result, err := backend.Put(ctx, subPath, bytes.NewReader(buf.Bytes()), size, mimeType) if err != nil { logger.ErrorF(ctx, "写入 %s 存储失败: %v", driver, err) return "", "", ErrSaveFileFailed } meta.Bucket = result.Bucket return string(driver), result.Key, "" } // isImageExtension 判断文件扩展名是否属于常见图片格式 func isImageExtension(ext string) bool { for _, imgExt := range []string{"jpg", "jpeg", "png", "webp", "gif"} { if ext == imgExt { return true } } return false } // parseUploadMetadata 解析上传元数据字段 func parseUploadMetadata(c *gin.Context, mimeType string) (model.UploadMetadata, string) { var meta model.UploadMetadata metadataStr := c.DefaultPostForm("metadata", "") if metadataStr != "" { if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil { return meta, ErrInvalidMetadataJSON } } meta.OriginalMime = mimeType meta.UserAgent = c.Request.UserAgent() meta.ClientIP = c.ClientIP() return meta, "" } // detectMimeType 检测文件的 MIME 类型,优先使用 Content-Type 头部信息 func detectMimeType(buf *bytes.Buffer, header *multipart.FileHeader, size int64) string { mimeType := http.DetectContentType(buf.Bytes()[:min(detectContentBytes, int(size))]) if mimeType == "application/octet-stream" && header.Header.Get("Content-Type") != "" { mimeType = header.Header.Get("Content-Type") } return mimeType } // saveUploadRecord 保存上传记录到数据库,失败时清理本地垃圾文件 func saveUploadRecord(ctx context.Context, upload *model.Upload, storageDriver, filePath string) string { if err := db.DB(ctx).Create(upload).Error; err != nil { backend, backendErr := storage.ForDriver(ctx, storage.Driver(storageDriver)) if backendErr == nil { if deleteErr := backend.Delete(ctx, filePath); deleteErr != nil { logger.WarnF(ctx, "清理未写入数据库的上传对象失败: %v", deleteErr) } } return ErrSaveUploadRecordFailed } return "" }