1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum AssetBackendKind {
16 Local,
18 Gcs,
20 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 pub bucket: String,
42 pub client_prefix: String,
44 pub sim_prefix: String,
46 pub local_root: PathBuf,
48 pub s3_region: String,
50 pub s3_endpoint: Option<String>,
52 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 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 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#[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 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}