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) } }