Skip to main content

flatland_client_lib/
asset_backend.rs

1//! Pluggable published-asset backends: local disk, GCS/Firebase, or S3-compatible.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6use async_trait::async_trait;
7use reqwest::header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
8
9use crate::assets::{
10    firebase_download_url, urlencoding_encode_path, DEFAULT_FIREBASE_BUCKET,
11};
12
13/// How published client/sim packs are stored and fetched.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum AssetBackendKind {
16    /// Files under `FLATLAND_ASSETS_LOCAL_ROOT` (single-machine / OSS laptop).
17    Local,
18    /// Google Cloud Storage / Firebase Storage JSON + media APIs.
19    Gcs,
20    /// S3-compatible object store (AWS, MinIO, R2) via path-style HTTPS.
21    S3,
22}
23
24impl AssetBackendKind {
25    pub fn parse(raw: &str) -> anyhow::Result<Self> {
26        match raw.trim().to_ascii_lowercase().as_str() {
27            "local" | "file" | "disk" => Ok(Self::Local),
28            "gcs" | "firebase" | "gs" => Ok(Self::Gcs),
29            "s3" | "minio" | "r2" => Ok(Self::S3),
30            other => anyhow::bail!(
31                "unknown FLATLAND_ASSETS_BACKEND={other:?} (expected local|gcs|s3)"
32            ),
33        }
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct AssetBackendConfig {
39    pub kind: AssetBackendKind,
40    /// GCS/Firebase or S3 bucket name.
41    pub bucket: String,
42    /// Object key prefix for **client** gfx packs (default `flatland3/client-assets`).
43    pub client_prefix: String,
44    /// Object key prefix for **sim** content packs (default `flatland3/sim-content`).
45    pub sim_prefix: String,
46    /// Local published root when `kind == Local`.
47    pub local_root: PathBuf,
48    /// S3 region (default `us-east-1`).
49    pub s3_region: String,
50    /// Optional S3 endpoint (MinIO/R2), e.g. `https://minio.example:9000`.
51    pub s3_endpoint: Option<String>,
52    /// Override for client `latest.json` fetch URL.
53    pub index_url_override: Option<String>,
54}
55
56impl Default for AssetBackendConfig {
57    fn default() -> Self {
58        Self::from_env()
59    }
60}
61
62impl AssetBackendConfig {
63    pub fn from_env() -> Self {
64        let kind = std::env::var("FLATLAND_ASSETS_BACKEND")
65            .ok()
66            .and_then(|s| AssetBackendKind::parse(&s).ok())
67            .unwrap_or(AssetBackendKind::Gcs);
68        let bucket = std::env::var("FLATLAND_ASSETS_BUCKET")
69            .unwrap_or_else(|_| DEFAULT_FIREBASE_BUCKET.to_string());
70        let client_prefix = std::env::var("FLATLAND_ASSETS_PREFIX")
71            .unwrap_or_else(|_| "flatland3/client-assets".to_string());
72        let sim_prefix = std::env::var("FLATLAND_SIM_ASSETS_PREFIX")
73            .unwrap_or_else(|_| "flatland3/sim-content".to_string());
74        let local_root = std::env::var("FLATLAND_ASSETS_LOCAL_ROOT")
75            .map(PathBuf::from)
76            .unwrap_or_else(|_| {
77                dirs::home_dir()
78                    .unwrap_or_else(|| PathBuf::from("."))
79                    .join(".flatland3")
80                    .join("published")
81            });
82        let s3_region =
83            std::env::var("FLATLAND_ASSETS_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
84        let s3_endpoint = std::env::var("FLATLAND_ASSETS_S3_ENDPOINT")
85            .ok()
86            .filter(|s| !s.is_empty());
87        let index_url_override = std::env::var("FLATLAND_ASSETS_INDEX_URL")
88            .ok()
89            .filter(|s| !s.is_empty());
90        Self {
91            kind,
92            bucket,
93            client_prefix,
94            sim_prefix,
95            local_root,
96            s3_region,
97            s3_endpoint,
98            index_url_override,
99        }
100    }
101
102    pub fn client_index_object(&self) -> String {
103        format!("{}/latest.json", self.client_prefix.trim_end_matches('/'))
104    }
105
106    pub fn sim_index_object(&self) -> String {
107        format!("{}/latest.json", self.sim_prefix.trim_end_matches('/'))
108    }
109
110    /// HTTPS (or `file://`) URL for the client `latest.json` index.
111    pub fn client_index_url(&self) -> String {
112        if let Some(url) = &self.index_url_override {
113            return url.clone();
114        }
115        self.object_url(&self.client_index_object())
116    }
117
118    pub fn sim_index_url(&self) -> String {
119        self.object_url(&self.sim_index_object())
120    }
121
122    pub fn client_object_key(&self, publish_rev: u64, relative: &str) -> String {
123        format!(
124            "{}/rev-{}/{}",
125            self.client_prefix.trim_end_matches('/'),
126            publish_rev,
127            relative
128        )
129    }
130
131    pub fn sim_object_key(&self, publish_rev: u64, relative: &str) -> String {
132        format!(
133            "{}/rev-{}/{}",
134            self.sim_prefix.trim_end_matches('/'),
135            publish_rev,
136            relative
137        )
138    }
139
140    /// Public fetch URL for an object key.
141    pub fn object_url(&self, object_key: &str) -> String {
142        match self.kind {
143            AssetBackendKind::Local => {
144                let path = self.local_root.join(object_key);
145                format!("file://{}", path.display())
146            }
147            AssetBackendKind::Gcs => firebase_download_url(&self.bucket, object_key),
148            AssetBackendKind::S3 => self.s3_object_url(object_key),
149        }
150    }
151
152    fn s3_object_url(&self, object_key: &str) -> String {
153        let key = object_key.trim_start_matches('/');
154        if let Some(endpoint) = &self.s3_endpoint {
155            let base = endpoint.trim_end_matches('/');
156            format!("{base}/{}/{key}", self.bucket)
157        } else {
158            format!(
159                "https://{}.s3.{}.amazonaws.com/{key}",
160                self.bucket, self.s3_region
161            )
162        }
163    }
164
165    pub fn local_object_path(&self, object_key: &str) -> PathBuf {
166        self.local_root.join(object_key)
167    }
168}
169
170/// Put/get published bytes (admin upload + worker/client download).
171#[async_trait]
172pub trait AssetStore: Send + Sync {
173    async fn put(
174        &self,
175        object_key: &str,
176        content_type: &str,
177        bytes: &[u8],
178    ) -> anyhow::Result<()>;
179
180    async fn get(&self, object_key: &str) -> anyhow::Result<Vec<u8>>;
181}
182
183pub fn store_from_config(cfg: &AssetBackendConfig) -> anyhow::Result<Box<dyn AssetStore>> {
184    match cfg.kind {
185        AssetBackendKind::Local => Ok(Box::new(LocalAssetStore {
186            root: cfg.local_root.clone(),
187        })),
188        AssetBackendKind::Gcs => Ok(Box::new(GcsAssetStore {
189            bucket: cfg.bucket.clone(),
190            client: reqwest::Client::new(),
191        })),
192        AssetBackendKind::S3 => Ok(Box::new(S3AssetStore {
193            bucket: cfg.bucket.clone(),
194            region: cfg.s3_region.clone(),
195            endpoint: cfg.s3_endpoint.clone(),
196            client: reqwest::Client::new(),
197        })),
198    }
199}
200
201pub struct LocalAssetStore {
202    pub root: PathBuf,
203}
204
205#[async_trait]
206impl AssetStore for LocalAssetStore {
207    async fn put(
208        &self,
209        object_key: &str,
210        _content_type: &str,
211        bytes: &[u8],
212    ) -> anyhow::Result<()> {
213        let path = self.root.join(object_key);
214        if let Some(parent) = path.parent() {
215            std::fs::create_dir_all(parent)
216                .with_context(|| format!("mkdir {}", parent.display()))?;
217        }
218        std::fs::write(&path, bytes).with_context(|| format!("write {}", path.display()))?;
219        Ok(())
220    }
221
222    async fn get(&self, object_key: &str) -> anyhow::Result<Vec<u8>> {
223        let path = self.root.join(object_key);
224        std::fs::read(&path).with_context(|| format!("read {}", path.display()))
225    }
226}
227
228pub struct GcsAssetStore {
229    pub bucket: String,
230    pub client: reqwest::Client,
231}
232
233#[async_trait]
234impl AssetStore for GcsAssetStore {
235    async fn put(
236        &self,
237        object_key: &str,
238        content_type: &str,
239        bytes: &[u8],
240    ) -> anyhow::Result<()> {
241        let token = gcs_upload_bearer_token().await?;
242        let url = format!(
243            "https://storage.googleapis.com/upload/storage/v1/b/{}/o?uploadType=media&name={}",
244            self.bucket,
245            urlencoding_encode_path(object_key)
246        );
247        let response = self
248            .client
249            .post(&url)
250            .header(AUTHORIZATION, format!("Bearer {token}"))
251            .header(CONTENT_TYPE, content_type)
252            .header(CONTENT_LENGTH, bytes.len())
253            .body(bytes.to_vec())
254            .send()
255            .await?;
256        if !response.status().is_success() {
257            let status = response.status();
258            let body = response.text().await.unwrap_or_default();
259            anyhow::bail!("GCS upload {object_key} failed: {status} {body}");
260        }
261        Ok(())
262    }
263
264    async fn get(&self, object_key: &str) -> anyhow::Result<Vec<u8>> {
265        let url = firebase_download_url(&self.bucket, object_key);
266        let response = self.client.get(&url).send().await?.error_for_status()?;
267        Ok(response.bytes().await?.to_vec())
268    }
269}
270
271pub struct S3AssetStore {
272    pub bucket: String,
273    pub region: String,
274    pub endpoint: Option<String>,
275    pub client: reqwest::Client,
276}
277
278#[async_trait]
279impl AssetStore for S3AssetStore {
280    async fn put(
281        &self,
282        object_key: &str,
283        content_type: &str,
284        bytes: &[u8],
285    ) -> anyhow::Result<()> {
286        // Prefer AWS CLI when present — avoids embedding SigV4 in the published client crate.
287        if which_aws_cli() {
288            return s3_cli_put(
289                &self.bucket,
290                object_key,
291                content_type,
292                bytes,
293                self.endpoint.as_deref(),
294                &self.region,
295            )
296            .await;
297        }
298        anyhow::bail!(
299            "S3 upload requires the `aws` CLI (aws s3 cp) or set FLATLAND_ASSETS_BACKEND=gcs|local"
300        )
301    }
302
303    async fn get(&self, object_key: &str) -> anyhow::Result<Vec<u8>> {
304        let url = if let Some(endpoint) = &self.endpoint {
305            format!(
306                "{}/{}/{}",
307                endpoint.trim_end_matches('/'),
308                self.bucket,
309                object_key
310            )
311        } else {
312            format!(
313                "https://{}.s3.{}.amazonaws.com/{}",
314                self.bucket, self.region, object_key
315            )
316        };
317        let response = self.client.get(&url).send().await?.error_for_status()?;
318        Ok(response.bytes().await?.to_vec())
319    }
320}
321
322fn which_aws_cli() -> bool {
323    std::process::Command::new("aws")
324        .arg("--version")
325        .output()
326        .map(|o| o.status.success())
327        .unwrap_or(false)
328}
329
330async fn s3_cli_put(
331    bucket: &str,
332    object_key: &str,
333    content_type: &str,
334    bytes: &[u8],
335    endpoint: Option<&str>,
336    region: &str,
337) -> anyhow::Result<()> {
338    let tmp = tempfile_path(object_key)?;
339    if let Some(parent) = tmp.parent() {
340        std::fs::create_dir_all(parent)?;
341    }
342    std::fs::write(&tmp, bytes)?;
343    let uri = format!("s3://{bucket}/{object_key}");
344    let mut cmd = tokio::process::Command::new("aws");
345    cmd.args(["s3", "cp", tmp.to_str().unwrap(), &uri]);
346    cmd.args(["--content-type", content_type]);
347    cmd.args(["--region", region]);
348    if let Some(ep) = endpoint {
349        cmd.args(["--endpoint-url", ep]);
350    }
351    let output = cmd.output().await?;
352    let _ = std::fs::remove_file(&tmp);
353    if !output.status.success() {
354        anyhow::bail!(
355            "aws s3 cp failed: {}",
356            String::from_utf8_lossy(&output.stderr)
357        );
358    }
359    Ok(())
360}
361
362fn tempfile_path(object_key: &str) -> anyhow::Result<PathBuf> {
363    let name = object_key.replace('/', "_");
364    let dir = std::env::temp_dir().join("flatland-asset-upload");
365    std::fs::create_dir_all(&dir)?;
366    Ok(dir.join(name))
367}
368
369async fn gcs_upload_bearer_token() -> anyhow::Result<String> {
370    if let Ok(token) = std::env::var("FLATLAND_ASSETS_UPLOAD_TOKEN") {
371        if !token.is_empty() {
372            return Ok(token);
373        }
374    }
375    let output = tokio::process::Command::new("gcloud")
376        .args(["auth", "application-default", "print-access-token"])
377        .output()
378        .await
379        .map_err(|err| {
380            anyhow::anyhow!(
381                "gcloud ADC token failed ({err}); set FLATLAND_ASSETS_UPLOAD_TOKEN or run gcloud auth application-default login"
382            )
383        })?;
384    if !output.status.success() {
385        anyhow::bail!(
386            "gcloud auth application-default print-access-token failed: {}",
387            String::from_utf8_lossy(&output.stderr)
388        );
389    }
390    Ok(String::from_utf8(output.stdout)?.trim().to_string())
391}
392
393pub fn mime_for_path(path: &Path) -> &'static str {
394    match path
395        .extension()
396        .and_then(|e| e.to_str())
397        .unwrap_or("")
398        .to_ascii_lowercase()
399        .as_str()
400    {
401        "png" => "image/png",
402        "jpg" | "jpeg" => "image/jpeg",
403        "webp" => "image/webp",
404        "json" => "application/json",
405        "yaml" | "yml" => "application/x-yaml",
406        _ => "application/octet-stream",
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn parse_backend_kinds() {
416        assert_eq!(
417            AssetBackendKind::parse("firebase").unwrap(),
418            AssetBackendKind::Gcs
419        );
420        assert_eq!(
421            AssetBackendKind::parse("local").unwrap(),
422            AssetBackendKind::Local
423        );
424        assert_eq!(
425            AssetBackendKind::parse("minio").unwrap(),
426            AssetBackendKind::S3
427        );
428    }
429
430    #[test]
431    fn gcs_object_urls() {
432        let cfg = AssetBackendConfig {
433            kind: AssetBackendKind::Gcs,
434            bucket: "flatland-8911e.appspot.com".into(),
435            client_prefix: "flatland3/client-assets".into(),
436            sim_prefix: "flatland3/sim-content".into(),
437            local_root: PathBuf::from("/tmp"),
438            s3_region: "us-east-1".into(),
439            s3_endpoint: None,
440            index_url_override: None,
441        };
442        let url = cfg.client_index_url();
443        assert!(url.contains("flatland-8911e.appspot.com"));
444        assert!(url.contains("latest.json"));
445        assert_eq!(
446            cfg.client_index_object(),
447            "flatland3/client-assets/latest.json"
448        );
449    }
450}