Store AI welcome image in object storage (MinIO/R2) instead of inlining
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:
co-authored by
Claude Opus 4.8
parent
256f2285b9
commit
63fdc4913b
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user