package api import ( "errors" "fmt" "net/http" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "fileshare/internal/middleware" "fileshare/internal/model" "fileshare/internal/response" "fileshare/internal/storage" ) // presignSessionExpires 预签名会话有效期(对齐参考 PRESIGN_SESSION_EXPIRES=900 秒)。 const presignSessionExpires = 900 // getValidSession 校验并返回预签名会话(对齐参考 _get_valid_session): // 不存在 404、已过期删除后 404、mode 不符 400。 func (d *Deps) getValidSession(c *gin.Context, uploadID, expectedMode string) (*model.PresignUploadSession, error) { ctx := c.Request.Context() var session model.PresignUploadSession err := d.DB.WithContext(ctx). Where("upload_id = ?", uploadID).First(&session).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errNotFound("上传会话不存在") } if err != nil { return nil, errInternal("查询上传会话失败: " + err.Error()) } if session.IsExpired(time.Now()) { _ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID). Delete(&model.PresignUploadSession{}).Error releaseStorage(ctx, d.DB, "presign:"+uploadID) return nil, errNotFound("上传会话已过期") } if expectedMode != "" && session.Mode != expectedMode { return nil, errBadRequest("此会话不支持" + expectedMode + "模式") } return &session, nil } // ============ POST /presign/upload/init 初始化预签名上传 ============ // presignInitRequest init 请求体(对齐参考 PresignUploadInitRequest)。 type presignInitRequest struct { FileName string `json:"file_name" form:"file_name"` FileSize int64 `json:"file_size" form:"file_size"` ExpireValue int `json:"expire_value" form:"expire_value"` ExpireStyle string `json:"expire_style" form:"expire_style"` Code string `json:"code" form:"code"` // 26.9:自定义提取码(init 时校验,完成时落库) } // presignInit 初始化预签名上传(对齐参考 presign_upload_init): // 引擎支持直链(S3)返回 direct + 预签名 PUT URL;否则返回 proxy + 代理地址。 func (d *Deps) presignInit(c *gin.Context) { if !d.requireShareLogin(c) { return } if !requireUploadLimit(c, d.Limiter) { return } var req presignInitRequest if err := bindJSONOrForm(c, &req); err != nil { respondError(c, err) return } safeName := storage.SanitizeFileName(req.FileName) if safeName == "" { auditRecordFailed(c, d.AuditSvc, "文件名非法") response.Fail(c, http.StatusBadRequest, "文件名非法") return } // 26.9:自定义提取码提前校验(init 时快速失败;完成请求须再次携带) if err := validatePickupCode(req.Code); err != nil { auditRecordFailed(c, d.AuditSvc, "提取码非法") respondError(c, err) return } if err := validateFileMagic(d.Cfg, safeName, "", nil); err != nil { auditUploadEntry(c, "", safeName, req.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件类型被拒绝") respondError(c, err) return } // 26.9 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize) if err := d.CurrentUploadPolicy().CheckSize(req.FileSize); err != nil { auditUploadEntry(c, "", safeName, req.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件大小超过限制") respondError(c, err) return } // M2:直传 confirm 的实际大小校验依赖真实对象,0/负值声明直接拒绝 if req.FileSize <= 0 { auditRecordFailed(c, d.AuditSvc, "file_size 非法") response.Fail(c, http.StatusBadRequest, "file_size 必须大于 0") return } if req.ExpireValue <= 0 { req.ExpireValue = 1 } if req.ExpireStyle == "" { req.ExpireStyle = "day" } if _, err := resolveExpire(d.Cfg, req.ExpireValue, req.ExpireStyle); err != nil { auditUploadEntry(c, "", safeName, req.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "过期策略非法") respondError(c, err) return } ctx := c.Request.Context() uploadID := uuidHex() resToken := "presign:" + uploadID if err := reserveStorage(ctx, d.DB, d.Cfg, resToken, req.FileSize, presignSessionExpires*time.Second); err != nil { auditUploadEntry(c, "", safeName, req.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "容量预留失败") respondError(c, err) return } dirPath, _, _, _, savePath := buildSavePath(d.Cfg, safeName, uploadID) mode := "proxy" uploadURL := "/presign/upload/proxy/" + uploadID putURL, err := d.Store.PresignPutURL(ctx, savePath, presignSessionExpires) switch { case err == nil: mode = "direct" uploadURL = putURL case errors.Is(err, storage.ErrNotSupported): // 引擎不支持直传:代理模式 default: releaseStorage(ctx, d.DB, resToken) auditUploadEntry(c, "", safeName, req.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "生成预签名失败") respondError(c, mapStorageError(err)) return } session := model.PresignUploadSession{ UploadID: uploadID, FileName: safeName, FileSize: req.FileSize, SavePath: savePath, Mode: mode, Engine: d.Store.CurrentName(), // 26.9:会话归属引擎 ExpireValue: req.ExpireValue, ExpireStyle: req.ExpireStyle, ExpiresAt: time.Now().Add(presignSessionExpires * time.Second), } if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil { releaseStorage(ctx, d.DB, resToken) auditUploadEntry(c, "", safeName, req.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "会话创建失败") respondError(c, errInternal("创建上传会话失败: "+err.Error())) return } d.Limiter.Add(c, middleware.LimitUpload) auditUploadEntry(c, uploadID, safeName, req.FileSize, 0) auditRecordSuccess(c, d.AuditSvc) detail := gin.H{ "upload_id": uploadID, "upload_url": uploadURL, "mode": mode, "expires_in": presignSessionExpires, "file_path": dirPath, } if mode == "proxy" { detail["proxy_upload_url"] = uploadURL detail["legacy_proxy_upload_url"] = "/api" + uploadURL } response.OK(c, detail) } // ============ PUT /presign/upload/proxy/{uploadID} 代理上传 ============ // presignProxy 代理模式上传(对齐参考 presign_upload_proxy): // 服务器接收文件并转存到存储引擎,随后立即创建分享记录。 func (d *Deps) presignProxy(c *gin.Context) { if !d.requireShareLogin(c) { return } if !requireUploadLimit(c, d.Limiter) { return } uploadID := c.Param("uploadID") session, err := d.getValidSession(c, uploadID, "proxy") if err != nil { auditUploadEntry(c, uploadID, "", 0, 0) auditRecordFailed(c, d.AuditSvc, err.Error()) respondError(c, err) return } // 26.9:自定义提取码随代理上传表单携带(init 时已预校验) if err := validatePickupCode(c.PostForm("code")); err != nil { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "提取码非法") respondError(c, err) return } ctx := c.Request.Context() if err := reserveStorage(ctx, d.DB, d.Cfg, "presign:"+uploadID, session.FileSize, presignSessionExpires*time.Second); err != nil { respondError(c, err) return } fh, err := c.FormFile("file") if err != nil { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "缺少 file 字段") response.Fail(c, http.StatusBadRequest, "缺少上传文件 file 字段") return } // 动态策略快照(与 share/chunk 上传路径一致,消除会话窗口内的策略滞后) maxSize := d.CurrentUploadPolicy().MaxFileSize if maxSize > 0 && fh.Size > maxSize { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "大小超过限制") response.Fail(c, http.StatusForbidden, fmt.Sprintf("大小超过限制,最大为%s", humanSize(maxSize))) return } // 文件大小与声明不符(±1KB 容差,对齐参考) if abs64(fh.Size-session.FileSize) > 1024 { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件大小与声明不符") response.Fail(c, http.StatusBadRequest, "文件大小与声明不符") return } f, err := fh.Open() if err == nil { defer func() { _ = f.Close() }() if err = validateFileMagic(d.Cfg, session.FileName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err == nil { // 26.9:落盘走会话归属引擎 var ps storage.Storage ps, sErr := d.storeFor(session.Engine) if sErr != nil { err = sErr } else if _, err = ps.SaveFile(ctx, f, session.SavePath); err != nil { // 落盘失败,err 交给统一错误处理 } } } if err != nil { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件保存失败") if isStorageErr(err) { respondError(c, mapStorageError(err)) } else { respondError(c, errInternal("文件保存失败: "+err.Error())) } return } code, err := d.createRecordFromSession(c, session, c.PostForm("code")) releaseStorage(ctx, d.DB, "presign:"+uploadID) if err != nil { if ps, sErr := d.storeFor(session.Engine); sErr == nil { _ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎 } auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "创建分享失败") respondError(c, err) return } _ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID). Delete(&model.PresignUploadSession{}).Error d.Limiter.Add(c, middleware.LimitUpload) auditUploadEntry(c, code, session.FileName, session.FileSize, fh.Size) auditRecordSuccess(c, d.AuditSvc) response.OK(c, gin.H{"code": code, "name": session.FileName}) } // ============ POST /presign/upload/confirm/{uploadID} 直传确认 ============ // presignConfirm 直传确认(对齐参考 presign_upload_confirm): // 客户端完成 S3 直传后调用,校验文件已存在并创建分享记录。 func (d *Deps) presignConfirm(c *gin.Context) { if !d.requireShareLogin(c) { return } if !requireUploadLimit(c, d.Limiter) { return } uploadID := c.Param("uploadID") session, err := d.getValidSession(c, uploadID, "direct") if err != nil { auditUploadEntry(c, uploadID, "", 0, 0) auditRecordFailed(c, d.AuditSvc, err.Error()) respondError(c, err) return } ctx := c.Request.Context() if err := reserveStorage(ctx, d.DB, d.Cfg, "presign:"+uploadID, session.FileSize, presignSessionExpires*time.Second); err != nil { respondError(c, err) return } // 26.9:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎) psCheck, sErr := d.storeFor(session.Engine) if sErr != nil { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "存储引擎不可用: "+sErr.Error()) respondError(c, mapStorageError(sErr)) return } // 26.9:自定义提取码随确认请求携带(query 或 JSON/form body,均可选) customCode := c.Query("code") if customCode == "" && c.Request.Body != nil && c.Request.ContentLength != 0 { var fin struct { Code string `json:"code" form:"code"` } if err := bindJSONOrForm(c, &fin); err != nil { respondError(c, err) return } customCode = fin.Code } exists, err := psCheck.FileExists(ctx, session.SavePath) if err == nil && !exists { err = errNotFound("文件未上传或上传失败") } if err != nil { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件未上传或上传失败") respondError(c, err) return } // M2 修复:直传内容不经过服务器,confirm 必须核实实际大小与内容类型。 meta, head, hErr := psCheck.HeadMeta(ctx, session.SavePath, 64) if hErr != nil { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件信息读取失败") respondError(c, mapStorageError(hErr)) return } if meta == nil { auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件未上传或上传失败") respondError(c, errNotFound("文件未上传或上传失败")) return } // 大小上限:实际大小超过策略上限 → 删除对象并 403(防绕过 max_file_size / storageLimit) maxSize := d.CurrentUploadPolicy().MaxFileSize if maxSize > 0 && meta.Size > maxSize { _ = psCheck.DeleteFile(ctx, session.SavePath) releaseStorage(ctx, d.DB, "presign:"+uploadID) auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "实际文件大小超过限制") respondError(c, errForbidden(fmt.Sprintf("大小超过限制,最大为%s", humanSize(maxSize)))) return } // 大小与声明不符(±1KB 容差,对齐 proxy 模式):超差删除对象并 400 if abs64(meta.Size-session.FileSize) > 1024 { _ = psCheck.DeleteFile(ctx, session.SavePath) releaseStorage(ctx, d.DB, "presign:"+uploadID) auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件大小与声明不符") respondError(c, errBadRequest("文件大小与声明不符")) return } // 内容类型防伪造(对齐 proxy 模式 magic bytes 校验) if err := validateFileMagic(d.Cfg, session.FileName, "", head); err != nil { _ = psCheck.DeleteFile(ctx, session.SavePath) releaseStorage(ctx, d.DB, "presign:"+uploadID) auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "文件内容校验失败") respondError(c, err) return } code, err := d.createRecordFromSession(c, session, customCode) releaseStorage(ctx, d.DB, "presign:"+uploadID) if err != nil { if ps, sErr := d.storeFor(session.Engine); sErr == nil { _ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎 } auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0) auditRecordFailed(c, d.AuditSvc, "创建分享失败") respondError(c, err) return } _ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID). Delete(&model.PresignUploadSession{}).Error d.Limiter.Add(c, middleware.LimitUpload) auditUploadEntry(c, code, session.FileName, session.FileSize, session.FileSize) auditRecordSuccess(c, d.AuditSvc) response.OK(c, gin.H{"code": code, "name": session.FileName}) } // createRecordFromSession 依据预签名会话创建分享记录(对齐参考 create_file_record)。 func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUploadSession, customCode string) (string, error) { exp, err := resolveExpire(d.Cfg, session.ExpireValue, session.ExpireStyle) if err != nil { return "", err } // 26.9:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过) if err := validatePickupCode(customCode); err != nil { return "", err } ctx := c.Request.Context() code, err := pickCustomCode(ctx, d.DB, d.Cfg, customCode) if err != nil { return "", err } dir, name := splitDirBase(session.SavePath) ext := baseExt(name) fc := model.FileCodes{ Code: code, Prefix: trimExt(name), Suffix: ext, UUIDFileName: &name, FilePath: &dir, Size: session.FileSize, ExpiredAt: exp.ExpiredAt, ExpiredCount: exp.ExpiredCount, UsedCount: exp.UsedCount, Engine: session.Engine, // 26.9:归属引擎戳 } if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil { return "", mapCodeConflict(err) // 26.9:并发占用自定义码 → 友好 400 } return code, nil } // ============ GET /presign/upload/status/{uploadID} ============ // presignStatus 查询预签名会话状态(对齐参考 presign_upload_status)。 func (d *Deps) presignStatus(c *gin.Context) { if !d.requireShareLogin(c) { return } uploadID := c.Param("uploadID") ctx := c.Request.Context() var session model.PresignUploadSession if err := d.DB.WithContext(ctx). Where("upload_id = ?", uploadID).First(&session).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { response.Fail(c, http.StatusNotFound, "上传会话不存在") return } respondError(c, errInternal("查询上传会话失败: "+err.Error())) return } response.OK(c, gin.H{ "upload_id": session.UploadID, "file_name": session.FileName, "file_size": session.FileSize, "mode": session.Mode, "created_at": session.CreatedAt.Format(time.RFC3339), "expires_at": session.ExpiresAt.Format(time.RFC3339), "is_expired": session.IsExpired(time.Now()), }) } // ============ DELETE /presign/upload/{uploadID} 取消会话 ============ // presignCancel 取消预签名上传会话(对齐参考 presign_upload_cancel): // 直传模式尽力清理已直传的文件。 func (d *Deps) presignCancel(c *gin.Context) { if !d.requireShareLogin(c) { return } uploadID := c.Param("uploadID") ctx := c.Request.Context() session, err := d.getValidSession(c, uploadID, "") if err != nil { respondError(c, err) return } if session.Mode == "direct" { // 26.9:清理走会话归属引擎 if ps, sErr := d.storeFor(session.Engine); sErr == nil { if exists, eErr := ps.FileExists(ctx, session.SavePath); eErr == nil && exists { _ = ps.DeleteFile(ctx, session.SavePath) } } } if err := d.DB.WithContext(ctx).Where("upload_id = ?", uploadID). Delete(&model.PresignUploadSession{}).Error; err != nil { respondError(c, errInternal("取消上传会话失败: "+err.Error())) return } releaseStorage(ctx, d.DB, "presign:"+uploadID) response.OK(c, gin.H{"message": "上传会话已取消"}) } // ============ 杂项 ============ // abs64 绝对值。 func abs64(n int64) int64 { if n < 0 { return -n } return n } // isStorageErr 判断是否为存储层哨兵错误(含 %w 包装)。 func isStorageErr(err error) bool { return err != nil && (errors.Is(err, storage.ErrNotFound) || errors.Is(err, storage.ErrInvalidPath) || errors.Is(err, storage.ErrUnavailable) || errors.Is(err, storage.ErrNotSupported) || errors.Is(err, storage.ErrRangeNotSatisfiable) || errors.Is(err, storage.ErrHashMismatch)) }