d87397b382
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>
367 lines
13 KiB
Rust
367 lines
13 KiB
Rust
//! 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");
|
|
}
|
|
}
|