Store AI welcome image in object storage (MinIO/R2) instead of inlining
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

The welcome image was inlined as a ~2.6MB data URL in the card, bloating every
public-profile fetch. Now it's uploaded to S3-compatible object storage and the
profile carries just a URL (profile JSON dropped ~2.6MB -> ~575 bytes).

- ObjectStore: add put_object(key, bytes, content_type) + public_url(key).
  R2Store uploads via a presigned PUT; InMemoryStore keeps bytes for tests.
- R2Config: add public_base (R2_PUBLIC_BASE); falls back to endpoint/bucket.
- card_service::set_welcome: decode the generated image, upload to
  welcome/<id>.<ext>, store its public URL as welcome.imageUrl.
- docker-compose: add MinIO (S3-compatible, drop-in for R2) + a one-shot that
  creates a public-read cardclaws-assets bucket for local dev.
- web: PublicProfile.welcome.imageUrl (renamed from imageDataUrl); WelcomeHero
  unchanged. astro check clean.

Verified live against MinIO: real Gemini image uploaded, served publicly
(HTTP 200, image/png, 1024x1024), profile payload now tiny. Same code path runs
against Cloudflare R2 in production (endpoint/creds/public_base only). Gate green
(118 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 20:04:49 -05:00
co-authored by Claude Opus 4.8
parent 256f2285b9
commit 63fdc4913b
7 changed files with 138 additions and 9 deletions
@@ -82,6 +82,8 @@ pub struct R2Config {
pub bucket: String,
pub access_key: String,
pub secret_key: String,
/// Public base URL objects are served from. Empty -> endpoint/bucket.
pub public_base: String,
}
impl Config {
@@ -116,6 +118,7 @@ impl Config {
bucket: optional(src, "R2_BUCKET", "cardclaws-assets").await,
access_key: optional(src, "R2_ACCESS_KEY", "").await,
secret_key: optional(src, "R2_SECRET_KEY", "").await,
public_base: optional(src, "R2_PUBLIC_BASE", "").await,
},
wallet: WalletConfig {
apple_pass_type_id: optional(src, "APPLE_PASS_TYPE_ID", "pass.com.cardclaws.card")