Files
honeyDueAPI/internal/handlers/upload_handler.go
T
Trey t bc3da007db
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Wire OpenTelemetry tracing — HTTP, B2, APNs, FCM, asynq, GORM (partial)
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider
that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling
is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod,
overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api
and honeydue-worker. otelecho.Middleware opens a span per HTTP request.

Step 2 — Manual spans: storage_service.Upload now takes ctx and emits
storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket,
result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token
spans with topic, status_code, reason. Asynq middleware emits
asynq.handle:<task_type> per job with retry/payload attrs and records
asynq_job_duration_seconds.

Step 3 — Database: otelgorm plugin registered in database.Connect, so
any SQL emitted via db.WithContext(ctx) attaches to the request span.
Every repository now exposes WithContext(ctx) *XRepository as the
migration helper. TaskService.ListTasks and GetTasksByResidence are
migrated end-to-end (ctx threaded through handler → service → repo);
remaining services adopt the same pattern incrementally — pre-migration
methods still emit untraced SQL via the unchanged db field.

OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env →
honeydue-secrets → api+worker Deployments via secretKeyRef (optional).
02-setup-secrets.sh sources them from prod.env on next run; manifests
mark both env vars optional so the deployment rolls without traces if
the secret is absent.

ch15 observability doc now lists what produces spans today vs the
remaining migration work, with the explicit per-method pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:28:05 -05:00

141 lines
4.1 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/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"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
fileOwnershipChecker FileOwnershipChecker
}
// NewUploadHandler creates a new upload handler
func NewUploadHandler(storageService *services.StorageService, fileOwnershipChecker FileOwnershipChecker) *UploadHandler {
return &UploadHandler{
storageService: storageService,
fileOwnershipChecker: fileOwnershipChecker,
}
}
// UploadImage handles POST /api/uploads/image
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadImage(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return apperrors.BadRequest("error.no_file_provided")
}
// Get category from query param (default: images)
category := c.QueryParam("category")
if category == "" {
category = "images"
}
result, err := h.storageService.Upload(c.Request().Context(), file, category)
if err != nil {
return err
}
return c.JSON(http.StatusOK, result)
}
// UploadDocument handles POST /api/uploads/document
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadDocument(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(c.Request().Context(), file, "documents")
if err != nil {
return err
}
return c.JSON(http.StatusOK, result)
}
// UploadCompletion handles POST /api/uploads/completion
// For task completion photos
func (h *UploadHandler) UploadCompletion(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(c.Request().Context(), file, "completions")
if err != nil {
return err
}
return c.JSON(http.StatusOK, result)
}
// 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")})
}