//! Theme-store catalog and file-serving endpoints. //! //! The store is filesystem-driven: the operator drops theme `.zip` //! archives (the same format the engine's theme importer consumes) //! into the directory named by the `THEME_STORE_DIR` environment //! variable (default `theme_store/`), plus an optional `.png` //! preview per theme. [`ThemeStore::scan`] runs once at startup — //! adding a theme means dropping the files and restarting the server. //! //! Endpoints (all public, no auth — the catalog is free): //! //! - `GET /api/themes` — [`ThemeCatalogResponse`] JSON //! - `GET /api/themes/{id}/download` — the theme `.zip` //! - `GET /api/themes/{id}/preview` — the preview PNG (404 when absent) use std::collections::HashMap; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; use axum::Json; use axum::extract::{Path as UrlPath, State}; use axum::http::header; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use sha2::{Digest, Sha256}; use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse}; use tracing::{info, warn}; use crate::AppState; use crate::error::AppError; /// Environment variable naming the theme-store directory. pub const THEME_STORE_DIR_ENV: &str = "THEME_STORE_DIR"; /// Default theme-store directory, relative to the server working dir — /// same convention as the `avatars/` upload directory. pub const DEFAULT_THEME_STORE_DIR: &str = "theme_store"; /// Archives larger than this are skipped at scan time with a warning. /// Mirrors the engine importer's `MAX_ARCHIVE_BYTES` (20 MiB) — a zip /// whose *compressed* size already exceeds the client's uncompressed /// limit can never import, so serving it only wastes bandwidth. pub const MAX_STORE_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024; /// Upper bound on the bytes read out of an archive's `theme.ron` /// entry at scan time, guarding the catalog scan against a /// zip-bombed manifest. Real manifests are a few KB. const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; /// `theme.ron`'s outer shape, `meta` block only — the 53 face entries /// are irrelevant to the catalog and are validated client-side by the /// importer at install time. #[derive(Debug, Deserialize)] struct ManifestMetaOnly { meta: StoreThemeMeta, } /// Mirror of the `meta` block of a theme manifest. The canonical /// definition is `solitaire_engine::theme::ThemeMeta`; the server /// cannot depend on the engine crate (it links Bevy), so the shape is /// re-declared here. Keep the two in lockstep. #[derive(Debug, Deserialize)] struct StoreThemeMeta { id: String, name: String, author: String, version: String, card_aspect: (u32, u32), } /// In-memory catalog built by scanning the theme-store directory once /// at startup. Shared via [`AppState`]. #[derive(Debug, Default)] pub struct ThemeStore { entries: Vec, zip_paths: HashMap, preview_paths: HashMap, } impl ThemeStore { /// An empty store — used by tests and when the store directory is /// absent (the server runs fine without a theme store). pub fn empty() -> Self { Self::default() } /// Scans `dir` for `*.zip` theme archives and builds the catalog. /// /// Best-effort per archive: unreadable files, oversized archives, /// missing/malformed manifests, and duplicate ids are skipped with /// a warning rather than failing startup — one bad upload must not /// take the store (or the server) down. pub fn scan(dir: &Path) -> Self { let mut store = Self::default(); let read_dir = match std::fs::read_dir(dir) { Ok(read_dir) => read_dir, Err(e) => { info!( "theme store: directory {} not readable ({e}); serving an empty catalog", dir.display() ); return store; } }; let mut zips: Vec = read_dir .flatten() .map(|entry| entry.path()) .filter(|path| path.extension().is_some_and(|ext| ext == "zip")) .collect(); // Deterministic catalog regardless of directory iteration order. zips.sort(); for zip_path in zips { match scan_archive(&zip_path) { Ok((meta, size_bytes, sha256)) => { if store.zip_paths.contains_key(&meta.id) { warn!( "theme store: skipping {} — duplicate theme id {:?}", zip_path.display(), meta.id ); continue; } let preview_path = dir.join(format!("{}.png", meta.id)); let preview_url = if preview_path.is_file() { store.preview_paths.insert(meta.id.clone(), preview_path); Some(format!("/api/themes/{}/preview", meta.id)) } else { None }; store.entries.push(ThemeCatalogEntry { download_url: format!("/api/themes/{}/download", meta.id), preview_url, id: meta.id.clone(), name: meta.name, author: meta.author, version: meta.version, card_aspect: meta.card_aspect, size_bytes, sha256, }); store.zip_paths.insert(meta.id, zip_path); } Err(reason) => { warn!("theme store: skipping {} — {reason}", zip_path.display()); } } } store.entries.sort_by(|a, b| a.name.cmp(&b.name)); info!("theme store: serving {} theme(s)", store.entries.len()); store } /// Catalog entries, sorted by display name. pub fn entries(&self) -> &[ThemeCatalogEntry] { &self.entries } } /// Reads one archive: enforces the size cap, hashes the bytes, and /// extracts + validates the manifest's `meta` block. fn scan_archive(zip_path: &Path) -> Result<(StoreThemeMeta, u64, String), String> { let size_bytes = std::fs::metadata(zip_path) .map_err(|e| format!("cannot stat: {e}"))? .len(); if size_bytes > MAX_STORE_ARCHIVE_BYTES { return Err(format!( "{size_bytes} bytes exceeds the {MAX_STORE_ARCHIVE_BYTES}-byte store limit" )); } let bytes = std::fs::read(zip_path).map_err(|e| format!("cannot read: {e}"))?; let sha256 = hex_digest(&bytes); let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip archive: {e}"))?; let manifest_bytes = { let entry = archive .by_name("theme.ron") .map_err(|e| format!("no theme.ron in archive: {e}"))?; let mut buf = Vec::new(); entry .take(MAX_MANIFEST_BYTES) .read_to_end(&mut buf) .map_err(|e| format!("cannot read theme.ron: {e}"))?; buf }; let parsed: ManifestMetaOnly = ron::de::from_bytes(&manifest_bytes).map_err(|e| format!("malformed theme.ron: {e}"))?; let meta = parsed.meta; if meta.id.is_empty() { return Err("theme id is empty".to_string()); } if meta.id.contains('/') || meta.id.contains('\\') || meta.id.contains("..") { return Err(format!("theme id {:?} is not filesystem-safe", meta.id)); } if meta.card_aspect.0 == 0 || meta.card_aspect.1 == 0 { return Err("card_aspect contains a zero".to_string()); } Ok((meta, size_bytes, sha256)) } /// Lowercase-hex SHA-256 of `bytes`. fn hex_digest(bytes: &[u8]) -> String { let digest = Sha256::digest(bytes); let mut out = String::with_capacity(digest.len() * 2); for byte in digest { out.push_str(&format!("{byte:02x}")); } out } /// `GET /api/themes` — the full catalog. pub async fn list(State(state): State) -> Json { Json(ThemeCatalogResponse { themes: state.theme_store.entries().to_vec(), }) } /// `GET /api/themes/{id}/download` — the theme archive bytes. pub async fn download( State(state): State, UrlPath(id): UrlPath, ) -> Result { let path = state .theme_store .zip_paths .get(&id) .ok_or_else(|| AppError::NotFound("theme not found".into()))?; let bytes = tokio::fs::read(path) .await .map_err(|e| AppError::Internal(format!("theme archive unreadable: {e}")))?; Ok(( [ (header::CONTENT_TYPE, "application/zip".to_string()), ( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{id}.zip\""), ), ], bytes, ) .into_response()) } /// `GET /api/themes/{id}/preview` — the preview PNG, when the store /// ships one for this theme. pub async fn preview( State(state): State, UrlPath(id): UrlPath, ) -> Result { let path = state .theme_store .preview_paths .get(&id) .ok_or_else(|| AppError::NotFound("theme preview not found".into()))?; let bytes = tokio::fs::read(path) .await .map_err(|e| AppError::Internal(format!("theme preview unreadable: {e}")))?; Ok(([(header::CONTENT_TYPE, "image/png".to_string())], bytes).into_response()) } #[cfg(test)] mod tests { use super::*; use std::io::Write; /// Minimal valid manifest for id/name pairs — the scan only reads /// the `meta` block, so no face entries are needed. fn manifest(id: &str, name: &str) -> String { format!( r#"( meta: ( id: "{id}", name: "{name}", author: "Test Author", version: "1.0.0", card_aspect: (2, 3), ), faces: {{}}, back: "back.svg", )"# ) } fn write_zip(dir: &Path, file_name: &str, manifest: &str) -> PathBuf { let path = dir.join(file_name); let file = std::fs::File::create(&path).expect("create zip"); let mut writer = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default(); writer.start_file("theme.ron", options).expect("start"); writer.write_all(manifest.as_bytes()).expect("write"); writer.finish().expect("finish"); path } #[test] fn scan_of_missing_dir_yields_empty_catalog() { let store = ThemeStore::scan(Path::new("/nonexistent/theme/store")); assert!(store.entries().is_empty()); } #[test] fn scan_builds_sorted_catalog_with_hashes() { let dir = tempfile::tempdir().expect("tempdir"); let zebra = write_zip(dir.path(), "b.zip", &manifest("zebra", "Zebra")); write_zip(dir.path(), "a.zip", &manifest("aurora", "Aurora")); let store = ThemeStore::scan(dir.path()); let entries = store.entries(); assert_eq!(entries.len(), 2); // Sorted by display name, not filename. assert_eq!(entries[0].id, "aurora"); assert_eq!(entries[1].id, "zebra"); assert_eq!(entries[1].download_url, "/api/themes/zebra/download"); assert_eq!(entries[1].preview_url, None); let zebra_bytes = std::fs::read(&zebra).expect("read zip"); assert_eq!(entries[1].sha256, hex_digest(&zebra_bytes)); assert_eq!(entries[1].size_bytes, zebra_bytes.len() as u64); } #[test] fn scan_picks_up_preview_png_keyed_by_theme_id() { let dir = tempfile::tempdir().expect("tempdir"); write_zip(dir.path(), "neon.zip", &manifest("neon", "Neon")); std::fs::write(dir.path().join("neon.png"), b"png-bytes").expect("write png"); let store = ThemeStore::scan(dir.path()); assert_eq!( store.entries()[0].preview_url.as_deref(), Some("/api/themes/neon/preview") ); assert!(store.preview_paths.contains_key("neon")); } #[test] fn scan_skips_duplicate_ids_and_keeps_first() { let dir = tempfile::tempdir().expect("tempdir"); write_zip(dir.path(), "1_first.zip", &manifest("dupe", "First")); write_zip(dir.path(), "2_second.zip", &manifest("dupe", "Second")); let store = ThemeStore::scan(dir.path()); assert_eq!(store.entries().len(), 1); assert_eq!(store.entries()[0].name, "First"); } #[test] fn scan_skips_malformed_and_unsafe_manifests() { let dir = tempfile::tempdir().expect("tempdir"); // Not a zip at all. std::fs::write(dir.path().join("junk.zip"), b"not a zip").expect("write"); // Path-separator id must be rejected — it would become a URL // path segment and a client install directory name. write_zip(dir.path(), "evil.zip", &manifest("../evil", "Evil")); // Valid one to prove the scan carries on past failures. write_zip(dir.path(), "ok.zip", &manifest("ok", "Ok")); let store = ThemeStore::scan(dir.path()); assert_eq!(store.entries().len(), 1); assert_eq!(store.entries()[0].id, "ok"); } }