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
@@ -9,6 +9,7 @@
//! ClamAV scanning (PRD §20.2) happen lazily on first access, not here — that //! ClamAV scanning (PRD §20.2) happen lazily on first access, not here — that
//! scan-on-serve path is a later milestone. //! scan-on-serve path is a later milestone.
use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
@@ -30,6 +31,18 @@ pub trait ObjectStore: Send + Sync {
/// Return a presigned PUT URL the client can upload `key` to. /// Return a presigned PUT URL the client can upload `key` to.
fn presign_put(&self, key: &str) -> Result<String, StoreError>; fn presign_put(&self, key: &str) -> Result<String, StoreError>;
/// Upload bytes server-side (e.g. an AI-generated image). The object must be
/// publicly readable so the web profile can fetch it.
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: &str,
) -> Result<(), StoreError>;
/// The public URL an uploaded object is served from.
fn public_url(&self, key: &str) -> String;
/// Delete an object. /// Delete an object.
async fn delete(&self, key: &str) -> Result<(), StoreError>; async fn delete(&self, key: &str) -> Result<(), StoreError>;
} }
@@ -40,6 +53,10 @@ pub struct R2Store {
bucket: Bucket, bucket: Bucket,
creds: Credentials, creds: Credentials,
http: reqwest::Client, http: reqwest::Client,
/// Base URL objects are publicly served from (e.g. the R2 public domain, or
/// `http://localhost:9000/<bucket>` for a local MinIO). S3-compatible, so the
/// same store works against R2 in prod and MinIO in dev.
public_base: String,
} }
impl R2Store { impl R2Store {
@@ -49,14 +66,20 @@ impl R2Store {
.parse() .parse()
.map_err(|e| StoreError(format!("{e}")))?; .map_err(|e| StoreError(format!("{e}")))?;
// R2 ignores the region but the S3 signer requires one; "auto" is // R2 ignores the region but the S3 signer requires one; "auto" is
// Cloudflare's convention. // Cloudflare's convention (MinIO ignores it too).
let bucket = Bucket::new(url, UrlStyle::Path, cfg.bucket.clone(), "auto") let bucket = Bucket::new(url, UrlStyle::Path, cfg.bucket.clone(), "auto")
.map_err(|e| StoreError(e.to_string()))?; .map_err(|e| StoreError(e.to_string()))?;
let creds = Credentials::new(cfg.access_key.clone(), cfg.secret_key.clone()); let creds = Credentials::new(cfg.access_key.clone(), cfg.secret_key.clone());
let public_base = if cfg.public_base.is_empty() {
format!("{}/{}", cfg.endpoint.trim_end_matches('/'), cfg.bucket)
} else {
cfg.public_base.trim_end_matches('/').to_string()
};
Ok(Self { Ok(Self {
bucket, bucket,
creds, creds,
http: reqwest::Client::new(), http: reqwest::Client::new(),
public_base,
}) })
} }
} }
@@ -68,6 +91,35 @@ impl ObjectStore for R2Store {
Ok(action.sign(PRESIGN_TTL).to_string()) Ok(action.sign(PRESIGN_TTL).to_string())
} }
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: &str,
) -> Result<(), StoreError> {
let url = self
.bucket
.put_object(Some(&self.creds), key)
.sign(PRESIGN_TTL);
let resp = self
.http
.put(url)
.header("content-type", content_type)
.body(bytes)
.send()
.await
.map_err(|e| StoreError(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
Err(StoreError(format!("put status {}", resp.status())))
}
}
fn public_url(&self, key: &str) -> String {
format!("{}/{}", self.public_base, key)
}
async fn delete(&self, key: &str) -> Result<(), StoreError> { async fn delete(&self, key: &str) -> Result<(), StoreError> {
let url = self let url = self
.bucket .bucket
@@ -92,6 +144,7 @@ impl ObjectStore for R2Store {
#[derive(Default)] #[derive(Default)]
pub struct InMemoryStore { pub struct InMemoryStore {
pub deleted: Mutex<Vec<String>>, pub deleted: Mutex<Vec<String>>,
pub objects: Mutex<HashMap<String, Vec<u8>>>,
} }
#[async_trait] #[async_trait]
@@ -100,6 +153,20 @@ impl ObjectStore for InMemoryStore {
Ok(format!("https://upload.test/{key}")) Ok(format!("https://upload.test/{key}"))
} }
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
_content_type: &str,
) -> Result<(), StoreError> {
self.objects.lock().unwrap().insert(key.to_string(), bytes);
Ok(())
}
fn public_url(&self, key: &str) -> String {
format!("https://assets.test/{key}")
}
async fn delete(&self, key: &str) -> Result<(), StoreError> { async fn delete(&self, key: &str) -> Result<(), StoreError> {
self.deleted.lock().unwrap().push(key.to_string()); self.deleted.lock().unwrap().push(key.to_string());
Ok(()) Ok(())
@@ -117,6 +184,7 @@ mod tests {
bucket: "cardclaws-assets".into(), bucket: "cardclaws-assets".into(),
access_key: "AKIA_TEST".into(), access_key: "AKIA_TEST".into(),
secret_key: "secret_test".into(), secret_key: "secret_test".into(),
public_base: String::new(),
}; };
let store = R2Store::new(&cfg).unwrap(); let store = R2Store::new(&cfg).unwrap();
let url = store.presign_put("assets/abc/logo.png").unwrap(); let url = store.presign_put("assets/abc/logo.png").unwrap();
@@ -124,5 +192,11 @@ mod tests {
assert!(url.contains("cardclaws-assets")); assert!(url.contains("cardclaws-assets"));
assert!(url.contains("logo.png")); assert!(url.contains("logo.png"));
assert!(url.contains("X-Amz-Signature")); assert!(url.contains("X-Amz-Signature"));
// Falls back to endpoint/bucket when no explicit public base is set.
assert_eq!(
store.public_url("welcome/x.png"),
"https://acct.r2.cloudflarestorage.com/cardclaws-assets/welcome/x.png"
);
} }
} }
@@ -2,6 +2,7 @@
//! mutating/owned-read path; non-owned access returns `NotFound` rather than //! mutating/owned-read path; non-owned access returns `NotFound` rather than
//! `Forbidden` so card existence is not leaked. //! `Forbidden` so card existence is not leaked.
use base64::Engine;
use cardclaws_db::models::card::CardRow; use cardclaws_db::models::card::CardRow;
use cardclaws_db::queries::{cards, users}; use cardclaws_db::queries::{cards, users};
use cardclaws_types::{AppError, Tier}; use cardclaws_types::{AppError, Tier};
@@ -43,10 +44,27 @@ pub async fn set_welcome(
}; };
let refined = state.ai.refine_prompt(&brief).await.map_err(ai_to_app)?; let refined = state.ai.refine_prompt(&brief).await.map_err(ai_to_app)?;
let img = state.ai.generate_image(&refined).await.map_err(ai_to_app)?; let img = state.ai.generate_image(&refined).await.map_err(ai_to_app)?;
// Upload the generated image to object storage (R2 in prod, MinIO in dev)
// and store its public URL — keeps the profile payload tiny.
let bytes = base64::engine::general_purpose::STANDARD
.decode(img.base64.as_bytes())
.map_err(|e| AppError::Internal(format!("decode image: {e}")))?;
let ext = if img.mime_type.contains("jpeg") {
"jpg"
} else {
"png"
};
let key = format!("welcome/{id}.{ext}");
state
.assets
.put_object(&key, bytes, &img.mime_type)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let welcome = serde_json::json!({ let welcome = serde_json::json!({
"kind": "image", "kind": "image",
"message": message.unwrap_or("Great to meet you"), "message": message.unwrap_or("Great to meet you"),
"imageDataUrl": format!("data:{};base64,{}", img.mime_type, img.base64), "imageUrl": state.assets.public_url(&key),
}); });
cards::update_welcome(&state.db, id, &welcome) cards::update_welcome(&state.db, id, &welcome)
.await .await
@@ -241,10 +241,10 @@ async fn welcome_generates_and_appears_on_public_profile() {
.await; .await;
assert_eq!(status, StatusCode::OK, "set welcome failed: {body}"); assert_eq!(status, StatusCode::OK, "set welcome failed: {body}");
assert_eq!(body["welcome"]["kind"], "image"); assert_eq!(body["welcome"]["kind"], "image");
assert!(body["welcome"]["imageDataUrl"] assert!(body["welcome"]["imageUrl"]
.as_str() .as_str()
.unwrap() .unwrap()
.starts_with("data:image/png;base64,")); .contains("welcome/"));
// Publish -> the public profile exposes the welcome. // Publish -> the public profile exposes the welcome.
app.request( app.request(
@@ -259,10 +259,10 @@ async fn welcome_generates_and_appears_on_public_profile() {
.await; .await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
assert_eq!(profile["welcome"]["message"], "Great to meet you, I'm Omar"); assert_eq!(profile["welcome"]["message"], "Great to meet you, I'm Omar");
assert!(profile["welcome"]["imageDataUrl"] assert!(profile["welcome"]["imageUrl"]
.as_str() .as_str()
.unwrap() .unwrap()
.starts_with("data:image/png")); .starts_with("http"));
} }
#[tokio::test] #[tokio::test]
@@ -82,6 +82,8 @@ pub struct R2Config {
pub bucket: String, pub bucket: String,
pub access_key: String, pub access_key: String,
pub secret_key: String, pub secret_key: String,
/// Public base URL objects are served from. Empty -> endpoint/bucket.
pub public_base: String,
} }
impl Config { impl Config {
@@ -116,6 +118,7 @@ impl Config {
bucket: optional(src, "R2_BUCKET", "cardclaws-assets").await, bucket: optional(src, "R2_BUCKET", "cardclaws-assets").await,
access_key: optional(src, "R2_ACCESS_KEY", "").await, access_key: optional(src, "R2_ACCESS_KEY", "").await,
secret_key: optional(src, "R2_SECRET_KEY", "").await, secret_key: optional(src, "R2_SECRET_KEY", "").await,
public_base: optional(src, "R2_PUBLIC_BASE", "").await,
}, },
wallet: WalletConfig { wallet: WalletConfig {
apple_pass_type_id: optional(src, "APPLE_PASS_TYPE_ID", "pass.com.cardclaws.card") apple_pass_type_id: optional(src, "APPLE_PASS_TYPE_ID", "pass.com.cardclaws.card")
+2 -2
View File
@@ -17,8 +17,8 @@ export interface PublicProfile {
export interface Welcome { export interface Welcome {
kind: "image"; kind: "image";
message: string; message: string;
/** `data:image/...;base64,...` (MVP) or an https URL later. */ /** Public URL of the AI welcome image (served from R2 / MinIO). */
imageDataUrl: string; imageUrl: string;
} }
// Loose mirror of the Rust CardDefinition — only the parts the profile reads. // Loose mirror of the Rust CardDefinition — only the parts the profile reads.
+1 -1
View File
@@ -43,7 +43,7 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
</head> </head>
<body> <body>
{welcome && ( {welcome && (
<WelcomeHero message={welcome.message} name={name} imageUrl={welcome.imageDataUrl} /> <WelcomeHero message={welcome.message} name={name} imageUrl={welcome.imageUrl} />
)} )}
<main> <main>
<CardHero <CardHero
+34
View File
@@ -26,5 +26,39 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
# S3-compatible object storage for local dev (drop-in for Cloudflare R2).
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # S3 API
- "9001:9001" # web console
volumes:
- miniodata:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
# One-shot: create the assets bucket and make it public-read so scanned
# profiles can fetch welcome images directly.
minio-init:
image: minio/mc
depends_on:
minio:
condition: service_started
entrypoint: >
/bin/sh -c "
until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
mc mb -p local/cardclaws-assets;
mc anonymous set download local/cardclaws-assets;
echo 'minio bucket ready';
"
volumes: volumes:
pgdata: pgdata:
miniodata: