package handler import ( "errors" "fmt" "io" "log/slog" "mime" "mime/multipart" "net/http" "net/url" "strconv" "time" "github.com/gin-gonic/gin" "github.com/dhao2001/mygo/internal/api" "github.com/dhao2001/mygo/internal/middleware" "github.com/dhao2001/mygo/internal/model" "github.com/dhao2001/mygo/internal/service" ) const ( jsonUploadBodyLimit = 64 << 10 multipartUploadOverhead = 1 << 20 multipartFileField = "file" ) var ( errMissingFileField = errors.New("missing file field") errMissingFileName = errors.New("missing file name") errUnexpectedMultipartField = errors.New("unexpected multipart field") errInvalidMultipartForm = errors.New("invalid multipart form") ) // FileHandler handles file management endpoints. type FileHandler struct { fileService *service.FileService } // NewFileHandler creates a FileHandler. func NewFileHandler(fileService *service.FileService) *FileHandler { return &FileHandler{fileService: fileService} } type createDirRequest struct { Name string `json:"name" binding:"required"` ParentID *string `json:"parent_id"` } type updateFileRequest struct { Name string `json:"name"` ParentID *string `json:"parent_id"` } type fileInfoResponse struct { ID string `json:"id"` UserID string `json:"user_id"` ParentID *string `json:"parent_id"` Name string `json:"name"` Size int64 `json:"size"` MimeType string `json:"mime_type"` IsDir bool `json:"is_dir"` Hash string `json:"hash,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type fileListResponse struct { Files []fileInfoResponse `json:"files"` Total int64 `json:"total"` } // Upload handles POST /api/v1/files. // If the content type is multipart/form-data, it uploads a file. // If the content type is application/json, it creates a directory. func (h *FileHandler) Upload(c *gin.Context) { userID := middleware.MustGetUserID(c) contentType := c.GetHeader("Content-Type") // Directory creation via JSON. mediaType, _, _ := mime.ParseMediaType(contentType) if mediaType == "application/json" { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, jsonUploadBodyLimit) var req createDirRequest if err := c.ShouldBindJSON(&req); err != nil { slog.DebugContext(c.Request.Context(), "create dir bind failed", "error", err) if isRequestBodyTooLarge(err) { api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large") return } api.Error(c, http.StatusBadRequest, "invalid request body") return } dir, err := h.fileService.CreateDir(c.Request.Context(), userID, req.ParentID, req.Name) if err != nil { api.RespondError(c, err) return } c.JSON(http.StatusCreated, toFileInfoResponse(dir)) return } // File upload via multipart. if mediaType != "multipart/form-data" { api.Error(c, http.StatusBadRequest, "unsupported content type") return } if maxUploadSize := h.fileService.MaxUploadSize(); maxUploadSize > 0 { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+multipartUploadOverhead) } var parentID *string parentIDStr := c.Query("parent_id") if parentIDStr != "" { parentID = &parentIDStr } reader, err := c.Request.MultipartReader() if err != nil { slog.DebugContext(c.Request.Context(), "create multipart reader failed", "error", err) if isRequestBodyTooLarge(err) { api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large") return } api.Error(c, http.StatusBadRequest, "invalid multipart form") return } part, fileName, err := nextUploadFilePart(reader) if err != nil { slog.DebugContext(c.Request.Context(), "read multipart file part failed", "error", err) if isRequestBodyTooLarge(err) { api.Error(c, http.StatusRequestEntityTooLarge, "request body is too large") return } api.Error(c, http.StatusBadRequest, uploadPartErrorMessage(err)) return } defer part.Close() info, err := h.fileService.Upload(c.Request.Context(), userID, parentID, fileName, uploadBodyReader{reader: part}) if err != nil { api.RespondError(c, err) return } c.JSON(http.StatusCreated, toFileInfoResponse(info)) } func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) { part, err := reader.NextPart() if err != nil { if err == io.EOF { return nil, "", errMissingFileField } return nil, "", fmt.Errorf("%w: %v", errInvalidMultipartForm, err) } if part.FormName() != multipartFileField { part.Close() return nil, "", fmt.Errorf("%w: %q", errUnexpectedMultipartField, part.FormName()) } fileName := part.FileName() if fileName == "" { part.Close() return nil, "", errMissingFileName } return part, fileName, nil } func uploadPartErrorMessage(err error) string { switch { case errors.Is(err, errMissingFileField): return "missing file field" case errors.Is(err, errMissingFileName): return "missing file name" case errors.Is(err, errUnexpectedMultipartField): return "unexpected multipart field" default: return "invalid multipart form" } } func isRequestBodyTooLarge(err error) bool { var maxBytesErr *http.MaxBytesError return errors.As(err, &maxBytesErr) } type uploadBodyReader struct { reader io.Reader } func (r uploadBodyReader) Read(p []byte) (int, error) { n, err := r.reader.Read(p) if isRequestBodyTooLarge(err) { return n, model.ErrUploadTooLarge } return n, err } // List handles GET /api/v1/files. func (h *FileHandler) List(c *gin.Context) { userID := middleware.MustGetUserID(c) parentIDStr := c.Query("parent_id") var parentID *string if parentIDStr != "" { parentID = &parentIDStr } offset, err := strconv.Atoi(c.DefaultQuery("offset", "0")) if err != nil || offset < 0 { api.Error(c, http.StatusBadRequest, "invalid offset") return } limit, err := strconv.Atoi(c.DefaultQuery("limit", "50")) if err != nil || limit < 1 { api.Error(c, http.StatusBadRequest, "invalid limit") return } if limit > 200 { limit = 200 } list, err := h.fileService.List(c.Request.Context(), userID, parentID, offset, limit) if err != nil { api.RespondError(c, err) return } c.JSON(http.StatusOK, toFileListResponse(list)) } // Get handles GET /api/v1/files/:id. func (h *FileHandler) Get(c *gin.Context) { userID := middleware.MustGetUserID(c) fileID := c.Param("id") info, err := h.fileService.Get(c.Request.Context(), userID, fileID) if err != nil { api.RespondError(c, err) return } c.JSON(http.StatusOK, toFileInfoResponse(info)) } // Download handles GET /api/v1/files/:id/content. func (h *FileHandler) Download(c *gin.Context) { userID := middleware.MustGetUserID(c) fileID := c.Param("id") reader, info, err := h.fileService.Download(c.Request.Context(), userID, fileID) if err != nil { api.RespondError(c, err) return } defer reader.Close() mimeType := info.MimeType if mimeType == "" { mimeType = "application/octet-stream" } c.Header("Content-Type", mimeType) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", url.PathEscape(info.Name))) c.Header("Content-Length", strconv.FormatInt(info.Size, 10)) c.Status(http.StatusOK) written, err := io.Copy(c.Writer, reader) if err != nil { slog.WarnContext(c.Request.Context(), "download copy failed", "user_id", userID, "file_id", fileID, "written", written, "expected_size", info.Size, "error", err) return } if written != info.Size { slog.WarnContext(c.Request.Context(), "download size mismatch", "user_id", userID, "file_id", fileID, "written", written, "expected_size", info.Size) } } // Update handles PUT /api/v1/files/:id. func (h *FileHandler) Update(c *gin.Context) { userID := middleware.MustGetUserID(c) fileID := c.Param("id") var req updateFileRequest if err := c.ShouldBindJSON(&req); err != nil { slog.DebugContext(c.Request.Context(), "update file bind failed", "error", err) api.Error(c, http.StatusBadRequest, "invalid request body") return } // Validate at least one field is provided. if req.Name == "" && req.ParentID == nil { api.Error(c, http.StatusBadRequest, "at least one of name or parent_id must be provided") return } info, err := h.fileService.Update(c.Request.Context(), userID, fileID, req.Name, req.ParentID) if err != nil { api.RespondError(c, err) return } c.JSON(http.StatusOK, toFileInfoResponse(info)) } // Delete handles DELETE /api/v1/files/:id. func (h *FileHandler) Delete(c *gin.Context) { userID := middleware.MustGetUserID(c) fileID := c.Param("id") if err := h.fileService.Delete(c.Request.Context(), userID, fileID); err != nil { api.RespondError(c, err) return } c.Status(http.StatusNoContent) } func toFileInfoResponse(info *service.FileInfo) fileInfoResponse { return fileInfoResponse{ ID: info.ID, UserID: info.UserID, ParentID: info.ParentID, Name: info.Name, Size: info.Size, MimeType: info.MimeType, IsDir: info.IsDir, Hash: info.Hash, CreatedAt: info.CreatedAt, UpdatedAt: info.UpdatedAt, } } func toFileListResponse(list *service.FileList) fileListResponse { items := make([]fileInfoResponse, 0, len(list.Files)) for i := range list.Files { items = append(items, toFileInfoResponse(&list.Files[i])) } return fileListResponse{ Files: items, Total: list.Total, } }