feat: in-game theme store — server catalog + verified downloads + install UI
Test / test (pull_request) Successful in 10m15s

Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).

- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
  (additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
  theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
  public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
  compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
  mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
  Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
  download on AsyncComputeTaskPool, atomic .tmp+rename write into
  user_theme_dir, then the existing hardened import_theme pipeline and
  an in-place registry refresh

New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-07 13:12:41 -07:00
parent 018b69285d
commit d87397b382
23 changed files with 1718 additions and 3 deletions
+366
View File
@@ -0,0 +1,366 @@
//! 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 `<id>.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<ThemeCatalogEntry>,
zip_paths: HashMap<String, PathBuf>,
preview_paths: HashMap<String, PathBuf>,
}
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<PathBuf> = 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<AppState>) -> Json<ThemeCatalogResponse> {
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<AppState>,
UrlPath(id): UrlPath<String>,
) -> Result<Response, AppError> {
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<AppState>,
UrlPath(id): UrlPath<String>,
) -> Result<Response, AppError> {
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");
}
}