Skip to content

Object Storage

User-uploaded binary content (avatars, chat images, group chat avatars) is stored in an S3-compatible object store. Source: backend/internal/storage/.

Interface

All storage backends implement a single interface:

// backend/internal/storage/storage.go
type FileStorage interface {
    Upload(ctx context.Context, file *multipart.FileHeader, path string) (string, error)
    Delete(ctx context.Context, path string) error
    GetURL(path string) string
}

The concrete implementation chosen at boot is always the S3 client (storage.S3Storage). For local development we point the S3 client at MinIO via the endpoint config field — there is no separate local_storage code path in production, even though local_storage.go exists in the package.

S3 client

Built on the AWS SDK v2 (github.com/aws/aws-sdk-go-v2/service/s3 + feature/s3/manager). Construction lives in wiring.ProvideFileStorage and reads from S3Config:

Config 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, etc.)
base_url Public CDN URL prefix (e.g. https://assets.tomoda.life) — used by GetURL
endpoint Optional override (e.g. 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 on every object so the CDN can pin them indefinitely. Content-Type comes from the multipart header, falling back to application/octet-stream.

URL strategy

GetURL(path) returns:

  • {S3.BaseURL}/{path} when BaseURL is configured (production — CDN URL)
  • https://{bucket}.s3.amazonaws.com/{path} otherwise (fallback)

This is the URL that gets persisted into Postgres on, e.g., User.AvatarURL. The bucket itself is not public — the CDN fronts it.

Local: MinIO

For local development, docker-compose.dev.yml runs MinIO on:

  • API: http://localhost:9000
  • Console: http://localhost:9001 (login: tomoda / tomoda123)

The bucket tomoda-local is created on startup and is what task db:test:setup uploads avatars into. S3.use_path_style: true is required for MinIO.

Production

Production uses AWS S3 (or GCS via the S3-compatible HMAC interface — verify by checking config.prod.yaml and the deployed bucket). Credentials are pulled from GCP Secret Manager and injected at runtime; do not hardcode them.

Consumers

Service Path prefix
AuthHandler.UploadAvatar avatars/{user_id}/{filename}
ChatHandler.UploadChatImage chat/{room_id}/{filename}
ChatHandler.UploadGroupAvatar groups/{room_id}/{filename}
Moment creation moments/{moment_id}/{filename}

Paths are not enforced in the storage layer — each handler decides where to put the object. Be consistent with the prefixes above when adding new upload paths.

Raw byte uploads

S3Storage.UploadRaw accepts an io.Reader and content type directly, bypassing the *multipart.FileHeader requirement. It is used by cmd/test/data to upload generated avatars, and is the right entry point for any server-internal upload (e.g. resized images, generated PDFs).