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}whenBaseURLis 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).