From d922b2caa92958b4e4fdfbd1339364067cfc364f Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Tue, 23 Jun 2026 02:47:39 +0300 Subject: [PATCH] feat(storage): integrate Cloudflare R2 for object storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing R2 infrastructure layer end-to-end across all three app layers. The service is optional — enabled only when R2_ACCOUNT_ID is set in the environment. Backend: - Add StorageService interface to usecase/ (Clean Architecture placement) - Update r2.New() to return usecase.StorageService; add NewWithHTTPClient constructor for test injection via custom RoundTripper - Bootstrap: add R2 config fields, App.StorageService, and init block - POST /api/v1/storage/presign — returns presigned PUT URL + public URL - DELETE /api/v1/storage/:key — deletes an object; both routes require Firebase auth and are gated on h.storageService != nil - Regenerate Swagger docs; add R2 vars to .env.example Web: - lib/storage.ts: presign() utility and uploadToR2() direct-to-R2 PUT - lib/useUpload.ts: useUpload() hook (isUploading, publicUrl, error, upload) Mobile: - storage/UploadRepository.kt: UploadRepository interface + R2UploadRepository (injectable OkHttpClient, coroutine-safe, Result return); add okhttp-mockwebserver test dependency Docs: - Create backend/docs/storage.md, web/docs/storage.md, mobile/docs/storage.md - Update environment.md, bootstrap.md, and three stale docs (error-handling.md, routing.md, websocket.md) Closes #22 Co-Authored-By: Claude Sonnet 4.6 --- backend/.env.example | 12 +- backend/docs/_index.md | 1 + backend/docs/bootstrap.md | 31 ++-- backend/docs/environment.md | 5 + backend/docs/error-handling.md | 4 +- backend/docs/routing.md | 2 +- backend/docs/storage.md | 137 +++++++++++++++ backend/docs/swagger/docs.go | 119 +++++++++++++ backend/docs/swagger/swagger.json | 119 +++++++++++++ backend/docs/swagger/swagger.yaml | 76 ++++++++ backend/docs/websocket.md | 22 +-- backend/internal/bootstrap/bootstrap.go | 55 ++++-- .../infrastructure/storage/r2/storage.go | 30 ++-- .../infrastructure/storage/r2/storage_test.go | 133 ++++++++++++++ backend/internal/server/server.go | 2 +- .../internal/transport/handlers/handler.go | 35 ++-- .../transport/handlers/health_handler_test.go | 4 +- backend/internal/transport/handlers/routes.go | 5 + .../transport/handlers/storage_handler.go | 68 ++++++++ .../handlers/storage_handler_test.go | 165 ++++++++++++++++++ backend/internal/usecase/storage.go | 13 ++ mobile/app/build.gradle.kts | 1 + .../template/storage/UploadRepository.kt | 75 ++++++++ .../template/storage/UploadRepositoryTest.kt | 102 +++++++++++ mobile/docs/_index.md | 1 + mobile/docs/storage.md | 75 ++++++++ mobile/gradle/libs.versions.toml | 1 + web/docs/_index.md | 1 + web/docs/storage.md | 79 +++++++++ web/lib/storage.test.ts | 102 +++++++++++ web/lib/storage.ts | 45 +++++ web/lib/useUpload.test.ts | 131 ++++++++++++++ web/lib/useUpload.ts | 39 +++++ 33 files changed, 1612 insertions(+), 78 deletions(-) create mode 100644 backend/docs/storage.md create mode 100644 backend/internal/infrastructure/storage/r2/storage_test.go create mode 100644 backend/internal/transport/handlers/storage_handler.go create mode 100644 backend/internal/transport/handlers/storage_handler_test.go create mode 100644 backend/internal/usecase/storage.go create mode 100644 mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt create mode 100644 mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt create mode 100644 mobile/docs/storage.md create mode 100644 web/docs/storage.md create mode 100644 web/lib/storage.test.ts create mode 100644 web/lib/storage.ts create mode 100644 web/lib/useUpload.test.ts create mode 100644 web/lib/useUpload.ts diff --git a/backend/.env.example b/backend/.env.example index 970ec6e..d8c9b96 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -29,4 +29,14 @@ MAILJET_API_KEY=your_mailjet_api_key_here MAILJET_SECRET_KEY=your_mailjet_secret_key_here # Sender identity — must be a verified Mailjet sender address FROM_EMAIL=no-reply@example.com -FROM_NAME=MyApp \ No newline at end of file +FROM_NAME=MyApp +# Cloudflare R2 object storage (optional; omit or leave empty to disable file storage) +# Account ID from: Cloudflare Dashboard → R2 → Overview +R2_ACCOUNT_ID=your_r2_account_id_here +# API token credentials (create from: Cloudflare Dashboard → R2 → Manage API Tokens) +R2_ACCESS_KEY=your_r2_access_key_here +R2_SECRET_KEY=your_r2_secret_key_here +# The name of your R2 bucket +R2_BUCKET=your_bucket_name_here +# Public URL for the bucket (set up a custom domain or use r2.dev subdomain) +R2_PUBLIC_URL=https://pub-xxxxxxxxxxxx.r2.dev \ No newline at end of file diff --git a/backend/docs/_index.md b/backend/docs/_index.md index 73857a8..276fe0f 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -20,3 +20,4 @@ The `docs` agent reads this index first to locate the right file before diving i | Redis Streams event fan-out (producer, consumer, consumer groups) | [streams.md](streams.md) | `internal/infrastructure/streams/events.go`, `internal/infrastructure/streams/producer.go`, `internal/infrastructure/streams/consumer.go` | | Firebase Cloud Messaging — token storage, send API, FCM endpoints | [fcm.md](fcm.md) | `internal/domain/fcm_token.go`, `internal/usecase/notification.go`, `internal/infrastructure/database/postgres/fcm_token_repository.go`, `internal/transport/handlers/fcm_handler.go`, `pkg/firebase/app.go`, `pkg/firebase/messaging.go` | | Transactional email (Mailjet) — EmailSender interface, MailjetSender, sandbox mode, templates | [email.md](email.md) | `internal/usecase/email.go`, `internal/infrastructure/email/mailjet.go`, `internal/infrastructure/email/templates/welcome.html`, `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `internal/transport/handlers/handler.go` | +| Object storage (Cloudflare R2) — StorageService interface, R2 implementation, presign/delete endpoints | [storage.md](storage.md) | `internal/usecase/storage.go`, `internal/infrastructure/storage/r2/storage.go`, `internal/transport/handlers/storage_handler.go`, `internal/transport/handlers/routes.go`, `internal/bootstrap/bootstrap.go` | diff --git a/backend/docs/bootstrap.md b/backend/docs/bootstrap.md index 003be60..83972cd 100644 --- a/backend/docs/bootstrap.md +++ b/backend/docs/bootstrap.md @@ -15,17 +15,18 @@ sources: ## App struct ```go type App struct { - DB *sql.DB - Cache usecase.CacheService // nil when REDIS_URL is not set - Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set - Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set - FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set - EmailSender usecase.EmailSender // nil when MAILJET_API_KEY/SECRET_KEY are not set - Config Config - Log *slog.Logger + DB *sql.DB + Cache usecase.CacheService // nil when REDIS_URL is not set + Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set + FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set + EmailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set + StorageService usecase.StorageService // nil when R2_ACCOUNT_ID is not set + Config Config + Log *slog.Logger } ``` -`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Enqueuer`, `Firebase`, `FCMSender`, `EmailSender`) are nil when their corresponding env vars are absent. +`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Enqueuer`, `Firebase`, `FCMSender`, `EmailSender`, `StorageService`) are nil when their corresponding env vars are absent. ## Config struct ```go @@ -39,6 +40,15 @@ type Config struct { FirebaseProjectID string FirebaseServiceAccountJSON string SentryDSN string + MailjetAPIKey string + MailjetSecretKey string + FromEmail string + FromName string + R2AccountID string + R2AccessKey string + R2SecretKey string + R2Bucket string + R2PublicURL string } ``` `loadConfig()` reads all values from environment variables. `PORT` defaults to `8080`; `BLUEPRINT_DB_SCHEMA` defaults to `public`; `BLUEPRINT_DB_SSLMODE` defaults to `disable`. `RateLimitBurst` is derived as `int(RPS)*5` when omitted and RPS is set. Optional fields (`RedisURL`, `FirebaseProjectID`, `FirebaseServiceAccountJSON`) default to empty string — their respective services are skipped when empty. @@ -54,7 +64,8 @@ type Config struct { 6. Init Asynq enqueuer via `queue.NewClient(cfg.RedisURL)` — skipped when `REDIS_URL` is empty 7. Init Firebase app via `firebase.NewApp(ctx, ...)`, then init Auth client (`firebase.NewAuthClient`) and FCM messaging client (`firebase.NewMessagingClient`) from the same app instance — all skipped when `FIREBASE_PROJECT_ID` is empty 8. Init Mailjet email sender via `email.NewMailjetSender(...)` — skipped when `MAILJET_API_KEY` or `MAILJET_SECRET_KEY` is empty; startup fails if only a partial Mailjet config is provided -9. Return `*App` on success; return a non-nil error on any failure +9. Init R2 storage client via `r2.New(...)` — skipped when `R2_ACCOUNT_ID` is empty; startup fails if `R2_ACCOUNT_ID` is set but any other R2 var is missing +10. Return `*App` on success; return a non-nil error on any failure ```go ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/backend/docs/environment.md b/backend/docs/environment.md index ab87c1b..11dc4f2 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -41,6 +41,11 @@ This runs on package init before any env var is read — no explicit `godotenv.L | `MAILJET_SECRET_KEY` | `bootstrap.go` | — | Mailjet secret key. Must be provided alongside `MAILJET_API_KEY`. | | `FROM_EMAIL` | `bootstrap.go` | — | Verified Mailjet sender address (e.g. `no-reply@example.com`). Required when `MAILJET_API_KEY` and `MAILJET_SECRET_KEY` are set; startup fails if omitted. | | `FROM_NAME` | `bootstrap.go` | — | Sender display name (e.g. `MyApp`). Only read when both Mailjet credentials are set. | +| `R2_ACCOUNT_ID` | `bootstrap.go` | — | Cloudflare account ID. When omitted, `App.StorageService` is `nil` and storage routes are not registered. | +| `R2_ACCESS_KEY` | `bootstrap.go` | — | R2 API token access key. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. | +| `R2_SECRET_KEY` | `bootstrap.go` | — | R2 API token secret key. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. | +| `R2_BUCKET` | `bootstrap.go` | — | R2 bucket name. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. | +| `R2_PUBLIC_URL` | `bootstrap.go` | — | Public base URL for the R2 bucket (custom domain or `r2.dev` subdomain). Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. | Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing. diff --git a/backend/docs/error-handling.md b/backend/docs/error-handling.md index 56ea813..4cd6004 100644 --- a/backend/docs/error-handling.md +++ b/backend/docs/error-handling.md @@ -1,6 +1,6 @@ --- topic: error-handling -last_verified: 2026-06-15 +last_verified: 2026-06-23 sources: - internal/infrastructure/database/postgres/health_repository.go - internal/transport/handlers/health_handler.go @@ -19,7 +19,7 @@ Never use `log.Fatal` or `os.Exit` inside `internal/`. | `cmd/api/main.go: main()` | `fmt.Fprintf(os.Stderr, ...) + os.Exit(1)` | `bootstrap.Run()` returned an error — process cannot start | This is the only permitted early-exit path and it lives in `cmd/`, not `internal/`. -`server.NewServer` does not return an error — all fallible startup work is done by `bootstrap.Run`. +`server.NewServer` returns `(*http.Server, error)` — the caller in `cmd/api/main.go` checks the error and exits on failure. Fallible startup work is split between `bootstrap.Run` and `server.NewServer` (e.g. registering Prometheus collectors). ## Repository errors Repository methods return `(Result, error)`. On failure, wrap with context using `fmt.Errorf`: diff --git a/backend/docs/routing.md b/backend/docs/routing.md index 4742090..37df7ed 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -59,7 +59,7 @@ if app.Config.RedisURL != "" { // parse URL and build asynqmon.New(...) } -h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo) +h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo, app.EmailSender) // Register DB pool metrics collector (AlreadyRegisteredError is silenced). prometheus.Register(postgres.NewDBStatsCollector(app.DB)) diff --git a/backend/docs/storage.md b/backend/docs/storage.md new file mode 100644 index 0000000..7215352 --- /dev/null +++ b/backend/docs/storage.md @@ -0,0 +1,137 @@ +--- +topic: storage +last_verified: 2026-06-23 +sources: + - backend/internal/usecase/storage.go + - backend/internal/infrastructure/storage/r2/storage.go + - backend/internal/transport/handlers/storage_handler.go + - backend/internal/transport/handlers/routes.go + - backend/internal/bootstrap/bootstrap.go +--- + +# Storage (Cloudflare R2) + +## Overview +R2 is the optional object storage backend. The integration is enabled only when `R2_ACCOUNT_ID` is set in the environment. When absent, `App.StorageService` is `nil` and the storage routes are not registered. + +## Interface +`usecase.StorageService` is defined in `internal/usecase/storage.go` and sits at the use-case layer to keep the transport and infrastructure layers decoupled from any concrete SDK: + +```go +type StorageService interface { + PresignUpload(ctx context.Context, key string, contentType string, ttl time.Duration) (string, error) + Delete(ctx context.Context, key string) error + PublicURL(key string) string +} +``` + +## R2 implementation +Package `internal/infrastructure/storage/r2` provides the concrete implementation using the AWS SDK v2 (R2 is S3-compatible). + +### Constructors + +```go +// Production — uses default AWS SDK HTTP client. +func New(accountID, accessKey, secretKey, bucket, publicBaseURL string) (usecase.StorageService, error) + +// Test-friendly — accepts a custom *http.Client to inject a mock transport. +// Pass nil to behave identically to New. +func NewWithHTTPClient(accountID, accessKey, secretKey, bucket, publicBaseURL string, httpClient *http.Client) (usecase.StorageService, error) +``` + +Both constructors return an error if any argument is empty. The R2 endpoint is derived as: +``` +https://.r2.cloudflarestorage.com +``` +The S3 client is configured with `UsePathStyle: true` and region `"auto"`. + +### Method behaviour +- `PresignUpload` calls `s3.PresignClient.PresignPutObject` and returns the signed URL. TTL is caller-controlled; the handler passes `15 * time.Minute`. +- `Delete` calls `s3.Client.DeleteObject` directly (no presigning). +- `PublicURL` returns `publicBaseURL + "/" + url.PathEscape(key)`. + +## HTTP endpoints + +Both routes live under the `/api/v1` group, which applies `FirebaseAuth` middleware when `h.verifier != nil`. They are only registered when `h.storageService != nil`. + +```go +if h.storageService != nil { + api.POST("/storage/presign", h.PresignHandler) + api.DELETE("/storage/:key", h.DeleteObjectHandler) +} +``` + +### POST /api/v1/storage/presign +Returns a presigned PUT URL for the client to upload directly to R2, plus the resulting public URL. + +Request body (`presignRequest`): +```json +{ "filename": "avatar.png", "content_type": "image/png" } +``` + +Response body (`presignResponse`): +```json +{ "upload_url": "https://...", "public_url": "https://pub-xxx.r2.dev/avatar.png" } +``` + +The `filename` field is used as-is as the R2 object key. The presigned URL expires in 15 minutes. + +Responses: `200 OK` | `400 Bad Request` (binding failure) | `500 Internal Server Error` + +### DELETE /api/v1/storage/:key +Deletes the object with the given key from R2. + +Responses: `204 No Content` | `500 Internal Server Error` + +## Bootstrap wiring +In `bootstrap.Run`: +```go +var storageService usecase.StorageService +if cfg.R2AccountID != "" { + svc, err := r2.New(cfg.R2AccountID, cfg.R2AccessKey, cfg.R2SecretKey, cfg.R2Bucket, cfg.R2PublicURL) + if err != nil { + return nil, fmt.Errorf("bootstrap: r2: %w", err) + } + storageService = svc + log.Info("bootstrap: R2 storage client initialised", "bucket", cfg.R2Bucket) +} +``` +No `validateConfig` checks guard the R2 block — if `R2_ACCOUNT_ID` is non-empty but other R2 vars are empty, `r2.New` returns an error that aborts startup. + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `R2_ACCOUNT_ID` | Conditional — presence enables the feature | Cloudflare account ID. Found in the R2 dashboard overview. | +| `R2_ACCESS_KEY` | Required when `R2_ACCOUNT_ID` is set | R2 API token access key. | +| `R2_SECRET_KEY` | Required when `R2_ACCOUNT_ID` is set | R2 API token secret key. | +| `R2_BUCKET` | Required when `R2_ACCOUNT_ID` is set | Name of the R2 bucket. | +| `R2_PUBLIC_URL` | Required when `R2_ACCOUNT_ID` is set | Public base URL for the bucket (custom domain or `r2.dev` subdomain). | + +## Testing + +### Handler unit tests +Handler tests inject a `mockStorageService` struct that implements `usecase.StorageService`. No real R2 credentials or network calls are needed: + +```go +type mockStorageService struct { + presignURL string + publicURL string + presignErr error + deleteErr error +} +func (m *mockStorageService) PresignUpload(_ context.Context, _ string, _ string, _ time.Duration) (string, error) { + return m.presignURL, m.presignErr +} +func (m *mockStorageService) Delete(_ context.Context, _ string) error { return m.deleteErr } +func (m *mockStorageService) PublicURL(_ string) string { return m.publicURL } +``` + +### r2 package tests +Tests in `internal/infrastructure/storage/r2/` use `NewWithHTTPClient` with a custom `http.RoundTripper` to intercept S3 and presign requests without making real network calls: + +```go +transport := &mockTransport{handler: func(r *http.Request) *http.Response { ... }} +svc, _ := r2.NewWithHTTPClient("acct", "key", "secret", "bucket", "https://pub.example.com", + &http.Client{Transport: transport}) +``` diff --git a/backend/docs/swagger/docs.go b/backend/docs/swagger/docs.go index d32ab96..c7bc030 100644 --- a/backend/docs/swagger/docs.go +++ b/backend/docs/swagger/docs.go @@ -246,6 +246,99 @@ const docTemplate = `{ } } }, + "/api/v1/storage/presign": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a presigned PUT URL and the final public URL. The client uploads directly to R2 using the presigned URL.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "storage" + ], + "summary": "Request a presigned upload URL", + "parameters": [ + { + "description": "Upload request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.presignRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.presignResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/storage/{key}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "storage" + ], + "summary": "Delete a stored object", + "parameters": [ + { + "type": "string", + "description": "Object key", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/health": { "get": { "produces": [ @@ -386,6 +479,32 @@ const docTemplate = `{ } } }, + "handlers.presignRequest": { + "type": "object", + "required": [ + "content_type", + "filename" + ], + "properties": { + "content_type": { + "type": "string" + }, + "filename": { + "type": "string" + } + } + }, + "handlers.presignResponse": { + "type": "object", + "properties": { + "public_url": { + "type": "string" + }, + "upload_url": { + "type": "string" + } + } + }, "handlers.registerFCMTokenRequest": { "type": "object", "required": [ diff --git a/backend/docs/swagger/swagger.json b/backend/docs/swagger/swagger.json index aae1a9e..4f6f877 100644 --- a/backend/docs/swagger/swagger.json +++ b/backend/docs/swagger/swagger.json @@ -240,6 +240,99 @@ } } }, + "/api/v1/storage/presign": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a presigned PUT URL and the final public URL. The client uploads directly to R2 using the presigned URL.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "storage" + ], + "summary": "Request a presigned upload URL", + "parameters": [ + { + "description": "Upload request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.presignRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.presignResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/storage/{key}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "storage" + ], + "summary": "Delete a stored object", + "parameters": [ + { + "type": "string", + "description": "Object key", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/health": { "get": { "produces": [ @@ -380,6 +473,32 @@ } } }, + "handlers.presignRequest": { + "type": "object", + "required": [ + "content_type", + "filename" + ], + "properties": { + "content_type": { + "type": "string" + }, + "filename": { + "type": "string" + } + } + }, + "handlers.presignResponse": { + "type": "object", + "properties": { + "public_url": { + "type": "string" + }, + "upload_url": { + "type": "string" + } + } + }, "handlers.registerFCMTokenRequest": { "type": "object", "required": [ diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index 746ce21..52d0308 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -37,6 +37,23 @@ definitions: wait_duration: type: string type: object + handlers.presignRequest: + properties: + content_type: + type: string + filename: + type: string + required: + - content_type + - filename + type: object + handlers.presignResponse: + properties: + public_url: + type: string + upload_url: + type: string + type: object handlers.registerFCMTokenRequest: properties: platform: @@ -212,6 +229,65 @@ paths: summary: Get current user tags: - auth + /api/v1/storage/{key}: + delete: + parameters: + - description: Object key + in: path + name: key + required: true + type: string + responses: + "204": + description: No Content + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete a stored object + tags: + - storage + /api/v1/storage/presign: + post: + consumes: + - application/json + description: Returns a presigned PUT URL and the final public URL. The client + uploads directly to R2 using the presigned URL. + parameters: + - description: Upload request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.presignRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.presignResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Request a presigned upload URL + tags: + - storage /health: get: produces: diff --git a/backend/docs/websocket.md b/backend/docs/websocket.md index c7cf044..3eeed84 100644 --- a/backend/docs/websocket.md +++ b/backend/docs/websocket.md @@ -1,6 +1,6 @@ --- topic: websocket -last_verified: 2026-06-15 +last_verified: 2026-06-23 sources: - internal/infrastructure/ws/message.go - internal/infrastructure/ws/hub.go @@ -116,7 +116,7 @@ started in separate goroutines. `server.go` accepts `*ws.Hub` as a second argument: ```go -func NewServer(app *bootstrap.App, hub *ws.Hub) *http.Server +func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) ``` `cmd/api/main.go` creates the Hub, starts `Run` with a child context, and cancels @@ -127,27 +127,13 @@ hubCtx, hubCancel := context.WithCancel(context.Background()) hub := ws.NewHub() go hub.Run(hubCtx) -srv := server.NewServer(app, hub) +srv, err := server.NewServer(app, hub) // ... <-done hubCancel() // stop hub after server drains connections ``` -## Handler struct - -`verifier` (for WS auth) and `hub` are now fields on `Handler`: - -```go -type Handler struct { - healthUC usecase.HealthUseCase - verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) - hub *ws.Hub -} - -func NewHandler(healthUC usecase.HealthUseCase, verifier usecase.FirebaseTokenVerifier, hub *ws.Hub) *Handler -``` - -`RegisterRoutes` no longer accepts `verifier` as a parameter — it reads from `h.verifier`. +The canonical `Handler` struct definition and `NewHandler` signature (including all fields beyond `hub` and `verifier`) are documented in `backend/docs/routing.md`. ## Publishing events from workers (future #18) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 2631c61..91419bf 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -19,6 +19,7 @@ import ( "backend/internal/infrastructure/database/postgres" "backend/internal/infrastructure/email" "backend/internal/infrastructure/queue" + "backend/internal/infrastructure/storage/r2" "backend/internal/usecase" "backend/pkg/firebase" "backend/pkg/logger" @@ -35,14 +36,15 @@ const ( // App holds all initialised, validated shared dependencies. // Constructed once by Run and passed to the HTTP server. type App struct { - DB *sql.DB - Cache usecase.CacheService // nil when REDIS_URL is not set - Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set - Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set - FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set - EmailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set - Config Config - Log *slog.Logger + DB *sql.DB + Cache usecase.CacheService // nil when REDIS_URL is not set + Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set + FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set + EmailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set + StorageService usecase.StorageService // nil when R2_ACCOUNT_ID is not set + Config Config + Log *slog.Logger } // Config holds all validated configuration values read from environment variables. @@ -60,6 +62,11 @@ type Config struct { MailjetSecretKey string FromEmail string FromName string + R2AccountID string + R2AccessKey string + R2SecretKey string + R2Bucket string + R2PublicURL string } // ConfigError is returned when required configuration is absent or invalid. @@ -155,17 +162,28 @@ func Run(ctx context.Context) (*App, error) { log.Info("bootstrap: mailjet email sender initialised", "from_email", cfg.FromEmail) } + var storageService usecase.StorageService + if cfg.R2AccountID != "" { + svc, err := r2.New(cfg.R2AccountID, cfg.R2AccessKey, cfg.R2SecretKey, cfg.R2Bucket, cfg.R2PublicURL) + if err != nil { + return nil, fmt.Errorf("bootstrap: r2: %w", err) + } + storageService = svc + log.Info("bootstrap: R2 storage client initialised", "bucket", cfg.R2Bucket) + } + log.Info("bootstrap: all checks passed — ready to serve") return &App{ - DB: db, - Cache: cache, - Enqueuer: enqueuer, - Firebase: firebaseClient, - FCMSender: fcmSender, - EmailSender: emailSender, - Config: cfg, - Log: log, + DB: db, + Cache: cache, + Enqueuer: enqueuer, + Firebase: firebaseClient, + FCMSender: fcmSender, + EmailSender: emailSender, + StorageService: storageService, + Config: cfg, + Log: log, }, nil } @@ -204,6 +222,11 @@ func loadConfig() Config { MailjetSecretKey: os.Getenv("MAILJET_SECRET_KEY"), FromEmail: os.Getenv("FROM_EMAIL"), FromName: os.Getenv("FROM_NAME"), + R2AccountID: os.Getenv("R2_ACCOUNT_ID"), + R2AccessKey: os.Getenv("R2_ACCESS_KEY"), + R2SecretKey: os.Getenv("R2_SECRET_KEY"), + R2Bucket: os.Getenv("R2_BUCKET"), + R2PublicURL: os.Getenv("R2_PUBLIC_URL"), DB: postgres.DBConfig{ Host: os.Getenv("BLUEPRINT_DB_HOST"), Port: os.Getenv("BLUEPRINT_DB_PORT"), diff --git a/backend/internal/infrastructure/storage/r2/storage.go b/backend/internal/infrastructure/storage/r2/storage.go index bb0371b..5c2eb99 100644 --- a/backend/internal/infrastructure/storage/r2/storage.go +++ b/backend/internal/infrastructure/storage/r2/storage.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" "time" @@ -11,14 +12,9 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" -) -// StorageService is the interface satisfied by this Cloudflare R2 adapter. -type StorageService interface { - PresignUpload(ctx context.Context, key string, contentType string, ttl time.Duration) (string, error) - Delete(ctx context.Context, key string) error - PublicURL(key string) string -} + "backend/internal/usecase" +) type storageService struct { client *s3.Client @@ -27,19 +23,31 @@ type storageService struct { publicURL string } -// New returns a StorageService backed by Cloudflare R2. +// New returns a usecase.StorageService backed by Cloudflare R2. // R2 is S3-compatible; the custom endpoint encodes the account ID. -func New(accountID, accessKey, secretKey, bucket, publicBaseURL string) (StorageService, error) { +func New(accountID, accessKey, secretKey, bucket, publicBaseURL string) (usecase.StorageService, error) { + return NewWithHTTPClient(accountID, accessKey, secretKey, bucket, publicBaseURL, nil) +} + +// NewWithHTTPClient is like New but accepts a custom *http.Client. +// Pass nil to use the default AWS SDK HTTP client. +// This constructor exists to allow tests to inject a mock HTTP transport. +func NewWithHTTPClient(accountID, accessKey, secretKey, bucket, publicBaseURL string, httpClient *http.Client) (usecase.StorageService, error) { if accountID == "" || accessKey == "" || secretKey == "" || bucket == "" || publicBaseURL == "" { return nil, errors.New("r2: accountID, accessKey, secretKey, bucket, and publicBaseURL are all required") } endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID) - cfg, err := config.LoadDefaultConfig(context.Background(), + awsOpts := []func(*config.LoadOptions) error{ config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), config.WithRegion("auto"), - ) + } + if httpClient != nil { + awsOpts = append(awsOpts, config.WithHTTPClient(httpClient)) + } + + cfg, err := config.LoadDefaultConfig(context.Background(), awsOpts...) if err != nil { return nil, fmt.Errorf("r2: load config: %w", err) } diff --git a/backend/internal/infrastructure/storage/r2/storage_test.go b/backend/internal/infrastructure/storage/r2/storage_test.go new file mode 100644 index 0000000..3324c40 --- /dev/null +++ b/backend/internal/infrastructure/storage/r2/storage_test.go @@ -0,0 +1,133 @@ +package r2_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "backend/internal/infrastructure/storage/r2" +) + +// roundTripFunc allows a plain function to satisfy http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestNew_MissingFields(t *testing.T) { + cases := []struct { + name string + accountID, accessKey, secretKey, bucket, pub string + }{ + {"missing accountID", "", "ak", "sk", "bucket", "https://pub.example.com"}, + {"missing accessKey", "acct", "", "sk", "bucket", "https://pub.example.com"}, + {"missing secretKey", "acct", "ak", "", "bucket", "https://pub.example.com"}, + {"missing bucket", "acct", "ak", "sk", "", "https://pub.example.com"}, + {"missing publicBaseURL", "acct", "ak", "sk", "bucket", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := r2.New(tc.accountID, tc.accessKey, tc.secretKey, tc.bucket, tc.pub) + if err == nil { + t.Fatal("expected error for missing field, got nil") + } + }) + } +} + +func TestNew_AllFieldsPresent(t *testing.T) { + svc, err := r2.New("acct123", "AKID", "SECRET", "my-bucket", "https://pub.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if svc == nil { + t.Fatal("expected non-nil StorageService") + } +} + +func TestPresignUpload_ReturnsURLContainingKey(t *testing.T) { + // PresignUpload generates the URL locally without an HTTP call, so we just + // need a valid-looking service instance. + svc, err := r2.New("acct123", "AKID", "SECRET", "my-bucket", "https://pub.example.com") + if err != nil { + t.Fatalf("New: %v", err) + } + + url, err := svc.PresignUpload(context.Background(), "uploads/photo.jpg", "image/jpeg", 15*time.Minute) + if err != nil { + t.Fatalf("PresignUpload: %v", err) + } + if url == "" { + t.Fatal("expected non-empty presigned URL") + } + if !strings.Contains(url, "photo.jpg") { + t.Errorf("expected URL to contain key, got: %s", url) + } +} + +func TestDelete_CallsServerWithDELETE(t *testing.T) { + var capturedMethod, capturedPath string + + // httptest server that records the request and responds 204. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + httpClient := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + // Rewrite the host to point at the test server. + req.URL.Host = strings.TrimPrefix(srv.URL, "http://") + req.URL.Scheme = "http" + return http.DefaultTransport.RoundTrip(req) + }), + } + + svc, err := r2.NewWithHTTPClient("acct123", "AKID", "SECRET", "my-bucket", "https://pub.example.com", httpClient) + if err != nil { + t.Fatalf("NewWithHTTPClient: %v", err) + } + + if err := svc.Delete(context.Background(), "uploads/photo.jpg"); err != nil { + t.Fatalf("Delete: %v", err) + } + + if capturedMethod != http.MethodDelete { + t.Errorf("expected DELETE request, got %s", capturedMethod) + } + if !strings.Contains(capturedPath, "photo.jpg") { + t.Errorf("expected path to contain key, got: %s", capturedPath) + } +} + +func TestPublicURL_ReturnsBaseURLPlusKey(t *testing.T) { + svc, err := r2.New("acct123", "AKID", "SECRET", "my-bucket", "https://pub.example.com") + if err != nil { + t.Fatalf("New: %v", err) + } + + // url.PathEscape encodes both the space and the slash within the key, + // because the entire key is a single path segment from the SDK's perspective. + got := svc.PublicURL("uploads/my file.jpg") + want := "https://pub.example.com/uploads%2Fmy%20file.jpg" + if got != want { + t.Errorf("PublicURL: got %q, want %q", got, want) + } +} + +func TestPublicURL_SimpleKey(t *testing.T) { + svc, err := r2.New("acct123", "AKID", "SECRET", "my-bucket", "https://pub.example.com") + if err != nil { + t.Fatalf("New: %v", err) + } + + got := svc.PublicURL("photo.jpg") + want := "https://pub.example.com/photo.jpg" + if got != want { + t.Errorf("PublicURL: got %q, want %q", got, want) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index d599a24..be403fd 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -56,7 +56,7 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) { } } - h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo, app.EmailSender) + h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo, app.EmailSender, app.StorageService) // Register DB pool metrics collector. // AlreadyRegisteredError is silenced — only the first registration wins diff --git a/backend/internal/transport/handlers/handler.go b/backend/internal/transport/handlers/handler.go index 22bfa8b..a00fde5 100644 --- a/backend/internal/transport/handlers/handler.go +++ b/backend/internal/transport/handlers/handler.go @@ -9,14 +9,15 @@ import ( // Handler holds all use case dependencies for HTTP handlers. type Handler struct { - healthUC usecase.HealthUseCase - verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) - hub *ws.Hub - enqueuer usecase.Enqueuer // nil when REDIS_URL is not set - queueUI http.Handler // nil disables /admin/queues route - fcmSender usecase.NotificationSender // nil when Firebase is not configured - fcmTokenRepo usecase.FCMTokenRepository // nil when Firebase is not configured - emailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set + healthUC usecase.HealthUseCase + verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) + hub *ws.Hub + enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + queueUI http.Handler // nil disables /admin/queues route + fcmSender usecase.NotificationSender // nil when Firebase is not configured + fcmTokenRepo usecase.FCMTokenRepository // nil when Firebase is not configured + emailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set + storageService usecase.StorageService // nil when R2_ACCOUNT_ID is not set } // NewHandler constructs a Handler with all required use cases. @@ -29,15 +30,17 @@ func NewHandler( fcmSender usecase.NotificationSender, fcmTokenRepo usecase.FCMTokenRepository, emailSender usecase.EmailSender, + storageService usecase.StorageService, ) *Handler { return &Handler{ - healthUC: healthUC, - verifier: verifier, - hub: hub, - enqueuer: enqueuer, - queueUI: queueUI, - fcmSender: fcmSender, - fcmTokenRepo: fcmTokenRepo, - emailSender: emailSender, + healthUC: healthUC, + verifier: verifier, + hub: hub, + enqueuer: enqueuer, + queueUI: queueUI, + fcmSender: fcmSender, + fcmTokenRepo: fcmTokenRepo, + emailSender: emailSender, + storageService: storageService, } } diff --git a/backend/internal/transport/handlers/health_handler_test.go b/backend/internal/transport/handlers/health_handler_test.go index b90e168..cc90ec7 100644 --- a/backend/internal/transport/handlers/health_handler_test.go +++ b/backend/internal/transport/handlers/health_handler_test.go @@ -31,7 +31,7 @@ func TestHealthHandler_Success(t *testing.T) { Status: "up", Message: "It's healthy", } - h := NewHandler(&mockHealthUC{stats: want}, nil, nil, nil, nil, nil, nil, nil) + h := NewHandler(&mockHealthUC{stats: want}, nil, nil, nil, nil, nil, nil, nil, nil) r := gin.New() r.GET("/health", h.HealthHandler) @@ -50,7 +50,7 @@ func TestHealthHandler_Success(t *testing.T) { } func TestHealthHandler_ServiceUnavailable(t *testing.T) { - h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}, nil, nil, nil, nil, nil, nil, nil) + h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}, nil, nil, nil, nil, nil, nil, nil, nil) r := gin.New() r.GET("/health", h.HealthHandler) diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index 9c565a3..4da0a2b 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -70,5 +70,10 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. api.DELETE("/fcm/unregister", h.UnregisterFCMToken) } + if h.storageService != nil { + api.POST("/storage/presign", h.PresignHandler) + api.DELETE("/storage/:key", h.DeleteObjectHandler) + } + return r } diff --git a/backend/internal/transport/handlers/storage_handler.go b/backend/internal/transport/handlers/storage_handler.go new file mode 100644 index 0000000..9367aba --- /dev/null +++ b/backend/internal/transport/handlers/storage_handler.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type presignRequest struct { + Filename string `json:"filename" binding:"required"` + ContentType string `json:"content_type" binding:"required"` +} + +type presignResponse struct { + UploadURL string `json:"upload_url"` + PublicURL string `json:"public_url"` +} + +// PresignHandler godoc +// @Summary Request a presigned upload URL +// @Description Returns a presigned PUT URL and the final public URL. The client uploads directly to R2 using the presigned URL. +// @Tags storage +// @Accept json +// @Produce json +// @Param body body presignRequest true "Upload request" +// @Success 200 {object} presignResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/storage/presign [post] +// @Security BearerAuth +func (h *Handler) PresignHandler(c *gin.Context) { + var req presignRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + uploadURL, err := h.storageService.PresignUpload(c.Request.Context(), req.Filename, req.ContentType, 15*time.Minute) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate upload URL"}) + return + } + + c.JSON(http.StatusOK, presignResponse{ + UploadURL: uploadURL, + PublicURL: h.storageService.PublicURL(req.Filename), + }) +} + +// DeleteObjectHandler godoc +// @Summary Delete a stored object +// @Tags storage +// @Param key path string true "Object key" +// @Success 204 +// @Failure 500 {object} map[string]string +// @Router /api/v1/storage/{key} [delete] +// @Security BearerAuth +func (h *Handler) DeleteObjectHandler(c *gin.Context) { + key := c.Param("key") + + if err := h.storageService.Delete(c.Request.Context(), key); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete object"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/transport/handlers/storage_handler_test.go b/backend/internal/transport/handlers/storage_handler_test.go new file mode 100644 index 0000000..b88529c --- /dev/null +++ b/backend/internal/transport/handlers/storage_handler_test.go @@ -0,0 +1,165 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "backend/internal/transport/middleware" + "backend/internal/usecase" +) + +// mockStorageService implements usecase.StorageService for handler unit tests. +type mockStorageService struct { + presignURL string + publicURL string + presignErr error + deleteErr error +} + +func (m *mockStorageService) PresignUpload(_ context.Context, key string, _ string, _ time.Duration) (string, error) { + if m.presignErr != nil { + return "", m.presignErr + } + if m.presignURL != "" { + return m.presignURL, nil + } + return "https://r2.example.com/presigned/" + key, nil +} + +func (m *mockStorageService) Delete(_ context.Context, _ string) error { + return m.deleteErr +} + +func (m *mockStorageService) PublicURL(key string) string { + if m.publicURL != "" { + return m.publicURL + } + return "https://pub.example.com/" + key +} + +func newStorageRouter(h *Handler) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + injectUID := func(c *gin.Context) { + c.Set(middleware.FirebaseClaimsKey, &usecase.FirebaseToken{UID: "uid123"}) + c.Next() + } + r.POST("/api/v1/storage/presign", injectUID, h.PresignHandler) + r.DELETE("/api/v1/storage/:key", injectUID, h.DeleteObjectHandler) + return r +} + +// --- PresignHandler tests --- + +func TestPresignHandler_HappyPath(t *testing.T) { + mock := &mockStorageService{} + h := &Handler{storageService: mock} + r := newStorageRouter(h) + + body, _ := json.Marshal(map[string]string{ + "filename": "photo.jpg", + "content_type": "image/jpeg", + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/storage/presign", bytes.NewReader(body))) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp presignResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.UploadURL == "" { + t.Error("expected non-empty upload_url") + } + if resp.PublicURL == "" { + t.Error("expected non-empty public_url") + } +} + +func TestPresignHandler_MissingBody_Returns400(t *testing.T) { + mock := &mockStorageService{} + h := &Handler{storageService: mock} + r := newStorageRouter(h) + + // Send empty JSON object — missing required fields. + body, _ := json.Marshal(map[string]string{}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/storage/presign", bytes.NewReader(body))) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPresignHandler_StorageError_Returns500(t *testing.T) { + mock := &mockStorageService{presignErr: errors.New("r2: presign failed")} + h := &Handler{storageService: mock} + r := newStorageRouter(h) + + body, _ := json.Marshal(map[string]string{ + "filename": "photo.jpg", + "content_type": "image/jpeg", + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/storage/presign", bytes.NewReader(body))) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +// --- DeleteObjectHandler tests --- + +func TestDeleteObjectHandler_HappyPath(t *testing.T) { + mock := &mockStorageService{} + h := &Handler{storageService: mock} + r := newStorageRouter(h) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/v1/storage/photo.jpg", nil)) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteObjectHandler_StorageError_Returns500(t *testing.T) { + mock := &mockStorageService{deleteErr: errors.New("r2: delete failed")} + h := &Handler{storageService: mock} + r := newStorageRouter(h) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/v1/storage/photo.jpg", nil)) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteObjectHandler_NilStorageService_Returns404(t *testing.T) { + // When storageService is nil, the route should not be registered; + // hitting it directly returns 404 from Gin. + h := &Handler{storageService: nil} + gin.SetMode(gin.TestMode) + r := gin.New() + // Register no storage routes (simulates routes.go conditional). + // Any request to the storage path returns 404. + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/v1/storage/photo.jpg", nil)) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } + _ = h // suppress unused warning +} diff --git a/backend/internal/usecase/storage.go b/backend/internal/usecase/storage.go new file mode 100644 index 0000000..3e3d6e8 --- /dev/null +++ b/backend/internal/usecase/storage.go @@ -0,0 +1,13 @@ +package usecase + +import ( + "context" + "time" +) + +// StorageService is the application-layer interface for object storage. +type StorageService interface { + PresignUpload(ctx context.Context, key string, contentType string, ttl time.Duration) (string, error) + Delete(ctx context.Context, key string) error + PublicURL(key string) string +} diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index 489e5bb..56e867b 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.espresso.core) diff --git a/mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt b/mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt new file mode 100644 index 0000000..8fb1143 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt @@ -0,0 +1,75 @@ +package com.company.template.storage + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +interface UploadRepository { + suspend fun upload( + filename: String, + contentType: String, + fileBytes: ByteArray, + idToken: String, + ): Result // returns public URL +} + +class R2UploadRepository( + private val backendBaseUrl: String, + private val httpClient: OkHttpClient = OkHttpClient(), +) : UploadRepository { + + @Serializable + private data class PresignRequest( + val filename: String, + @SerialName("content_type") val contentType: String, + ) + + @Serializable + private data class PresignResponse( + @SerialName("upload_url") val uploadUrl: String, + @SerialName("public_url") val publicUrl: String, + ) + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun upload( + filename: String, + contentType: String, + fileBytes: ByteArray, + idToken: String, + ): Result = withContext(Dispatchers.IO) { + runCatching { + val presignResponse = presign(filename, contentType, idToken) + uploadToR2(presignResponse.uploadUrl, fileBytes, contentType) + presignResponse.publicUrl + } + } + + private fun presign(filename: String, contentType: String, idToken: String): PresignResponse { + val payload = json.encodeToString(PresignRequest.serializer(), PresignRequest(filename, contentType)) + val request = Request.Builder() + .url("$backendBaseUrl/api/v1/storage/presign") + .post(payload.toRequestBody("application/json".toMediaType())) + .header("Authorization", "Bearer $idToken") + .build() + val response = httpClient.newCall(request).execute() + check(response.isSuccessful) { "presign failed: ${response.code}" } + val body = checkNotNull(response.body?.string()) { "presign: empty body" } + return json.decodeFromString(PresignResponse.serializer(), body) + } + + private fun uploadToR2(uploadUrl: String, fileBytes: ByteArray, contentType: String) { + val request = Request.Builder() + .url(uploadUrl) + .put(fileBytes.toRequestBody(contentType.toMediaType())) + .build() + val response = httpClient.newCall(request).execute() + check(response.isSuccessful) { "R2 upload failed: ${response.code}" } + } +} diff --git a/mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt b/mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt new file mode 100644 index 0000000..8137904 --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt @@ -0,0 +1,102 @@ +package com.company.template.storage + +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class UploadRepositoryTest { + + private lateinit var backendServer: MockWebServer + private lateinit var r2Server: MockWebServer + private lateinit var repository: R2UploadRepository + + @Before + fun setUp() { + backendServer = MockWebServer() + r2Server = MockWebServer() + backendServer.start() + r2Server.start() + repository = R2UploadRepository( + backendBaseUrl = backendServer.url("").toString().trimEnd('/'), + httpClient = OkHttpClient(), + ) + } + + @After + fun tearDown() { + backendServer.shutdown() + r2Server.shutdown() + } + + @Test + fun `upload returns public URL on success`() = runTest { + val r2Url = r2Server.url("/test-key").toString() + backendServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"upload_url":"$r2Url","public_url":"https://pub.r2.dev/test-key"}"""), + ) + r2Server.enqueue(MockResponse().setResponseCode(200)) + + val result = repository.upload( + filename = "test.jpg", + contentType = "image/jpeg", + fileBytes = byteArrayOf(1, 2, 3), + idToken = "fake-token", + ) + + assertTrue(result.isSuccess) + assertEquals("https://pub.r2.dev/test-key", result.getOrNull()) + + val presignReq = backendServer.takeRequest() + assertEquals("POST", presignReq.method) + assertEquals("/api/v1/storage/presign", presignReq.path) + assertEquals("Bearer fake-token", presignReq.getHeader("Authorization")) + + val r2Req = r2Server.takeRequest() + assertEquals("PUT", r2Req.method) + assertEquals("image/jpeg", r2Req.getHeader("Content-Type")) + } + + @Test + fun `upload returns failure when presign fails`() = runTest { + backendServer.enqueue(MockResponse().setResponseCode(401)) + + val result = repository.upload( + filename = "test.jpg", + contentType = "image/jpeg", + fileBytes = byteArrayOf(), + idToken = "bad-token", + ) + + assertTrue(result.isFailure) + } + + @Test + fun `upload returns failure when R2 PUT fails`() = runTest { + val r2Url = r2Server.url("/test-key").toString() + backendServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"upload_url":"$r2Url","public_url":"https://pub.r2.dev/test-key"}"""), + ) + r2Server.enqueue(MockResponse().setResponseCode(403)) + + val result = repository.upload( + filename = "test.jpg", + contentType = "image/jpeg", + fileBytes = byteArrayOf(1, 2, 3), + idToken = "token", + ) + + assertTrue(result.isFailure) + } +} diff --git a/mobile/docs/_index.md b/mobile/docs/_index.md index 84e42ff..3984ed7 100644 --- a/mobile/docs/_index.md +++ b/mobile/docs/_index.md @@ -14,3 +14,4 @@ Topic-based documentation for the Android app. Each file is kept in sync with th | Testing patterns | `testing.md` | `app/src/test/java/com/company/template/GreetingFormatTest.kt`, `app/src/androidTest/java/com/company/template/GreetingTest.kt`, `app/build.gradle.kts` | | Observability (Sentry error tracking) | `observability.md` | `gradle/libs.versions.toml`, `app/build.gradle.kts`, `app/src/main/java/com/company/template/MainActivity.kt` | | Firebase Cloud Messaging — service, token registration, background notifications | `fcm.md` | `app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt`, `app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt`, `app/src/main/AndroidManifest.xml`, `gradle/libs.versions.toml` | +| Object storage (Cloudflare R2) — UploadRepository interface, R2UploadRepository, presign + PUT flow | `storage.md` | `app/src/main/java/com/company/template/storage/UploadRepository.kt` | diff --git a/mobile/docs/storage.md b/mobile/docs/storage.md new file mode 100644 index 0000000..ef93000 --- /dev/null +++ b/mobile/docs/storage.md @@ -0,0 +1,75 @@ +--- +topic: storage +last_verified: 2026-06-23 +sources: + - mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt +--- + +# Storage (Cloudflare R2 upload) + +## Upload flow +Same two-step flow as the web client: presign then PUT. + +1. POST to `/api/v1/storage/presign` with `filename` and `content_type` to receive `upload_url` and `public_url`. +2. PUT the raw file bytes directly to R2 using the presigned URL. + +Both steps execute on `Dispatchers.IO` inside a `withContext` block. + +## Interface + +```kotlin +interface UploadRepository { + suspend fun upload( + filename: String, + contentType: String, + fileBytes: ByteArray, + idToken: String, + ): Result // returns public URL on success +} +``` + +## R2UploadRepository + +`R2UploadRepository` in `com.company.template.storage` is the concrete implementation. + +Constructor: +```kotlin +class R2UploadRepository( + private val backendBaseUrl: String, + private val httpClient: OkHttpClient = OkHttpClient(), +) +``` + +The `httpClient` parameter defaults to a plain `OkHttpClient()`. Tests can inject a custom instance (e.g. one backed by `MockWebServer`) without subclassing. + +### Internal data classes +Both are `@Serializable` and use `kotlinx.serialization`: + +```kotlin +private data class PresignRequest( + val filename: String, + @SerialName("content_type") val contentType: String, +) + +private data class PresignResponse( + @SerialName("upload_url") val uploadUrl: String, + @SerialName("public_url") val publicUrl: String, +) +``` + +`Json { ignoreUnknownKeys = true }` is used for decoding so extra fields from the backend do not cause failures. + +### upload() +Calls private `presign()` then private `uploadToR2()` inside `runCatching`. Returns `Result` — the public URL on success, a wrapped exception on failure. + +### presign() +Serialises a `PresignRequest` to JSON, POSTs to `$backendBaseUrl/api/v1/storage/presign`, sets `Authorization: Bearer `. Throws via `check` if the response is not successful or the body is empty. + +### uploadToR2() +PUTs `fileBytes` to the presigned URL with the given `contentType`. Uses `ByteArray.toRequestBody(MediaType)`. Throws via `check` if the response is not successful. + +## Dependencies +The implementation uses OkHttp for HTTP and `kotlinx-serialization-json` for JSON. Both must be declared in `gradle/libs.versions.toml` and added to `app/build.gradle.kts`. + +## Testing +Inject a `MockWebServer` (OkHttp test library) instance and pass its URL as `backendBaseUrl` and an `OkHttpClient` pointed at it. Enqueue mock responses for the presign call and the R2 PUT call, then assert on `upload()` returning the expected public URL. diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml index 9ffe90d..6da749f 100644 --- a/mobile/gradle/libs.versions.toml +++ b/mobile/gradle/libs.versions.toml @@ -35,6 +35,7 @@ sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = " firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging-ktx = { group = "com.google.firebase", name = "firebase-messaging-ktx" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } diff --git a/web/docs/_index.md b/web/docs/_index.md index 33999e6..08f0615 100644 --- a/web/docs/_index.md +++ b/web/docs/_index.md @@ -13,3 +13,4 @@ The `docs` agent reads this index first to locate the right file. | Observability (Sentry error tracking) | [observability.md](observability.md) | `sentry.client.config.ts`, `sentry.server.config.ts`, `sentry.edge.config.ts`, `next.config.ts`, `.env.example` | | WebSocket hook (useWebSocket, reconnect, auth) | [websocket.md](websocket.md) | `lib/useWebSocket.ts`, `lib/useWebSocket.test.ts` | | Firebase Cloud Messaging — permission, token, service worker, useFCM hook | [fcm.md](fcm.md) | `lib/fcm.ts`, `lib/useFCM.ts`, `public/firebase-messaging-sw.js`, `lib/fcm.test.ts` | +| Object storage (Cloudflare R2) — presign utility, uploadToR2, useUpload hook | [storage.md](storage.md) | `lib/storage.ts`, `lib/useUpload.ts` | diff --git a/web/docs/storage.md b/web/docs/storage.md new file mode 100644 index 0000000..20efbaf --- /dev/null +++ b/web/docs/storage.md @@ -0,0 +1,79 @@ +--- +topic: storage +last_verified: 2026-06-23 +sources: + - web/lib/storage.ts + - web/lib/useUpload.ts +--- + +# Storage (Cloudflare R2 upload) + +## Upload flow +The client never talks to R2 directly with credentials. The two-step flow is: + +1. Call the backend `POST /api/v1/storage/presign` with the filename and MIME type to receive a short-lived presigned PUT URL and the final public URL. +2. PUT the file bytes directly to R2 using the presigned URL. + +Both steps are encapsulated in `lib/storage.ts`. The `useUpload` hook in `lib/useUpload.ts` wraps the flow with React state. + +## lib/storage.ts + +### `presign(filename, contentType, idToken): Promise` +Posts to the backend presign endpoint. Forwards the Firebase ID token as `Authorization: Bearer `. Throws if the response is not OK. + +Return type: +```ts +interface PresignResult { + uploadUrl: string + publicUrl: string +} +``` + +The backend base URL is read from `process.env.NEXT_PUBLIC_BACKEND_URL`, defaulting to `http://localhost:8080`. + +Request body sent: +```json +{ "filename": "avatar.png", "content_type": "image/png" } +``` + +### `uploadToR2(file: File, uploadUrl: string): Promise` +PUTs the file directly to R2 using the presigned URL. Sets `Content-Type` to `file.type`. Throws if the response is not OK. No auth header — the presigned URL is self-authenticating. + +## lib/useUpload.ts + +`useUpload` is a Client Component hook (`'use client'`). It orchestrates the two-step upload and exposes React state. + +```ts +interface UploadState { + isUploading: boolean + publicUrl: string | null + error: Error | null + upload: (file: File, idToken: string) => Promise +} + +function useUpload(): UploadState +``` + +Calling `upload(file, idToken)`: +1. Sets `isUploading = true`, clears `error`. +2. Calls `presign(file.name, file.type, idToken)` to get `uploadUrl` and `publicUrl`. +3. Calls `uploadToR2(file, uploadUrl)`. +4. On success: sets `publicUrl`. +5. On any error: sets `error` (normalised to `Error` if the thrown value is not already one). +6. Always sets `isUploading = false` in the `finally` block. + +The `upload` function is memoised with `useCallback` and has no dependencies, so it is stable across renders. + +## Environment variable + +| Variable | Description | +|---|---| +| `NEXT_PUBLIC_BACKEND_URL` | Backend base URL. Defaults to `http://localhost:8080` when absent. | + +## Testing + +### storage.ts +Tests use `vi.stubGlobal('fetch', ...)` to intercept the `presign` call and the R2 PUT without any real network. Assert on the URL, method, headers, and body passed to `fetch`. + +### useUpload.ts +Tests use `vi.mock('@/lib/storage', ...)` to replace `presign` and `uploadToR2` with controlled stubs. Render the hook with `renderHook`, call `result.current.upload(...)`, and assert on `isUploading`, `publicUrl`, and `error`. diff --git a/web/lib/storage.test.ts b/web/lib/storage.test.ts new file mode 100644 index 0000000..dcb1a62 --- /dev/null +++ b/web/lib/storage.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { presign, uploadToR2 } from './storage' + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('presign', () => { + it('returns uploadUrl and publicUrl on a 200 response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + upload_url: 'https://r2.example.com/presigned-put', + public_url: 'https://cdn.example.com/my-file.jpg', + }), + }) + vi.stubGlobal('fetch', mockFetch) + + const result = await presign('my-file.jpg', 'image/jpeg', 'id-token-abc') + + expect(result).toEqual({ + uploadUrl: 'https://r2.example.com/presigned-put', + publicUrl: 'https://cdn.example.com/my-file.jpg', + }) + }) + + it('throws when the backend returns a non-200 response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 }) + vi.stubGlobal('fetch', mockFetch) + + await expect(presign('file.png', 'image/png', 'bad-token')).rejects.toThrow('presign failed: 401') + }) + + it('passes the Authorization header with the idToken', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ upload_url: 'https://r2.example.com/put', public_url: 'https://cdn.example.com/f' }), + }) + vi.stubGlobal('fetch', mockFetch) + + await presign('photo.png', 'image/png', 'my-id-token') + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/storage/presign'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-id-token', + }), + }), + ) + }) + + it('sends filename and content_type in the request body', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ upload_url: 'https://r2.example.com/put', public_url: 'https://cdn.example.com/f' }), + }) + vi.stubGlobal('fetch', mockFetch) + + await presign('doc.pdf', 'application/pdf', 'token-xyz') + + expect(mockFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ filename: 'doc.pdf', content_type: 'application/pdf' }), + }), + ) + }) +}) + +describe('uploadToR2', () => { + it('makes a PUT request to the presigned URL with the file bytes and Content-Type', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', mockFetch) + + const file = new File(['hello world'], 'hello.txt', { type: 'text/plain' }) + await uploadToR2(file, 'https://r2.example.com/presigned-put?sig=abc') + + expect(mockFetch).toHaveBeenCalledWith( + 'https://r2.example.com/presigned-put?sig=abc', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ 'Content-Type': 'text/plain' }), + body: file, + }), + ) + }) + + it('throws on a non-2xx response from R2', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 403 }) + vi.stubGlobal('fetch', mockFetch) + + const file = new File(['data'], 'data.bin', { type: 'application/octet-stream' }) + await expect(uploadToR2(file, 'https://r2.example.com/put')).rejects.toThrow('R2 upload failed: 403') + }) +}) diff --git a/web/lib/storage.ts b/web/lib/storage.ts new file mode 100644 index 0000000..c14b95b --- /dev/null +++ b/web/lib/storage.ts @@ -0,0 +1,45 @@ +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:8080' + +export interface PresignResult { + uploadUrl: string + publicUrl: string +} + +/** + * Calls the backend presign endpoint to get a presigned PUT URL and a public URL. + * The idToken is forwarded as a Bearer token so the backend can authenticate the caller. + */ +export async function presign( + filename: string, + contentType: string, + idToken: string, +): Promise { + const res = await fetch(`${BACKEND_URL}/api/v1/storage/presign`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${idToken}`, + }, + body: JSON.stringify({ filename, content_type: contentType }), + }) + if (!res.ok) { + throw new Error(`presign failed: ${res.status}`) + } + const data = (await res.json()) as { upload_url: string; public_url: string } + return { uploadUrl: data.upload_url, publicUrl: data.public_url } +} + +/** + * Uploads a file directly to Cloudflare R2 using a presigned PUT URL. + * The file bytes are sent as the request body with the file's MIME type. + */ +export async function uploadToR2(file: File, uploadUrl: string): Promise { + const res = await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file, + }) + if (!res.ok) { + throw new Error(`R2 upload failed: ${res.status}`) + } +} diff --git a/web/lib/useUpload.test.ts b/web/lib/useUpload.test.ts new file mode 100644 index 0000000..f09ce40 --- /dev/null +++ b/web/lib/useUpload.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +const { mockPresign, mockUploadToR2 } = vi.hoisted(() => ({ + mockPresign: vi.fn(), + mockUploadToR2: vi.fn(), +})) + +vi.mock('@/lib/storage', () => ({ + presign: mockPresign, + uploadToR2: mockUploadToR2, +})) + +import { useUpload } from './useUpload' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useUpload', () => { + it('has isUploading false initially', () => { + const { result } = renderHook(() => useUpload()) + expect(result.current.isUploading).toBe(false) + }) + + it('has publicUrl null initially', () => { + const { result } = renderHook(() => useUpload()) + expect(result.current.publicUrl).toBeNull() + }) + + it('has error null initially', () => { + const { result } = renderHook(() => useUpload()) + expect(result.current.error).toBeNull() + }) + + it('sets publicUrl and clears isUploading after a successful upload', async () => { + mockPresign.mockResolvedValue({ + uploadUrl: 'https://r2.example.com/put', + publicUrl: 'https://cdn.example.com/uploaded.jpg', + }) + mockUploadToR2.mockResolvedValue(undefined) + + const { result } = renderHook(() => useUpload()) + const file = new File(['img'], 'photo.jpg', { type: 'image/jpeg' }) + + await act(async () => { + await result.current.upload(file, 'id-token-ok') + }) + + expect(result.current.publicUrl).toBe('https://cdn.example.com/uploaded.jpg') + expect(result.current.isUploading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('sets error and clears isUploading after a failed upload', async () => { + mockPresign.mockRejectedValue(new Error('presign failed: 401')) + + const { result } = renderHook(() => useUpload()) + const file = new File(['img'], 'photo.jpg', { type: 'image/jpeg' }) + + await act(async () => { + await result.current.upload(file, 'bad-token') + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('presign failed: 401') + expect(result.current.isUploading).toBe(false) + expect(result.current.publicUrl).toBeNull() + }) + + it('sets isUploading to true while the upload is in progress', async () => { + let resolvePresign!: (value: { uploadUrl: string; publicUrl: string }) => void + mockPresign.mockReturnValue( + new Promise<{ uploadUrl: string; publicUrl: string }>((resolve) => { + resolvePresign = resolve + }), + ) + mockUploadToR2.mockResolvedValue(undefined) + + const { result } = renderHook(() => useUpload()) + const file = new File(['img'], 'photo.jpg', { type: 'image/jpeg' }) + + // Start the upload without awaiting so we can inspect mid-flight state + act(() => { + void result.current.upload(file, 'token') + }) + + expect(result.current.isUploading).toBe(true) + + // Resolve and finish + await act(async () => { + resolvePresign({ uploadUrl: 'https://r2.example.com/put', publicUrl: 'https://cdn.example.com/f' }) + }) + + expect(result.current.isUploading).toBe(false) + }) + + it('calls presign with the file name, file type, and idToken', async () => { + mockPresign.mockResolvedValue({ + uploadUrl: 'https://r2.example.com/put', + publicUrl: 'https://cdn.example.com/f', + }) + mockUploadToR2.mockResolvedValue(undefined) + + const { result } = renderHook(() => useUpload()) + const file = new File(['data'], 'report.pdf', { type: 'application/pdf' }) + + await act(async () => { + await result.current.upload(file, 'auth-token') + }) + + expect(mockPresign).toHaveBeenCalledWith('report.pdf', 'application/pdf', 'auth-token') + }) + + it('calls uploadToR2 with the file and the presigned upload URL', async () => { + mockPresign.mockResolvedValue({ + uploadUrl: 'https://r2.example.com/put?sig=xyz', + publicUrl: 'https://cdn.example.com/f', + }) + mockUploadToR2.mockResolvedValue(undefined) + + const { result } = renderHook(() => useUpload()) + const file = new File(['data'], 'image.png', { type: 'image/png' }) + + await act(async () => { + await result.current.upload(file, 'token') + }) + + expect(mockUploadToR2).toHaveBeenCalledWith(file, 'https://r2.example.com/put?sig=xyz') + }) +}) diff --git a/web/lib/useUpload.ts b/web/lib/useUpload.ts new file mode 100644 index 0000000..48f1f14 --- /dev/null +++ b/web/lib/useUpload.ts @@ -0,0 +1,39 @@ +'use client' + +import { useState, useCallback } from 'react' +import { presign, uploadToR2 } from '@/lib/storage' + +export interface UploadState { + isUploading: boolean + publicUrl: string | null + error: Error | null + upload: (file: File, idToken: string) => Promise +} + +/** + * React hook for the presign → PUT → public URL upload flow. + * + * Call `upload(file, idToken)` to start an upload. The hook tracks + * isUploading, publicUrl (set on success), and error (set on failure). + */ +export function useUpload(): UploadState { + const [isUploading, setIsUploading] = useState(false) + const [publicUrl, setPublicUrl] = useState(null) + const [error, setError] = useState(null) + + const upload = useCallback(async (file: File, idToken: string) => { + setIsUploading(true) + setError(null) + try { + const { uploadUrl, publicUrl: url } = await presign(file.name, file.type, idToken) + await uploadToR2(file, uploadUrl) + setPublicUrl(url) + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setIsUploading(false) + } + }, []) + + return { isUploading, publicUrl, error, upload } +}