Skip to content

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

client
RN / web
↓ intent
backend
assets.Service.IssueUploadURL
↓ presigned PUT (5 min)
S3 bucket
private
↑ OAC read
CloudFront
CDN domain
Writes bypass the backend's egress path; reads go through the CDN.

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.tfmoments/ 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:

  1. Content-Type — signed; S3 rejects a PUT whose Content-Type differs.
  2. 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
  • Asynccleanup:s3_delete + cleanup:s3_orphan_sweep task definitions, admin DLQ endpoints