diff --git a/Cargo.lock b/Cargo.lock index dc28d31..a3d27f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7351,13 +7351,16 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "solitaire_core", "solitaire_server", "solitaire_sync", "sqlx", + "tempfile", "thiserror 2.0.18", "tokio", "uuid", + "zip", ] [[package]] @@ -7402,10 +7405,13 @@ dependencies = [ "chrono", "dotenvy", "jsonwebtoken", + "ron", "serde", "serde_json", + "sha2", "solitaire_sync", "sqlx", + "tempfile", "thiserror 2.0.18", "tokio", "tower", @@ -7414,6 +7420,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "zip", ] [[package]] @@ -7422,6 +7429,7 @@ version = "0.1.0" dependencies = [ "chrono", "serde", + "serde_json", "thiserror 2.0.18", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 2a7f490..04d79ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ dirs = "6" keyring = "4" keyring-core = "1" reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false } +sha2 = "0.10" arboard = { version = "3", default-features = false } jni = { version = "0.21", default-features = false } diff --git a/README_SERVER.md b/README_SERVER.md index caa0e44..61bcdb2 100644 --- a/README_SERVER.md +++ b/README_SERVER.md @@ -44,6 +44,26 @@ docker compose up -d ``` +## Theme store + +The server can offer card-art themes for in-game download. Drop theme +`.zip` archives (the same format the game's Settings → Import accepts: +a `theme.ron` manifest plus 53 SVGs) into the directory named by +`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then +restart the server — the catalog is scanned once at startup. An +optional `.png` in the same directory becomes the theme's +store preview. + +Endpoints (public, no auth): + +- `GET /api/themes` — catalog JSON (id, name, author, size, sha256) +- `GET /api/themes//download` — the archive +- `GET /api/themes//preview` — the preview PNG, if present + +Archives that are oversized (> 20 MiB), unreadable, or have a +malformed `theme.ron` are skipped with a warning in the server log; +they never fail startup. + ## Admin — Password Reset If a player loses access to their account, the server binary includes a diff --git a/docker-compose.yml b/docker-compose.yml index b403360..a26f170 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,13 @@ services: # Override DATABASE_URL so the DB always lands in the persistent volume, # regardless of what .env contains. DATABASE_URL: sqlite:///data/solitaire.db + # Theme-store catalog directory (scanned once at startup; the + # host ./theme_store folder is where the operator drops theme + # zips + preview PNGs). + THEME_STORE_DIR: /theme_store volumes: - ./data:/data + - ./theme_store:/theme_store:ro restart: unless-stopped expose: - "${SERVER_PORT:-8080}" diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index f1836dc..0400e91 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -24,6 +24,9 @@ uuid = { workspace = true } dirs = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } +# Theme-store downloads are verified against the catalog's SHA-256 +# before the archive is handed to the engine's theme importer. +sha2 = { workspace = true } # `keyring-core` is the typed Entry/Error API used by # `auth_tokens`. The crate's own dependency tree pulls in @@ -47,6 +50,10 @@ sqlx = { workspace = true } jsonwebtoken = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } +# theme_store_round_trip builds a theme zip in a tempdir for the +# in-process store server. +zip = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index ee1fad3..b5f1b94 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -161,6 +161,11 @@ pub use sync_client::LocalOnlyProvider; #[cfg(not(target_arch = "wasm32"))] pub use sync_client::{SolitaireServerClient, provider_for_backend}; +#[cfg(not(target_arch = "wasm32"))] +pub mod theme_store_client; +#[cfg(not(target_arch = "wasm32"))] +pub use theme_store_client::{ThemeStoreClient, ThemeStoreError}; + pub mod replay; pub use replay::{ REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay, diff --git a/solitaire_data/src/theme_store_client.rs b/solitaire_data/src/theme_store_client.rs new file mode 100644 index 0000000..a84e15d --- /dev/null +++ b/solitaire_data/src/theme_store_client.rs @@ -0,0 +1,200 @@ +//! HTTP client for the server's theme-store endpoints. +//! +//! Fetches the catalog (`GET /api/themes`) and downloads theme +//! archives, verifying each download's size and SHA-256 against the +//! catalog entry before returning the bytes. The caller (the engine's +//! theme-store UI) hands verified bytes to the theme importer, which +//! independently re-validates the archive's structure — the store +//! pipeline never trusts a byte the importer hasn't checked. +//! +//! Native-only: gated out on wasm32 alongside the other `reqwest` +//! consumers in this crate. + +use sha2::{Digest, Sha256}; +use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse}; +use thiserror::Error; + +/// Hard cap on a theme archive download, matching the engine +/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import, +/// so downloading it is pure waste. +pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024; + +/// Errors surfaced by [`ThemeStoreClient`]. +#[derive(Debug, Error)] +pub enum ThemeStoreError { + /// The request could not be sent or the response body not read. + #[error("network error: {0}")] + Network(String), + /// The server answered with a non-success status code. + #[error("server returned HTTP {0}")] + Http(u16), + /// The catalog JSON did not parse. + #[error("malformed catalog: {0}")] + MalformedCatalog(String), + /// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its + /// catalog-declared size. + #[error("archive size {got} exceeds the expected {expected} bytes")] + Oversized { expected: u64, got: u64 }, + /// The downloaded bytes do not hash to the catalog's SHA-256 — + /// the file changed on the server or was corrupted in transit. + #[error("archive checksum mismatch (expected {expected}, got {got})")] + ChecksumMismatch { expected: String, got: String }, +} + +/// Client for one server's theme store. +/// +/// Unauthenticated: the catalog and downloads are public endpoints, +/// so unlike `SolitaireServerClient` there is no token handling. +pub struct ThemeStoreClient { + /// Base URL of the server, trailing slash stripped. + base_url: String, + client: reqwest::Client, +} + +impl ThemeStoreClient { + /// Construct a client for the server at `base_url` + /// (e.g. `"https://solitaire.example.com"`). + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + client: reqwest::Client::new(), + } + } + + /// Fetch the theme catalog, sorted by display name (server-side). + pub async fn fetch_catalog(&self) -> Result, ThemeStoreError> { + let resp = self + .client + .get(format!("{}/api/themes", self.base_url)) + .send() + .await + .map_err(|e| ThemeStoreError::Network(e.to_string()))?; + if !resp.status().is_success() { + return Err(ThemeStoreError::Http(resp.status().as_u16())); + } + let catalog: ThemeCatalogResponse = resp + .json() + .await + .map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?; + Ok(catalog.themes) + } + + /// Download `entry`'s archive and verify it against the catalog's + /// size and SHA-256. Returns the verified `.zip` bytes, ready for + /// the engine's theme importer. + pub async fn download_theme( + &self, + entry: &ThemeCatalogEntry, + ) -> Result, ThemeStoreError> { + // The catalog itself could name an absurd size; refuse before + // buffering anything. + if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES { + return Err(ThemeStoreError::Oversized { + expected: MAX_THEME_DOWNLOAD_BYTES, + got: entry.size_bytes, + }); + } + let resp = self + .client + .get(format!("{}{}", self.base_url, entry.download_url)) + .send() + .await + .map_err(|e| ThemeStoreError::Network(e.to_string()))?; + if !resp.status().is_success() { + return Err(ThemeStoreError::Http(resp.status().as_u16())); + } + let bytes = resp + .bytes() + .await + .map_err(|e| ThemeStoreError::Network(e.to_string()))?; + verify_archive(&bytes, entry)?; + Ok(bytes.to_vec()) + } +} + +/// Checks downloaded `bytes` against the catalog `entry`'s declared +/// size and SHA-256. Pure so it can be unit-tested without a server. +fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> { + if bytes.len() as u64 != entry.size_bytes { + return Err(ThemeStoreError::Oversized { + expected: entry.size_bytes, + got: bytes.len() as u64, + }); + } + let got = hex_digest(bytes); + // Case-insensitive: the server emits lowercase hex, but a + // hand-authored catalog mirror shouldn't fail on case alone. + if !got.eq_ignore_ascii_case(&entry.sha256) { + return Err(ThemeStoreError::ChecksumMismatch { + expected: entry.sha256.clone(), + got, + }); + } + Ok(()) +} + +/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest +/// encoding in `solitaire_server::theme_store`. +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 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry { + ThemeCatalogEntry { + id: "neon".into(), + name: "Neon".into(), + author: "Test".into(), + version: "1.0.0".into(), + card_aspect: (2, 3), + size_bytes: bytes.len() as u64, + sha256: hex_digest(bytes), + download_url: "/api/themes/neon/download".into(), + preview_url: None, + } + } + + #[test] + fn verify_accepts_matching_bytes() { + let bytes = b"theme archive bytes"; + assert!(verify_archive(bytes, &entry_for(bytes)).is_ok()); + } + + #[test] + fn verify_accepts_uppercase_catalog_hash() { + let bytes = b"theme archive bytes"; + let mut entry = entry_for(bytes); + entry.sha256 = entry.sha256.to_uppercase(); + assert!(verify_archive(bytes, &entry).is_ok()); + } + + #[test] + fn verify_rejects_size_mismatch() { + let bytes = b"theme archive bytes"; + let mut entry = entry_for(bytes); + entry.size_bytes += 1; + assert!(matches!( + verify_archive(bytes, &entry), + Err(ThemeStoreError::Oversized { .. }) + )); + } + + #[test] + fn verify_rejects_checksum_mismatch() { + let bytes = b"theme archive bytes"; + let mut entry = entry_for(bytes); + entry.sha256 = "0".repeat(64); + assert!(matches!( + verify_archive(bytes, &entry), + Err(ThemeStoreError::ChecksumMismatch { .. }) + )); + } +} diff --git a/solitaire_data/tests/theme_store_round_trip.rs b/solitaire_data/tests/theme_store_round_trip.rs new file mode 100644 index 0000000..dbb72de --- /dev/null +++ b/solitaire_data/tests/theme_store_round_trip.rs @@ -0,0 +1,118 @@ +//! End-to-end theme-store test: a real `solitaire_server` router +//! serving a scanned catalog over a localhost TCP socket, consumed by +//! [`solitaire_data::ThemeStoreClient`]. +//! +//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0, +//! router in a background `tokio::spawn`, no explicit shutdown. + +#![cfg(not(target_arch = "wasm32"))] + +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use solitaire_data::ThemeStoreClient; +use solitaire_server::theme_store::ThemeStore; + +/// Minimal theme archive: the catalog scan only reads the `meta` +/// block, so no face SVGs are needed. +fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf { + let manifest = format!( + r#"( + meta: ( + id: "{id}", + name: "{name}", + author: "Round Trip", + version: "1.0.0", + card_aspect: (2, 3), + ), + faces: {{}}, + back: "back.svg", +)"# + ); + 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_file"); + writer + .write_all(manifest.as_bytes()) + .expect("write manifest"); + writer.finish().expect("finish zip"); + path +} + +/// Spawn the test server with a theme store scanned from `store_dir` +/// and return its base URL. +async fn spawn_store_server(store_dir: &Path) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test listener"); + let addr = listener.local_addr().expect("listener has no local addr"); + + let pool = solitaire_server::build_test_pool().await; + let app = + solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir)); + + tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app).await { + eprintln!("test server crashed: {e}"); + } + }); + + format!("http://{addr}") +} + +/// Catalog fetch → download → verified bytes match the file the +/// server scanned. This is the exact path the engine's store UI runs. +#[tokio::test] +async fn catalog_fetch_and_verified_download_round_trip() { + let dir = tempfile::tempdir().expect("tempdir"); + let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon"); + let base_url = spawn_store_server(dir.path()).await; + + let client = ThemeStoreClient::new(&base_url); + let catalog = client.fetch_catalog().await.expect("fetch catalog"); + assert_eq!(catalog.len(), 1); + let entry = &catalog[0]; + assert_eq!(entry.id, "neon"); + + let bytes = client.download_theme(entry).await.expect("download"); + assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip")); +} + +/// A catalog entry whose hash no longer matches the served file must +/// be rejected client-side — the importer never sees unverified bytes. +#[tokio::test] +async fn download_with_stale_catalog_hash_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + write_store_zip(dir.path(), "neon.zip", "neon", "Neon"); + let base_url = spawn_store_server(dir.path()).await; + + let client = ThemeStoreClient::new(&base_url); + let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone(); + entry.sha256 = "0".repeat(64); + + let err = client + .download_theme(&entry) + .await + .expect_err("mismatched hash must fail"); + assert!( + matches!( + err, + solitaire_data::ThemeStoreError::ChecksumMismatch { .. } + ), + "expected ChecksumMismatch, got: {err:?}" + ); +} + +/// An empty (or absent) store directory serves an empty catalog — the +/// client sees a store with nothing in it, not an error. +#[tokio::test] +async fn empty_store_yields_empty_catalog() { + let dir = tempfile::tempdir().expect("tempdir"); + let base_url = spawn_store_server(dir.path()).await; + + let client = ThemeStoreClient::new(&base_url); + let catalog = client.fetch_catalog().await.expect("fetch catalog"); + assert!(catalog.is_empty()); +} diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 683dc08..18aaa98 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -25,6 +25,7 @@ use crate::{ #[cfg(not(target_arch = "wasm32"))] use crate::{ AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin, + ThemeStorePlugin, }; /// Groups all Ferrous Solitaire gameplay plugins. @@ -126,6 +127,7 @@ impl Plugin for CoreGamePlugin { .add_plugins(AudioPlugin) .add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncSetupPlugin) + .add_plugins(ThemeStorePlugin) .add_plugins(AnalyticsPlugin) .add_plugins(LeaderboardPlugin); } diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 9f61f4f..79eb0ea 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent; #[derive(Message, Debug, Clone, Copy, Default)] pub struct SyncConfigureRequestEvent; +/// Request to open the in-game theme-store modal. Fired by the +/// "Browse theme store" button in the Settings cosmetic section; +/// handled by `theme_store_plugin`. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct ThemeStoreOpenRequestEvent; + /// Request to disconnect from the current sync backend, clear stored /// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect" /// button in the Settings sync section. diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 2ab2b93..9e89dd9 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -54,6 +54,8 @@ pub mod sync_plugin; pub mod sync_setup_plugin; pub mod table_plugin; pub mod theme; +#[cfg(not(target_arch = "wasm32"))] +pub mod theme_store_plugin; pub mod time_attack_plugin; pub mod touch_selection_plugin; pub mod ui_focus; @@ -176,6 +178,8 @@ pub use theme::{ ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry, ThemeRegistryPlugin, set_theme, }; +#[cfg(not(target_arch = "wasm32"))] +pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen}; pub use time_attack_plugin::{ TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, }; diff --git a/solitaire_engine/src/settings_plugin/input.rs b/solitaire_engine/src/settings_plugin/input.rs index c0c67e4..d2b2af4 100644 --- a/solitaire_engine/src/settings_plugin/input.rs +++ b/solitaire_engine/src/settings_plugin/input.rs @@ -407,6 +407,10 @@ pub(super) fn handle_settings_buttons( | SettingsButton::DeleteAccount => { // Handled by `handle_sync_buttons`. } + #[cfg(not(target_arch = "wasm32"))] + SettingsButton::OpenThemeStore => { + // Handled by `handle_sync_buttons`. + } SettingsButton::Done => { screen.0 = false; } @@ -423,6 +427,9 @@ pub(super) fn handle_sync_buttons( mut configure_sync: MessageWriter, mut logout_sync: MessageWriter, mut delete_account: MessageWriter, + #[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter< + crate::events::ThemeStoreOpenRequestEvent, + >, mut screen: ResMut, ) { for (interaction, button) in &interaction_query { @@ -439,6 +446,13 @@ pub(super) fn handle_sync_buttons( screen.0 = false; configure_sync.write(SyncConfigureRequestEvent); } + #[cfg(not(target_arch = "wasm32"))] + SettingsButton::OpenThemeStore => { + // Same shape as ConnectSync: close settings first so the + // store modal's own-scrim guard doesn't block. + screen.0 = false; + open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent); + } SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); } diff --git a/solitaire_engine/src/settings_plugin/mod.rs b/solitaire_engine/src/settings_plugin/mod.rs index 225af2f..a5986c1 100644 --- a/solitaire_engine/src/settings_plugin/mod.rs +++ b/solitaire_engine/src/settings_plugin/mod.rs @@ -250,6 +250,9 @@ enum SettingsButton { /// Scan `user_theme_dir()` for new `.zip` files and import each one. #[cfg(not(target_arch = "wasm32"))] ScanThemes, + /// Open the in-game theme-store modal (closes Settings first). + #[cfg(not(target_arch = "wasm32"))] + OpenThemeStore, SyncNow, /// Open the sync-server Connect modal (shown when backend = Local). ConnectSync, @@ -313,6 +316,8 @@ impl SettingsButton { SettingsButton::SelectTheme(_) => 85, #[cfg(not(target_arch = "wasm32"))] SettingsButton::ScanThemes => 86, + #[cfg(not(target_arch = "wasm32"))] + SettingsButton::OpenThemeStore => 87, // Sync section SettingsButton::SyncNow => 90, SettingsButton::ConnectSync => 91, @@ -371,6 +376,7 @@ impl Plugin for SettingsPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() diff --git a/solitaire_engine/src/settings_plugin/ui.rs b/solitaire_engine/src/settings_plugin/ui.rs index 7ea6966..8bb4d81 100644 --- a/solitaire_engine/src/settings_plugin/ui.rs +++ b/solitaire_engine/src/settings_plugin/ui.rs @@ -239,6 +239,8 @@ pub(super) fn spawn_settings_panel( } #[cfg(not(target_arch = "wasm32"))] import_themes_row(body, font_res); + #[cfg(not(target_arch = "wasm32"))] + theme_store_row(body, font_res); // --- Privacy (only shown when a Matomo URL is configured) --- if settings.matomo_url.is_some() { @@ -1193,6 +1195,31 @@ pub(super) fn import_themes_row( }); } +/// "Theme store" row: one pill button that closes Settings and opens +/// the in-game theme-store modal (`theme_store_plugin`). Sits directly +/// under the Import row so both install paths live together. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) { + parent + .spawn(( + FocusRow, + Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + ..default() + }, + )) + .with_children(|row| { + pill_button( + row, + SettingsButton::OpenThemeStore, + "Browse theme store", + "Download themes from your sync server without leaving the game.", + font_res, + ); + }); +} + pub(super) fn icon_button( parent: &mut ChildSpawnerCommands, label: &str, diff --git a/solitaire_engine/src/theme_store_plugin.rs b/solitaire_engine/src/theme_store_plugin.rs new file mode 100644 index 0000000..4b4f325 --- /dev/null +++ b/solitaire_engine/src/theme_store_plugin.rs @@ -0,0 +1,657 @@ +//! In-game theme store: browse the sync server's theme catalog and +//! install themes without leaving the app. +//! +//! Settings → Cosmetic → "Browse theme store" fires +//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a +//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and +//! handles per-theme Install buttons. An install downloads the archive +//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically +//! into `user_theme_dir()`, and runs it through the same hardened +//! [`import_theme`] pipeline as a manually dropped zip — the store +//! never bypasses the importer's validation. +//! +//! Native-only: downloads need `reqwest`/Tokio and the importer is +//! filesystem-based; the plugin is gated out on wasm32 alongside +//! `SyncPlugin`. + +use bevy::prelude::*; +use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; +use thiserror::Error; + +use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError}; +use solitaire_data::{Settings, SyncBackend}; +use solitaire_sync::ThemeCatalogEntry; + +use crate::assets::user_theme_dir; +use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent}; +use crate::font_plugin::FontResource; +use crate::resources::TokioRuntimeResource; +use crate::settings_plugin::{SettingsPanel, SettingsResource}; +use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry}; +use crate::ui_modal::{ + ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, + spawn_modal_button, spawn_modal_header, +}; +use crate::ui_theme::{ + BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, + Z_MODAL_PANEL, +}; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Everything that can go wrong between clicking Install and the theme +/// appearing in the picker. +#[derive(Debug, Error)] +enum ThemeInstallError { + /// Catalog fetch or archive download failed (network, HTTP status, + /// size/checksum verification). + #[error(transparent)] + Download(#[from] ThemeStoreError), + /// The verified archive could not be written into the themes dir. + #[error("could not save archive: {0}")] + Io(#[from] std::io::Error), + /// The downloaded archive failed importer validation. + #[error(transparent)] + Import(#[from] ImportError), +} + +// --------------------------------------------------------------------------- +// Components & resources +// --------------------------------------------------------------------------- + +/// Marker on the theme-store modal's scrim root. +#[derive(Component)] +pub struct ThemeStoreScreen; + +/// Marker on the modal's Close button. +#[derive(Component)] +struct ThemeStoreCloseButton; + +/// Per-row Install button carrying its catalog entry. +#[derive(Component)] +struct ThemeStoreInstallButton(ThemeCatalogEntry); + +/// Catalog fetch lifecycle. The modal renders directly from this state +/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel) +/// whenever it changes. +#[derive(Resource, Default)] +enum CatalogState { + /// No fetch has run since the modal was last opened. + #[default] + Idle, + Loading, + Loaded(Vec), + Error(String), +} + +/// In-flight catalog fetch, polled without blocking. +#[derive(Resource, Default)] +struct CatalogTask(Option, ThemeStoreError>>>); + +/// Result of one download + import: `Ok(Some(id))` on a fresh install, +/// `Ok(None)` when the theme was already installed (id collision) — +/// informational, not an error. +type InstallResult = Result, ThemeInstallError>; + +/// In-flight download + import. `String` is the theme id, for toasts. +#[derive(Resource, Default)] +struct InstallTask(Option<(String, Task)>); + +/// Server base URL captured when the modal opens. Catalog +/// `download_url`s are server-relative, so installs join them onto +/// this. `None` while the modal has never been opened. +#[derive(Resource, Default)] +struct StoreBaseUrl(Option); + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Bevy plugin for the in-game theme store. See the module docs. +pub struct ThemeStorePlugin; + +impl Plugin for ThemeStorePlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_systems( + Update, + ( + handle_open_request, + poll_catalog_task, + handle_install_buttons, + poll_install_task, + handle_close_button, + ) + .chain(), + ); + } +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Resolves the store's server base URL from the player's sync +/// backend. The store rides the same self-hosted server as sync; +/// without one configured there is nothing to browse. +fn store_base_url(settings: &Settings) -> Option { + match &settings.sync_backend { + SyncBackend::SolitaireServer { url, .. } => Some(url.clone()), + _ => None, + } +} + +/// Opens the store modal and kicks off the catalog fetch. +/// +/// Mirrors `open_sync_setup_modal`: the Settings button closes the +/// settings panel in the same frame it fires the event, and despawns +/// are deferred, so the settings scrim is excluded from the +/// another-modal-is-open guard. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +fn handle_open_request( + mut events: MessageReader, + existing: Query<(), With>, + other_modal_scrims: Query< + (), + ( + With, + Without, + Without, + ), + >, + settings: Option>, + rt: Option>, + mut catalog_state: ResMut, + mut catalog_task: ResMut, + mut store_base: ResMut, + mut warning_toast: MessageWriter, + mut commands: Commands, + font_res: Option>, +) { + if events.is_empty() { + return; + } + events.clear(); + if !existing.is_empty() || !other_modal_scrims.is_empty() { + return; + } + + let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else { + warning_toast.write(WarningToastEvent( + "Theme store needs a server — connect one in Settings → Sync.".to_string(), + )); + return; + }; + let Some(rt) = rt else { + warning_toast.write(WarningToastEvent( + "Theme store unavailable — no network runtime.".to_string(), + )); + return; + }; + + store_base.0 = Some(base_url.clone()); + *catalog_state = CatalogState::Loading; + let rt = rt.0.clone(); + catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move { + rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await }) + })); + + spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref()); +} + +/// Polls the catalog fetch; on completion updates [`CatalogState`] and +/// rebuilds the modal if it is still open. +fn poll_catalog_task( + mut catalog_task: ResMut, + mut catalog_state: ResMut, + screens: Query>, + registry: Option>, + mut commands: Commands, + font_res: Option>, +) { + let Some(task) = catalog_task.0.as_mut() else { + return; + }; + let Some(result) = future::block_on(future::poll_once(task)) else { + return; + }; + catalog_task.0 = None; + + *catalog_state = match result { + Ok(entries) => CatalogState::Loaded(entries), + Err(e) => { + warn!("theme store: catalog fetch failed: {e}"); + CatalogState::Error(e.to_string()) + } + }; + + rebuild_open_modal( + &screens, + &mut commands, + &catalog_state, + registry.as_deref(), + font_res.as_deref(), + ); +} + +/// Starts a download + import task when an Install button is pressed. +/// One install at a time; repeat clicks while busy are ignored. +fn handle_install_buttons( + interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed>, + rt: Option>, + store_base: Res, + mut install_task: ResMut, + mut info_toast: MessageWriter, +) { + for (interaction, button) in &interactions { + if *interaction != Interaction::Pressed || install_task.0.is_some() { + continue; + } + let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else { + continue; + }; + let entry = button.0.clone(); + info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name))); + let rt = rt.0.clone(); + let task = AsyncComputeTaskPool::get().spawn(async move { + let bytes = rt + .block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?; + install_verified_archive(&entry.id, &bytes) + }); + install_task.0 = Some((button.0.id.clone(), task)); + } +} + +/// Downloads land here (already SHA-256-verified): write the archive +/// atomically into the user themes dir (`.tmp` + rename, §2.5), then +/// run the standard importer. Returns `Ok(None)` when the theme was +/// already installed. +fn install_verified_archive(id: &str, bytes: &[u8]) -> Result, ThemeInstallError> { + let themes_dir = user_theme_dir(); + std::fs::create_dir_all(&themes_dir)?; + let final_path = themes_dir.join(format!("{id}.zip")); + let tmp_path = themes_dir.join(format!("{id}.zip.tmp")); + std::fs::write(&tmp_path, bytes)?; + std::fs::rename(&tmp_path, &final_path)?; + + match import_theme(&final_path) { + Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())), + Err(ImportError::IdCollision { .. }) => Ok(None), + Err(e) => { + // Don't leave a zip that fails validation lying around for + // the folder-scan flow to re-try on every scan. + let _ = std::fs::remove_file(&final_path); + Err(e.into()) + } + } +} + +/// Polls the install task; on completion refreshes the theme registry, +/// toasts the outcome, and rebuilds the modal so the row flips to +/// "Installed". +#[allow(clippy::too_many_arguments)] +fn poll_install_task( + mut install_task: ResMut, + mut registry: Option>, + catalog_state: Res, + screens: Query>, + mut info_toast: MessageWriter, + mut warning_toast: MessageWriter, + mut commands: Commands, + font_res: Option>, +) { + let Some((id, task)) = install_task.0.as_mut() else { + return; + }; + let Some(result) = future::block_on(future::poll_once(task)) else { + return; + }; + let id = id.clone(); + install_task.0 = None; + + match result { + Ok(Some(theme_id)) => { + if let Some(registry) = registry.as_deref_mut() { + refresh_registry(registry, &user_theme_dir()); + } + info_toast.write(InfoToastEvent(format!( + "Theme '{theme_id}' installed — pick it in Settings → Cosmetic." + ))); + } + Ok(None) => { + info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed."))); + } + Err(e) => { + warn!("theme store: install of '{id}' failed: {e}"); + warning_toast.write(WarningToastEvent(format!("Install failed: {e}"))); + } + } + + rebuild_open_modal( + &screens, + &mut commands, + &catalog_state, + registry.as_deref(), + font_res.as_deref(), + ); +} + +/// Despawns the store modal when Close is pressed. +fn handle_close_button( + interactions: Query<&Interaction, (Changed, With)>, + screens: Query>, + mut commands: Commands, +) { + for interaction in &interactions { + if *interaction != Interaction::Pressed { + continue; + } + for entity in &screens { + commands.entity(entity).despawn(); + } + } +} + +// --------------------------------------------------------------------------- +// Modal rendering +// --------------------------------------------------------------------------- + +/// Despawn + respawn the modal for every open screen (Bevy despawns +/// are deferred, so this is safe within one frame). +fn rebuild_open_modal( + screens: &Query>, + commands: &mut Commands, + catalog_state: &CatalogState, + registry: Option<&ThemeRegistry>, + font_res: Option<&FontResource>, +) { + for entity in screens { + commands.entity(entity).despawn(); + spawn_store_modal(commands, catalog_state, registry, font_res); + } +} + +/// Spawns the store modal for the current catalog state. +fn spawn_store_modal( + commands: &mut Commands, + catalog_state: &CatalogState, + registry: Option<&ThemeRegistry>, + font_res: Option<&FontResource>, +) { + let body_font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_BODY, + ..default() + }; + let caption_font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }; + + let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| { + spawn_modal_header(card, "Theme Store", font_res); + + match catalog_state { + CatalogState::Idle | CatalogState::Loading => { + card.spawn(( + Text::new("Loading catalog…"), + body_font.clone(), + TextColor(TEXT_SECONDARY), + )); + } + CatalogState::Error(msg) => { + card.spawn(( + Text::new(format!("Could not load the catalog: {msg}")), + body_font.clone(), + TextColor(TEXT_SECONDARY), + )); + } + CatalogState::Loaded(entries) if entries.is_empty() => { + card.spawn(( + Text::new("The server has no themes yet."), + body_font.clone(), + TextColor(TEXT_SECONDARY), + )); + } + CatalogState::Loaded(entries) => { + for entry in entries { + let installed = + registry.is_some_and(|registry| registry.find(&entry.id).is_some()); + spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res); + } + } + } + + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + ThemeStoreCloseButton, + "Close", + None, + ButtonVariant::Primary, + font_res, + ); + }); + }); + commands.entity(scrim).insert(ScrimDismissible); +} + +/// One catalog row: name + author/size caption on the left, Install +/// button (or "Installed" caption) on the right. +fn spawn_store_row( + parent: &mut ChildSpawnerCommands, + entry: &ThemeCatalogEntry, + installed: bool, + body_font: &TextFont, + caption_font: &TextFont, + font_res: Option<&FontResource>, +) { + parent + .spawn(( + Node { + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + padding: UiRect::vertical(VAL_SPACE_2), + border: UiRect::bottom(Val::Px(1.0)), + ..default() + }, + BorderColor::all(BORDER_SUBTLE), + )) + .with_children(|row| { + row.spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|col| { + col.spawn(( + Text::new(entry.name.clone()), + body_font.clone(), + TextColor(TEXT_PRIMARY), + )); + col.spawn(( + Text::new(format!( + "by {} — {} KB", + entry.author, + entry.size_bytes.div_ceil(1024) + )), + caption_font.clone(), + TextColor(TEXT_SECONDARY), + )); + }); + if installed { + row.spawn(( + Text::new("Installed"), + caption_font.clone(), + TextColor(TEXT_SECONDARY), + )); + } else { + spawn_modal_button( + row, + ThemeStoreInstallButton(entry.clone()), + "Install", + None, + ButtonVariant::Secondary, + font_res, + ); + } + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use bevy::ecs::message::Messages; + + fn headless_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(ThemeStorePlugin); + app.update(); + app + } + + fn settings_with_server() -> SettingsResource { + SettingsResource(Settings { + sync_backend: SyncBackend::SolitaireServer { + url: "http://127.0.0.1:1".to_string(), + username: "tester".to_string(), + avatar_url: None, + }, + ..Settings::default() + }) + } + + fn screen_count(app: &mut App) -> usize { + app.world_mut() + .query::<&ThemeStoreScreen>() + .iter(app.world()) + .count() + } + + #[test] + fn store_base_url_requires_server_backend() { + assert_eq!(store_base_url(&Settings::default()), None); + assert_eq!( + store_base_url(&settings_with_server().0).as_deref(), + Some("http://127.0.0.1:1") + ); + } + + #[test] + fn open_request_without_server_warns_and_spawns_nothing() { + let mut app = headless_app(); + // Default settings → SyncBackend::Local → no store. + app.insert_resource(SettingsResource(Settings::default())); + app.world_mut() + .resource_mut::>() + .write(ThemeStoreOpenRequestEvent); + app.update(); + + assert_eq!(screen_count(&mut app), 0); + assert!( + !app.world() + .resource::>() + .is_empty(), + "player must be told why the store did not open" + ); + } + + #[test] + fn open_request_without_runtime_warns_and_spawns_nothing() { + let mut app = headless_app(); + app.insert_resource(settings_with_server()); + // No TokioRuntimeResource inserted. + app.world_mut() + .resource_mut::>() + .write(ThemeStoreOpenRequestEvent); + app.update(); + + assert_eq!(screen_count(&mut app), 0); + assert!( + !app.world() + .resource::>() + .is_empty() + ); + } + + #[test] + fn open_request_with_server_spawns_modal_in_loading_state() { + let mut app = headless_app(); + app.insert_resource(settings_with_server()); + app.insert_resource(TokioRuntimeResource::new().expect("test runtime")); + app.world_mut() + .resource_mut::>() + .write(ThemeStoreOpenRequestEvent); + app.update(); + + assert_eq!(screen_count(&mut app), 1); + assert!(matches!( + *app.world().resource::(), + CatalogState::Loading | CatalogState::Error(_) + )); + assert_eq!( + app.world().resource::().0.as_deref(), + Some("http://127.0.0.1:1") + ); + } + + #[test] + fn second_open_request_does_not_stack_a_second_modal() { + let mut app = headless_app(); + app.insert_resource(settings_with_server()); + app.insert_resource(TokioRuntimeResource::new().expect("test runtime")); + for _ in 0..2 { + app.world_mut() + .resource_mut::>() + .write(ThemeStoreOpenRequestEvent); + app.update(); + } + assert_eq!(screen_count(&mut app), 1); + } + + #[test] + fn failed_catalog_fetch_rebuilds_modal_with_error_state() { + let mut app = headless_app(); + app.insert_resource(settings_with_server()); + app.insert_resource(TokioRuntimeResource::new().expect("test runtime")); + app.world_mut() + .resource_mut::>() + .write(ThemeStoreOpenRequestEvent); + app.update(); + + // 127.0.0.1:1 refuses connections; the fetch task must resolve + // to an error within a bounded number of frames and the modal + // must survive the rebuild. + for _ in 0..200 { + app.update(); + if matches!( + *app.world().resource::(), + CatalogState::Error(_) + ) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(matches!( + *app.world().resource::(), + CatalogState::Error(_) + )); + assert_eq!(screen_count(&mut app), 1); + } +} diff --git a/solitaire_server/Cargo.toml b/solitaire_server/Cargo.toml index 1b67601..b16d760 100644 --- a/solitaire_server/Cargo.toml +++ b/solitaire_server/Cargo.toml @@ -29,9 +29,15 @@ tower-http = { version = "0.6", features = ["fs"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } dotenvy = { workspace = true } +# Theme store: scans a directory of theme .zip archives at startup, +# reading each archive's theme.ron manifest and hashing the bytes. +ron = { workspace = true } +zip = { workspace = true } +sha2 = { workspace = true } [dev-dependencies] -tower = { version = "0.5", features = ["util"] } +tower = { version = "0.5", features = ["util"] } +tempfile = { workspace = true } [lints] workspace = true diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 17ba7c5..d47ab03 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -11,6 +11,7 @@ pub mod leaderboard; pub mod middleware; pub mod replays; pub mod sync; +pub mod theme_store; pub use auth::reset_password; @@ -86,6 +87,10 @@ pub struct AppState { pub pool: SqlitePool, /// HS256 signing secret for JWT access and refresh tokens. pub jwt_secret: String, + /// Theme-store catalog scanned once at startup. + /// [`theme_store::ThemeStore::empty`] when the server runs without + /// a store directory. + pub theme_store: Arc, } /// Construct the full Axum [`Router`]. @@ -125,9 +130,20 @@ pub async fn build_test_pool() -> SqlitePool { /// integration tests do not need to set `JWT_SECRET` in the environment. #[doc(hidden)] pub fn build_test_router(pool: SqlitePool) -> Router { + build_test_router_with_theme_store(pool, theme_store::ThemeStore::empty()) +} + +/// [`build_test_router`] with a caller-supplied theme store, for +/// integration tests exercising the theme endpoints. +#[doc(hidden)] +pub fn build_test_router_with_theme_store( + pool: SqlitePool, + theme_store: theme_store::ThemeStore, +) -> Router { let state = AppState { pool, jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(), + theme_store: Arc::new(theme_store), }; build_router_inner(state, false) } @@ -195,6 +211,9 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { .route("/api/daily-challenge", get(challenge::daily_challenge)) .route("/api/replays/recent", get(replays::recent)) .route("/api/replays/{id}", get(replays::get_by_id)) + .route("/api/themes", get(theme_store::list)) + .route("/api/themes/{id}/download", get(theme_store::download)) + .route("/api/themes/{id}/preview", get(theme_store::preview)) .route("/health", get(health)) .nest_service("/avatars", ServeDir::new("avatars")); diff --git a/solitaire_server/src/main.rs b/solitaire_server/src/main.rs index 1425770..108cbf8 100644 --- a/solitaire_server/src/main.rs +++ b/solitaire_server/src/main.rs @@ -31,12 +31,13 @@ //! echo "new_password" | ./solitaire_server --reset-password alice //! ``` -use solitaire_server::{AppState, build_router}; +use solitaire_server::{AppState, build_router, theme_store}; use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; use std::{ io::{self, BufRead}, net::SocketAddr, str::FromStr, + sync::Arc, }; const JWT_SECRET_MIN_BYTES: usize = 32; @@ -142,7 +143,19 @@ async fn run_server() { tracing::info!("database ready at {db_url}"); - let state = AppState { pool, jwt_secret }; + // Theme store: optional; a missing directory just means an empty + // catalog. Scanned once — add themes, then restart to publish. + let theme_store_dir = std::env::var(theme_store::THEME_STORE_DIR_ENV) + .unwrap_or_else(|_| theme_store::DEFAULT_THEME_STORE_DIR.into()); + let theme_store = Arc::new(theme_store::ThemeStore::scan(std::path::Path::new( + &theme_store_dir, + ))); + + let state = AppState { + pool, + jwt_secret, + theme_store, + }; let app = build_router(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); diff --git a/solitaire_server/src/theme_store.rs b/solitaire_server/src/theme_store.rs new file mode 100644 index 0000000..69b3b93 --- /dev/null +++ b/solitaire_server/src/theme_store.rs @@ -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 `.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"); + } +} diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index 270baed..e0fa603 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -1614,6 +1614,7 @@ async fn auth_rate_limit_returns_429_on_11th_request() { let state = solitaire_server::AppState { pool: test_pool().await, jwt_secret: TEST_SECRET.to_string(), + theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()), }; let app = solitaire_server::build_router(state); @@ -1675,6 +1676,7 @@ async fn sync_push_rate_limit_returns_429_on_11th_request() { let state = solitaire_server::AppState { pool: test_pool().await, jwt_secret: TEST_SECRET.to_string(), + theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()), }; let app = solitaire_server::build_router(state); @@ -1879,3 +1881,125 @@ async fn replay_upload_malformed_body_returns_400() { let resp = post_authed(app, "/api/replays", &token, bad).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } + +// --------------------------------------------------------------------------- +// Theme store +// --------------------------------------------------------------------------- + +/// Writes a minimal theme zip (manifest only — the catalog scan reads +/// just the `meta` block) into `dir` and returns its path. +fn write_store_zip( + dir: &std::path::Path, + file_name: &str, + id: &str, + name: &str, +) -> std::path::PathBuf { + use std::io::Write as _; + let manifest = format!( + r#"( + meta: ( + id: "{id}", + name: "{name}", + author: "Test Author", + version: "1.0.0", + card_aspect: (2, 3), + ), + faces: {{}}, + back: "back.svg", +)"# + ); + 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_file"); + writer + .write_all(manifest.as_bytes()) + .expect("write manifest"); + writer.finish().expect("finish zip"); + path +} + +/// `GET /api/themes` returns the scanned catalog as JSON, no auth needed. +#[tokio::test] +async fn theme_catalog_lists_scanned_themes() { + let dir = tempfile::tempdir().expect("tempdir"); + let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon"); + let store = solitaire_server::theme_store::ThemeStore::scan(dir.path()); + let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store); + + let req = Request::builder() + .uri("/api/themes") + .body(Body::empty()) + .expect("build request"); + let resp = app.oneshot(req).await.expect("oneshot"); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let catalog: solitaire_sync::ThemeCatalogResponse = + serde_json::from_slice(&body).expect("parse catalog"); + assert_eq!(catalog.themes.len(), 1); + let entry = &catalog.themes[0]; + assert_eq!(entry.id, "neon"); + assert_eq!(entry.name, "Neon"); + assert_eq!(entry.download_url, "/api/themes/neon/download"); + assert_eq!(entry.preview_url, None); + let zip_bytes = std::fs::read(&zip_path).expect("read zip"); + assert_eq!(entry.size_bytes, zip_bytes.len() as u64); +} + +/// The download endpoint streams back exactly the bytes on disk, so a +/// client hashing the response gets the catalog's sha256. +#[tokio::test] +async fn theme_download_returns_archive_bytes() { + let dir = tempfile::tempdir().expect("tempdir"); + let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon"); + let store = solitaire_server::theme_store::ThemeStore::scan(dir.path()); + let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store); + + let req = Request::builder() + .uri("/api/themes/neon/download") + .body(Body::empty()) + .expect("build request"); + let resp = app.oneshot(req).await.expect("oneshot"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/zip") + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + assert_eq!(&body[..], &std::fs::read(&zip_path).expect("read zip")[..]); +} + +/// Unknown ids 404 on both file endpoints; a theme without preview art +/// 404s on `/preview` while still serving its download. +#[tokio::test] +async fn theme_unknown_id_and_missing_preview_return_404() { + let dir = tempfile::tempdir().expect("tempdir"); + write_store_zip(dir.path(), "neon.zip", "neon", "Neon"); + let store = solitaire_server::theme_store::ThemeStore::scan(dir.path()); + let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store); + + for uri in [ + "/api/themes/nope/download", + "/api/themes/nope/preview", + "/api/themes/neon/preview", + ] { + let req = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("build request"); + let resp = app.clone().oneshot(req).await.expect("oneshot"); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "expected 404 for {uri}" + ); + } +} diff --git a/solitaire_sync/Cargo.toml b/solitaire_sync/Cargo.toml index 7a499ab..81db114 100644 --- a/solitaire_sync/Cargo.toml +++ b/solitaire_sync/Cargo.toml @@ -10,5 +10,8 @@ uuid = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } +[dev-dependencies] +serde_json = { workspace = true } + [lints] workspace = true diff --git a/solitaire_sync/src/lib.rs b/solitaire_sync/src/lib.rs index 5d88350..77f3161 100644 --- a/solitaire_sync/src/lib.rs +++ b/solitaire_sync/src/lib.rs @@ -10,11 +10,13 @@ pub mod achievements; pub mod merge; pub mod progress; pub mod stats; +pub mod theme_store; pub use achievements::AchievementRecord; pub use merge::{merge, merge_at}; pub use progress::{PlayerProgress, level_for_xp}; pub use stats::StatsSnapshot; +pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; diff --git a/solitaire_sync/src/theme_store.rs b/solitaire_sync/src/theme_store.rs new file mode 100644 index 0000000..4f70cc1 --- /dev/null +++ b/solitaire_sync/src/theme_store.rs @@ -0,0 +1,102 @@ +//! Theme-store catalog wire types. +//! +//! Shared between `solitaire_server` (which builds the catalog by +//! scanning its theme-store directory) and `solitaire_data` (which +//! fetches it and downloads theme archives). These are **additive** +//! API types: they do not participate in [`crate::SyncPayload`] and +//! changing them cannot break the sync wire format. +//! +//! Store entries describe downloadable card-art theme archives — the +//! same `.zip` format consumed by the engine's theme importer (a +//! `theme.ron` manifest plus 53 SVGs). + +use serde::{Deserialize, Serialize}; + +/// One downloadable theme in the server's catalog. +/// +/// URLs are server-relative (e.g. `/api/themes/neon/download`) so a +/// client can join them onto whichever base URL it is configured with. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThemeCatalogEntry { + /// Unique theme id — `meta.id` from the archive's `theme.ron`. + /// Also the id the theme installs under on the client. + pub id: String, + /// Display name shown in the store UI. + pub name: String, + /// Author attribution (free-form text). + pub author: String, + /// Version string (conventionally semver). + pub version: String, + /// Card aspect ratio as `(numerator, denominator)`. + pub card_aspect: (u32, u32), + /// Size of the `.zip` archive in bytes. Clients use this for + /// display and as a pre-download sanity bound. + pub size_bytes: u64, + /// Lowercase-hex SHA-256 of the `.zip` archive. Clients MUST + /// verify the downloaded bytes against this digest before handing + /// the archive to the theme importer. + pub sha256: String, + /// Server-relative URL of the theme archive. + pub download_url: String, + /// Server-relative URL of a PNG preview, when the store ships one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview_url: Option, +} + +/// Response body of the catalog listing endpoint (`GET /api/themes`). +/// +/// Wrapped in a struct (rather than a bare `Vec`) so future additive +/// fields — pagination, store revision, purchase metadata — don't +/// break existing clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThemeCatalogResponse { + /// Every theme currently offered by the server, sorted by name. + pub themes: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry() -> ThemeCatalogEntry { + ThemeCatalogEntry { + id: "neon".into(), + name: "Neon".into(), + author: "Rusty Solitaire".into(), + version: "1.0.0".into(), + card_aspect: (2, 3), + size_bytes: 123_456, + sha256: "ab".repeat(32), + download_url: "/api/themes/neon/download".into(), + preview_url: Some("/api/themes/neon/preview".into()), + } + } + + #[test] + fn catalog_round_trips_through_json() { + let response = ThemeCatalogResponse { + themes: vec![entry()], + }; + let json = serde_json::to_string(&response).expect("serialize"); + let back: ThemeCatalogResponse = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, response); + } + + #[test] + fn missing_preview_url_deserializes_to_none() { + // Older servers (or themes without preview art) omit the key + // entirely; the field must default rather than error. + let json = r#"{ + "id": "neon", + "name": "Neon", + "author": "Rusty Solitaire", + "version": "1.0.0", + "card_aspect": [2, 3], + "size_bytes": 1, + "sha256": "00", + "download_url": "/api/themes/neon/download" + }"#; + let entry: ThemeCatalogEntry = serde_json::from_str(json).expect("deserialize"); + assert_eq!(entry.preview_url, None); + } +}