Object Storage¶
User-uploaded assets live in an S3-compatible bucket fronted by CloudFront. The backend never proxies uploads from the client — the frontend PUTs directly to S3 with a short-lived presigned URL the backend issues. Reads go through CloudFront with Origin Access Control (OAC); the bucket itself is private.
backend/internal/storage/ is the S3 / local driver (the FileStorage contract); backend/internal/platform/assets wraps it as assets.Service, the single write / delete entry point. Handlers and other services go through assets.Service, never FileStorage directly.
Mental model¶
RN / web
assets.Service.IssueUploadURL
private
CDN domain
Bucket layout¶
Every prefix is a typed storage.Prefix constant. The set is closed: adding a location means adding a constant in backend/internal/storage/prefixes.go and an AssetKind in backend/internal/platform/assets/assets.go, never passing a raw string.
| Prefix | Owning column | Lifecycle |
|---|---|---|
avatars/ |
users.asset_key |
Replace on rotation; delete on user purge |
group-avatars/ |
chat_rooms.asset_key |
Replace on rotation; delete on room purge |
chat-images/ |
chat_messages.metadata->'images'->'keys' (JSONB array; 1..N per message) |
Delete on message hard-delete (sender-delete propagates) + disappearing-message TTL |
moments/ |
moments.asset_key |
Delete on moment hard-delete; will be ref-counted once moment tagging ships |
event-covers/ |
events.asset_key |
Delete on event hard-delete / cancel |
plan-covers/ |
plans.asset_key |
User-uploaded cover; overrides the parsed-item thumbnail fallback |
item-images/ |
item_photos.asset_key (user-owned photos), item_links.thumbnail_key (rehosted link thumbnails) |
Delete on item / link hard-delete; not tied to source URL rot |
user-stamps/ |
stamp_mints.asset_key (pixelated PNG) |
Delete when the last stamp_mint_holders row drops — see DeleteMintIfOrphan |
user-stamps-raw/ |
stamp_mints.raw_asset_key |
Cascades with user-stamps/ — same mint owns both keys |
stamps/ |
tomoda_stamps.asset_key |
Curated — permanent |
stamps/bespoke/ |
tomoda_stamps.bespoke_asset_key |
Curated — permanent |
curios/ |
curios.asset_key |
Curated — permanent |
Per-prefix S3 lifecycle rules (transitions, multipart-abort) live in devops/infrastructure/aws/s3.tf — moments/ transitions to STANDARD_IA at 30 days; every prefix shares the 24h abort-incomplete-multipart rule.
Curated vs. user-generated¶
Prefix.IsCurated() returns true for stamps/, stamps/bespoke/, and curios/. These hold admin-managed assets seeded by the curated-import path and are never deleted by user-facing code. Both assets.Service.Delete / assets.Service.EnqueueDelete and the async cleanup handler refuse curated keys; the orphan sweep skips them entirely.
User-generated prefixes follow the app-cascade-delete pattern: when the owning DB row is hard-deleted, the service enqueues an assets.Service.EnqueueDelete for each key. No DB foreign key, no S3 lifecycle rule — the deletion is an explicit cascade in service code.
Direct-to-S3 uploads¶
The frontend uploads bytes straight to S3 via presigned PUT. Backend egress stays out of the hot path.
POST /uploads/<kind> → presigned PUT to S3 → finalise on the owning resource
| Start endpoint | Issues for | Owner-scoped key | Finalise on |
|---|---|---|---|
POST /api/v1/uploads/avatar |
AssetAvatar |
user ID | PUT /api/v1/users/me/avatar |
POST /api/v1/uploads/group-avatar |
AssetGroupAvatar |
— (caller supplies room_id; reserved for future membership policy) |
PUT /api/v1/dm/rooms/{id}/avatar |
POST /api/v1/uploads/chat-image |
AssetChatImage |
— | WebSocket send_message with image_keys: [key] |
POST /api/v1/uploads/chat-image/batch |
AssetChatImage × N |
— | WebSocket send_message with image_keys: [...] (1..10, MIME per key) |
POST /api/v1/uploads/moment |
AssetMoment |
user ID | POST /api/v1/moments |
POST /api/v1/uploads/event-cover |
AssetEventCover |
user ID | POST / PATCH /api/v1/events (cover_image_url = key) |
POST /api/v1/uploads/plan-cover |
AssetPlanCover |
user ID | PATCH /api/v1/plans/{id} (cover_image_key) |
POST /api/v1/uploads/item-image |
AssetItemImage |
user ID | POST /api/v1/items (image_keys) or POST /api/v1/items/{id}/photos |
Each start endpoint returns { key, upload_url }. The TTL is fixed at 5 minutes (issueUploadURLTTL in backend/internal/platform/assets/assets.go). Two things are bound into the signature so a client can't rewrite them at upload time:
- Content-Type — signed; S3 rejects a PUT whose
Content-Typediffers. - Key prefix — generated server-side from the kind's
storage.Prefix; the client can't pick the key.
Size is not bound into the signature. Binding Content-Length would force the client to send exactly that byte count, which the issuer can't know in advance. Instead the per-kind cap (spec.maxBytes in backend/internal/platform/assets/assets.go) is enforced post-upload by AssertUploaded's HeadObject check, with a best-effort delete on oversize.
After the PUT, the client calls the finalise endpoint with the key. The handler routes to assets.Service.AssertUploaded, which:
- verifies the key lives under the kind's prefix (catches a client trying to POST somebody else's
moments/key to the avatar finalise); - for owner-scoped kinds, checks the prefix-stripped basename starts with the supplied owner ID;
- HEADs the object to confirm it exists and re-checks size against the cap.
The finalise step then persists the key on the owning model and enqueues the old key (if any) for async cleanup.
User-stamp catalog + event-cover flows haven't been migrated yet — those compose UIs need server-side processing alongside the persist, so they'll move to the presigned flow when the compose flows are reworked.
Local dev / MinIO falls back to the same presign code path; the S3 endpoint override is set in S3Config.endpoint.
CloudFront OAC reads¶
The bucket has no public ACL. Reads are served by a CloudFront distribution with Origin Access Control bound to the bucket's resource policy. The CDN domain is wired into S3Config.base_url (e.g. https://assets.tomoda.life); assets.Service.URLFromKey composes {base_url}/{key} on read, called via the AfterFind hooks on each model with an asset.
When base_url is empty (local dev), URLFromKey falls back to FileStorage.GetURL which returns the direct MinIO URL — same shape, different host.
Cascade delete¶
User-row hard-deletes fan out async S3 deletes. The DB transaction commits immediately; the S3 work runs on the asynq low queue.
| Trigger | Cascade |
|---|---|
MomentService.PurgeSoftDeleted |
For each purged row, assets.Service.EnqueueDelete(moment.AssetKey) |
UserStampService.UntagSelf / replace path |
repo.DeleteMintIfOrphan returns both keys when no holders remain; service calls EnqueueDelete on each |
UserService.HardDelete / GDPR purge |
Per-row enumeration of avatar + moment + chat-image keys, each enqueued |
AuthService.UpdateAvatar (replace) / UpdateGroupAvatar |
Async assets.Service.EnqueueDelete on the old key after the new key persists — keeps the request path off S3 |
ChatService.DeleteMessage (sender hard-delete) |
Each key in metadata.images.keys enqueued so every attached image disappears for everyone |
ChatService.CleanupExpiredMessages (disappearing TTL) |
Batch enqueue every key in each expired row's metadata.images.keys |
The task type is cleanup:s3_delete; payload is the key + a timestamp. Handler: HandleS3Delete in backend/internal/async/handlers/cleanup.go. The handler re-checks the curated-prefix guard before calling the S3 backend.
Failed-delete recovery¶
Failed cleanup:s3_delete tasks retry per the asynq retry policy, then land in the archived queue. The admin DLQ inspector surfaces them:
| Endpoint | What it does |
|---|---|
GET /api/v1/admin/async/queues |
Snapshot per queue: size, active, pending, scheduled, retry, archived |
GET /api/v1/admin/async/queues/:queue/archived |
Paginated listing of archived tasks with payload + last error |
POST /api/v1/admin/async/queues/:queue/archived/:id/run |
Pull a single task back into pending |
POST /api/v1/admin/async/queues/:queue/archived/run-all |
Bulk replay every archived task in a queue |
DELETE /api/v1/admin/async/queues/:queue/archived/:id |
Drop a poison-payload task |
See backend/internal/services/admin/async_handler.go.
Orphan sweep¶
Crash windows between a row's hard-delete and the cleanup:s3_delete task can leave an object in S3 with no DB owner. The orphan-sweep handler enumerates each user-generated prefix, cross-checks every key against the owning column (using Unscoped() so soft-deleted rows still count as owners), and queues a delete for any unowned key. Chat-image keys live inside chat_messages.metadata->'images'->'keys', so that prefix uses a raw SQL jsonb_array_elements_text pluck instead of a direct column read.
The sweep is admin-triggered only. It is intentionally not on the scheduler — at scale, the enumeration cost on S3 plus the Distinct pluck to build the "owned" set is dominated by moments/ and chat-images/ (millions of objects after a few months). Cost beats value for a routine cron.
Admins kick it off via POST /api/v1/admin/async/s3-orphan-sweep?prefix=moments/ — one prefix per call, bounded scope. Curated prefixes return 400. The handler routes the task onto the low queue with MaxRetry=2 and 24h retention.
See HandleS3OrphanSweep + orphanColumns in backend/internal/async/handlers/cleanup.go for the per-prefix DB lookup map.
Configuration¶
Construction lives in wiring.ProvideFileStorage; the only backend in production is S3Storage.
S3Config field |
Purpose |
|---|---|
bucket |
Bucket name |
region |
AWS region (defaults to ap-northeast-1 when blank) |
access_key_id / secret_access_key |
Static credentials, or empty to use the SDK default chain (instance metadata, env) |
base_url |
CloudFront URL (e.g. https://assets.tomoda.life) — used by URLFromKey |
endpoint |
Optional override (http://localhost:9000 for MinIO) |
use_path_style |
true for MinIO, false for real S3 |
Uploads use the managed s3/manager.Uploader (multipart-aware) and set Cache-Control: public, max-age=31536000, immutable so the CDN can pin objects indefinitely. Content-Type comes from the asset-side sniff (assets.Service.Upload) or the presigned signature (direct PUT).
Local dev (MinIO)¶
docker-compose.dev.yml runs MinIO on:
- API:
http://localhost:9000 - Console:
http://localhost:9001(login:tomoda/tomoda123)
Bucket tomoda-local is created on startup. S3.use_path_style: true is required.
See also¶
backend/internal/platform/assets— the single write / delete entry point that wraps the storage driver- Async —
cleanup:s3_delete+cleanup:s3_orphan_sweeptask definitions, admin DLQ endpoints