b7f83293b8
The presigned-URL upload flow (POST /api/uploads/presign + direct B2 POST
+ upload_ids[] in entity creation) is now the only image upload path. The
legacy multipart routes and DTO fields used by older clients are removed:
Removed:
- POST /api/uploads/image/ (legacy multipart upload → URL)
- POST /api/uploads/document/ (legacy multipart upload → URL)
- POST /api/uploads/completion/ (legacy multipart upload → URL)
- Multipart branch in POST /api/task-completions/ (now JSON-only)
- CreateTaskCompletionRequest.ImageURLs DTO field
- UpdateTaskCompletionRequest.ImageURLs DTO field
- CreateDocumentRequest.ImageURLs DTO field
- Service-layer ImageURLs loops in task_service.CreateCompletion,
task_service.UpdateCompletion, document_service.CreateDocument
- Tests exercising the removed paths
- Now-unused imports (strings/time/decimal) in task_handler.go
Kept:
- DELETE /api/uploads/ (orphan-cleanup endpoint, still useful)
- POST /api/uploads/presign/ (the new path)
- POST /api/documents/:id/images/ (uses storage_service.Upload directly,
same multipart pattern but separate code path; deferred for now)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
4.2 KiB
Go
133 lines
4.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
|
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
|
"github.com/treytartt/honeydue-api/internal/i18n"
|
|
"github.com/treytartt/honeydue-api/internal/middleware"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/services"
|
|
)
|
|
|
|
// FileOwnershipChecker verifies whether a user owns a file referenced by URL.
|
|
// Implementations should check associated records (e.g., task completion images,
|
|
// document files, document images) to determine ownership.
|
|
type FileOwnershipChecker interface {
|
|
IsFileOwnedByUser(fileURL string, userID uint) (bool, error)
|
|
}
|
|
|
|
// UploadHandler handles file upload endpoints
|
|
type UploadHandler struct {
|
|
storageService *services.StorageService
|
|
uploadService *services.UploadService // optional — only set when S3 storage is configured
|
|
fileOwnershipChecker FileOwnershipChecker
|
|
}
|
|
|
|
// NewUploadHandler creates a new upload handler
|
|
func NewUploadHandler(storageService *services.StorageService, fileOwnershipChecker FileOwnershipChecker) *UploadHandler {
|
|
return &UploadHandler{
|
|
storageService: storageService,
|
|
fileOwnershipChecker: fileOwnershipChecker,
|
|
}
|
|
}
|
|
|
|
// SetUploadService wires the presigned-URL upload service. Called from the
|
|
// router only when S3 storage is configured; with local-disk storage the
|
|
// presign endpoint is unsupported and returns 503.
|
|
func (h *UploadHandler) SetUploadService(s *services.UploadService) {
|
|
h.uploadService = s
|
|
}
|
|
|
|
// DeleteFileRequest is the request body for deleting a file.
|
|
type DeleteFileRequest struct {
|
|
URL string `json:"url" validate:"required"`
|
|
}
|
|
|
|
// DeleteFile handles DELETE /api/uploads
|
|
// Expects JSON body with "url" field.
|
|
// Verifies that the requesting user owns the file by checking associated records
|
|
// (task completion images, document files/images) before allowing deletion.
|
|
func (h *UploadHandler) DeleteFile(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var req DeleteFileRequest
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return apperrors.BadRequest("error.invalid_request")
|
|
}
|
|
|
|
if err := c.Validate(&req); err != nil {
|
|
return apperrors.BadRequest("error.url_required")
|
|
}
|
|
|
|
// Verify ownership: the user must own a record that references this file URL
|
|
if h.fileOwnershipChecker != nil {
|
|
owned, err := h.fileOwnershipChecker.IsFileOwnedByUser(req.URL, user.ID)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("user_id", user.ID).Str("file_url", req.URL).Msg("Failed to check file ownership")
|
|
return apperrors.Internal(err)
|
|
}
|
|
if !owned {
|
|
log.Warn().Uint("user_id", user.ID).Str("file_url", req.URL).Msg("Unauthorized file deletion attempt")
|
|
return apperrors.Forbidden("error.file_access_denied")
|
|
}
|
|
}
|
|
|
|
// Log the deletion with user ID for audit trail
|
|
log.Info().
|
|
Uint("user_id", user.ID).
|
|
Str("file_url", req.URL).
|
|
Msg("File deletion requested")
|
|
|
|
if err := h.storageService.Delete(req.URL); err != nil {
|
|
return err
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.file_deleted")})
|
|
}
|
|
|
|
// PresignUpload handles POST /api/uploads/presign.
|
|
//
|
|
// Returns a short-lived signed POST policy that the client uses to upload an
|
|
// image or document directly to B2, bypassing the API entirely for the byte
|
|
// transfer. The returned `id` is later passed in `upload_ids[]` on the
|
|
// task-completion or document creation endpoints to attach the object.
|
|
func (h *UploadHandler) PresignUpload(c echo.Context) error {
|
|
if h.uploadService == nil {
|
|
return apperrors.Internal(nil)
|
|
}
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var req requests.PresignUploadRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return apperrors.BadRequest("error.invalid_request")
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := h.uploadService.Presign(
|
|
c.Request().Context(),
|
|
user.ID,
|
|
models.UploadCategory(req.Category),
|
|
req.ContentType,
|
|
req.ContentLength,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusCreated, resp)
|
|
}
|