diff --git a/AGENTS.md b/AGENTS.md index e731124..1186352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ src/ lib.rs # Module declarations only (no logic) config.rs # AppConfig (clap derive with env fallbacks) errors.rs # Error enum + Result type alias - web.rs # Web UI handlers (index, /api/books, /covers/{id}, login, OIDC) + web.rs # Web API + static SPA serving (/api/*, /covers/{id}, login, OIDC, SPA fallback) oidc.rs # OIDC Relying Party: discovery, auth URL, token exchange, provisioning db/ mod.rs # Re-exports only @@ -79,10 +79,25 @@ src/ sync.rs # SyncEngine (incremental sync algorithm) sync_point.rs # Sync point tracking sync_token.rs # Sync token encode/decode -web/ - index.html # Static HTML (embedded via include_str!) +web-ui/ # Leptos CSR frontend (own crate + workspace, wasm32 only) + index.html # Trunk entrypoint + styles.css # Hand-written dark theme (no Tailwind/CDN) + setup_glue.js # JS glue for the setup page (File System Access + sql.js) + src/ + main.rs # mount_to_body(App) + api.rs # Typed /api client (gloo-net) + components/ # app (router), library, login, register, setup ``` +The browser UI is a Leptos client-side-rendered SPA in `web-ui/`, built with +Trunk into `web-ui/dist` and embedded into the server binary via `rust-embed` +(see `web.rs` `WebAssets`). It talks to the same `/api/...` JSON routes; the +server serves the SPA shell for all non-API paths (SPA fallback) and enforces +auth per-`/api` handler (returning `401`, not a redirect). Build it with +`trunk build` in `web-ui/` (dev) or `nix build .#kobors-web-ui`; the full +`nix build` builds the bundle and embeds it. None of this touches the Kobo wire +contract. + ## Code Style ### General Principles @@ -243,7 +258,8 @@ Kobo device. Internal refactors are fine; wire-visible changes are not. ### Architecture notes - **Two routers mounted:** Kobo API under `/kobo/{auth_token}`, web UI at root - (see `web.rs`; `web/index.html` is embedded via `include_str!`). + (see `web.rs`; the Leptos SPA bundle `web-ui/dist` is embedded via + `rust-embed` and served with an `index.html` SPA fallback). - **Auth:** the device sends the opaque token as an `Authorization: Bearer` JWT (the token rides in the `kobors_token` claim of its `KoboAccessToken`). `token_header::inject_bearer_token` middleware runs before routing, decodes diff --git a/Cargo.lock b/Cargo.lock index d86d02b..8462fa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1497,6 +1497,7 @@ dependencies = [ "password-auth", "quick-xml", "reqwest", + "rust-embed", "serde", "serde_json", "sha2 0.11.0", @@ -2266,6 +2267,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2328,6 +2364,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3384,6 +3429,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3495,6 +3550,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 51b862b..c3d8670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "kobors" version = "0.1.0" edition = "2021" +# Application, not a published library; lets cargo-deny treat it as private +# (see deny.toml `[licenses] private.ignore`). +publish = false [dependencies] # Web framework @@ -52,6 +55,11 @@ toml = "1" futures = "0.3" axum-listener = "0.3" +# Embed the Leptos SPA bundle (web-ui/dist) into the binary. In release the +# assets are baked in (single-binary deploy); in debug they are read from disk, +# so rebuilding the frontend with Trunk needs no server recompile. +rust-embed = "8" + # EPUB upload: zip container + OPF metadata parsing, content hashing zip = { version = "8", default-features = false, features = ["deflate"] } quick-xml = "0.41" diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..03d7e0d --- /dev/null +++ b/deny.toml @@ -0,0 +1,42 @@ +# cargo-deny configuration. `nix flake check` runs `cargo deny check` (bans, +# licenses, sources); newer cargo-deny (0.20+) requires an explicit license +# allow-list. The set below is exactly the licenses present in the dependency +# tree — all permissive or weak-copyleft-leaf. + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "0BSD", + "Zlib", + "Unicode-3.0", + "Unlicense", + "CDLA-Permissive-2.0", + # Only via `r-efi` (a wasi build-time leaf); not linked into distributed code. + "LGPL-2.1-or-later", +] +confidence-threshold = 0.8 +# kobors itself is private/unpublished and carries no license field. +private = { ignore = true } + +[bans] +# The tree legitimately carries several crates at multiple versions +# (base64, hashbrown, thiserror, sha2, …) via independent sub-dependencies. +multiple-versions = "allow" + +[advisories] +# RUSTSEC-2023-0071 (rsa Marvin attack) is reachable only through sqlx-mysql, +# which sqlx-macros pulls into the lockfile for all backends. kobors uses +# SQLite only and never touches the MySQL driver; no fixed rsa release exists. +# Mirrors the flake's cargoAuditExtraArgs. +ignore = ["RUSTSEC-2023-0071"] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/flake.lock b/flake.lock index 6f255d6..8ebb0f6 100644 --- a/flake.lock +++ b/flake.lock @@ -31,6 +31,43 @@ "type": "github" } }, + "crates-io-index": { + "flake": false, + "locked": { + "lastModified": 1785121282, + "narHash": "sha256-8ISyRV36Wt5eQguKOHEc5TJkjMc/n2oJuIajHZumbEA=", + "ref": "refs/heads/master", + "rev": "576251625b9ef1d28a0b950da2386d704873246b", + "shallow": true, + "type": "git", + "url": "https://github.com/rust-lang/crates.io-index" + }, + "original": { + "shallow": true, + "type": "git", + "url": "https://github.com/rust-lang/crates.io-index" + } + }, + "crates-nix": { + "inputs": { + "crates-io-index": [ + "crates-io-index" + ] + }, + "locked": { + "lastModified": 1773225892, + "narHash": "sha256-EM//kHJ1Rlhsl6Q01gRxFozaGWlZvrnaI8Gdv0YfMgs=", + "owner": "uttarayan21", + "repo": "crates.nix", + "rev": "40b6736feba1aedf830b500b5d017d07ab5bd6f2", + "type": "github" + }, + "original": { + "owner": "uttarayan21", + "repo": "crates.nix", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -89,6 +126,8 @@ "inputs": { "advisory-db": "advisory-db", "crane": "crane", + "crates-io-index": "crates-io-index", + "crates-nix": "crates-nix", "flake-utils": "flake-utils", "nix-github-actions": "nix-github-actions", "nixpkgs": "nixpkgs", diff --git a/flake.nix b/flake.nix index 4f2736e..43a5362 100644 --- a/flake.nix +++ b/flake.nix @@ -5,6 +5,16 @@ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; crane.url = "github:ipetkov/crane"; + # Builds wasm-bindgen-cli at the exact version the web-ui Cargo.lock pins + # (trunk requires the CLI to match the wasm-bindgen crate), fully offline. + crates-nix = { + url = "github:uttarayan21/crates.nix"; + inputs.crates-io-index.follows = "crates-io-index"; + }; + crates-io-index = { + url = "git+https://github.com/rust-lang/crates.io-index?shallow=1"; + flake = false; + }; nix-github-actions = { url = "github:uttarayan21/nix-github-actions"; inputs.nixpkgs.follows = "nixpkgs"; @@ -22,6 +32,7 @@ outputs = { self, crane, + crates-nix, flake-utils, nixpkgs, rust-overlay, @@ -41,19 +52,65 @@ cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); name = cargoToml.package.name; - stableToolchain = pkgs.rust-bin.stable.latest.default; + # Base toolchain carries the wasm32 target so the Leptos CSR frontend + # (web-ui/) can be cross-compiled by Trunk. Harmless for the native + # server build. + stableToolchain = pkgs.rust-bin.stable.latest.default.override { + targets = ["wasm32-unknown-unknown"]; + }; stableToolchainWithLLvmTools = stableToolchain.override { extensions = ["rust-src" "llvm-tools"]; + targets = ["wasm32-unknown-unknown"]; }; stableToolchainWithRustAnalyzer = stableToolchain.override { extensions = ["rust-src" "rust-analyzer"]; + targets = ["wasm32-unknown-unknown"]; }; craneLib = (crane.mkLib pkgs).overrideToolchain stableToolchain; craneLibLLvmTools = (crane.mkLib pkgs).overrideToolchain stableToolchainWithLLvmTools; + # crates.nix builds a single crate from crates.io — used to get + # wasm-bindgen-cli at the exact version web-ui/Cargo.lock pins. + crates = crates-nix.mkLib {inherit pkgs;}; + lockedCrateVersion = crateName: lockFile: + (builtins.elemAt + (builtins.filter (p: p.name == crateName) + (builtins.fromTOML (builtins.readFile lockFile)).package) + 0) + .version; + + # ---- Leptos CSR frontend (web-ui/) ---- + # Built with Trunk into a static bundle, then embedded into the server + # binary (rust-embed reads web-ui/dist at compile time — injected below + # via commonArgs.preBuild). This is a self-contained crate with its own + # Cargo.lock, so it builds independently of the server workspace. + webUiSrc = lib.cleanSourceWith { + src = ./web-ui; + filter = path: type: + (craneLib.filterCargoSources path type) + || (lib.any (ext: lib.hasSuffix ext path) [".html" ".css" ".js"]); + }; + webUiDist = craneLib.buildTrunkPackage { + src = webUiSrc; + pname = "kobors-web-ui"; + version = "0.1.0"; + trunkIndexPath = "index.html"; + doCheck = false; + nativeBuildInputs = [pkgs.binaryen]; + # Exact-version CLI: trunk refuses a wasm-bindgen-cli that does not + # match the compiled wasm-bindgen crate. + wasm-bindgen-cli = crates.buildCrate "wasm-bindgen-cli" { + version = lockedCrateVersion "wasm-bindgen" ./web-ui/Cargo.lock; + }; + }; + src = let filterBySuffix = path: exts: lib.any (ext: lib.hasSuffix ext path) exts; - sourceFilters = path: type: (craneLib.filterCargoSources path type) || filterBySuffix path [".c" ".h" ".hpp" ".cpp" ".cc" ".html"]; + # Exclude web-ui/ — it is a separate crate built via webUiDist and + # injected into web-ui/dist at server build time. + sourceFilters = path: type: + !(lib.hasInfix "/web-ui/" path) + && ((craneLib.filterCargoSources path type) || filterBySuffix path [".c" ".h" ".hpp" ".cpp" ".cc" ".toml"]); in lib.cleanSourceWith { filter = sourceFilters; @@ -65,6 +122,15 @@ pname = name; stdenv = p: p.clangStdenv; doCheck = false; + # Place the built Leptos bundle where rust-embed expects it + # (web-ui/dist) before the server compiles. Runs for every crane + # derivation (deps, clippy, nextest, doc, package) so the embed + # macro always finds the folder. + preBuild = '' + rm -rf web-ui/dist + mkdir -p web-ui + cp -r ${webUiDist} web-ui/dist + ''; # LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; # nativeBuildInputs = with pkgs; [ # cmake @@ -125,6 +191,7 @@ // {inherit cargoArtifacts;}); in { "${name}" = pkg; + "${name}-web-ui" = webUiDist; default = pkg; }; @@ -137,6 +204,9 @@ cargo-nextest cargo-deny sqlite + trunk + wasm-bindgen-cli + binaryen ] ++ (lib.optionals pkgs.stdenv.isDarwin [ apple-sdk_26 diff --git a/src/db/app_db.rs b/src/db/app_db.rs index a07188b..e11dab1 100644 --- a/src/db/app_db.rs +++ b/src/db/app_db.rs @@ -102,6 +102,9 @@ pub struct AuthTokenRecord { pub id: i64, pub auth_token: String, pub user_id: i64, + /// Serial of a device bound to this token via `device_registrations`, if + /// any. Only populated by `list_auth_tokens`; other lookups leave it `None`. + pub device_serial: Option, } /// A sync point — a frozen snapshot of a user's library state at a moment in time. @@ -528,6 +531,7 @@ impl AppDb { id: r.get("id"), auth_token: r.get("auth_token"), user_id: r.get("user_id"), + device_serial: None, })) } @@ -633,14 +637,20 @@ impl AppDb { Ok(()) } - /// List all auth tokens for a user. + /// List all auth tokens for a user, each with the serial of any device + /// currently bound to it (via `device_registrations`). pub async fn list_auth_tokens(&self, user_id: i64) -> Result> { - let rows = sqlx::query("SELECT id, auth_token, user_id FROM auth_tokens WHERE user_id = ?") - .bind(user_id) - .fetch_all(&self.pool) - .await - .change_context(Error::Database) - .attach("Failed to list auth tokens")?; + let rows = sqlx::query( + r#"SELECT t.id, t.auth_token, t.user_id, d.serial AS device_serial + FROM auth_tokens t + LEFT JOIN device_registrations d ON d.auth_token = t.auth_token + WHERE t.user_id = ?"#, + ) + .bind(user_id) + .fetch_all(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to list auth tokens")?; Ok(rows .iter() @@ -648,10 +658,31 @@ impl AppDb { id: r.get("id"), auth_token: r.get("auth_token"), user_id: r.get("user_id"), + device_serial: r.get::, _>("device_serial"), }) .collect()) } + /// The serial of a device bound to this token (scoped to the user), or + /// `None` if the token is unbound or not owned by the user. Used to guard + /// deletion of a token a Kobo device is actively syncing with. + pub async fn token_device_serial(&self, token_id: i64, user_id: i64) -> Result> { + let row = sqlx::query( + r#"SELECT d.serial + FROM auth_tokens t + JOIN device_registrations d ON d.auth_token = t.auth_token + WHERE t.id = ? AND t.user_id = ?"#, + ) + .bind(token_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to look up token device binding")?; + + Ok(row.map(|r| r.get("serial"))) + } + /// Delete a specific auth token by ID, scoped to a user. pub async fn delete_auth_token_by_id(&self, token_id: i64, user_id: i64) -> Result { let result = sqlx::query("DELETE FROM auth_tokens WHERE id = ? AND user_id = ?") diff --git a/src/web.rs b/src/web.rs index e7a5335..398f4bf 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,17 +1,26 @@ use axum::{ extract::{DefaultBodyLimit, Path, Query, State}, - http::{HeaderMap, HeaderValue, StatusCode}, - response::{Html, IntoResponse, Redirect}, - routing::{get, post}, + http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, + response::{Html, IntoResponse, Redirect, Response}, + routing::{delete, get, post}, Form, Json, Router, }; -use axum_login::{login_required, AuthUser, AuthnBackend}; +use axum_login::{AuthUser, AuthnBackend}; +use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; use tower_sessions::Session; use tracing::error; use uuid::Uuid; -use crate::auth::{AuthSession, Backend, Credentials}; +/// The Leptos SPA bundle produced by `trunk build` in `web-ui/`. +/// +/// Baked into the binary for release (single-binary deploy); read from disk in +/// debug builds so a `trunk build` is picked up without recompiling the server. +#[derive(RustEmbed)] +#[folder = "web-ui/dist/"] +struct WebAssets; + +use crate::auth::{AuthSession, Credentials}; use crate::kobo::auth::AppState; use crate::upload::handler::{api_delete_upload, api_upload}; @@ -54,70 +63,104 @@ struct TokenJson { /// presents `token` (JWT-wrapped, as its `KoboAccessToken`) via /// `Authorization: Bearer` when calling it. api_endpoint: String, + /// Serial of a device currently bound to this token, if any. The UI shows a + /// badge and, on delete, confirms before revoking a device's token. + device_serial: Option, } /// Build the web UI router (mounted at server root). /// -/// Protected routes (books, covers, API) require login. -/// Login and logout routes are public. +/// The browser UI is a Leptos client-side SPA (`web-ui/`), served as static +/// assets with a client-routing fallback to `index.html`. Data lives behind the +/// `/api/...` and `/covers/...` routes, which enforce auth per-handler and +/// return `401` (not a redirect) so the SPA can react; the SPA shell itself is +/// public and gates its own content via `/api/me`. Auth POST/OIDC/logout routes +/// are the browser's login flow. None of this touches the Kobo `/kobo/...` +/// wire contract. pub fn router(max_upload_size: usize) -> Router { - let protected = Router::new() - .route("/", get(index)) - .route("/setup", get(setup)) + let api = Router::new() .route("/api/books", get(api_books)) .route("/api/me", get(api_me)) .route("/api/tokens", get(api_list_tokens)) .route("/api/tokens", post(api_create_token)) .route("/api/register-device", post(api_register_device)) .route("/api/ingest/calibre", post(api_ingest_calibre)) - .route( - "/api/tokens/{token_id}", - axum::routing::delete(api_delete_token), - ) + .route("/api/tokens/{token_id}", delete(api_delete_token)) .route("/covers/{book_id}", get(cover)) - .route_layer(login_required!(Backend, login_url = "/login")); - - // Upload routes authenticate via the `ApiUser` extractor (browser session - // cookie OR `Authorization: Bearer` token), so they must sit OUTSIDE the - // `login_required!` layer — that layer 302-redirects any request without a - // session to the HTML login page, before a token-only client (the Calibre - // plugin) could ever be authenticated. - let uploads = Router::new() + // Upload routes authenticate via the `ApiUser` extractor (browser + // session cookie OR `Authorization: Bearer` token) so the Calibre + // plugin can post with a token. .route( "/api/upload", post(api_upload).layer(DefaultBodyLimit::max(max_upload_size)), ) - .route("/api/upload/{id}", axum::routing::delete(api_delete_upload)); + .route("/api/upload/{id}", delete(api_delete_upload)); let public = Router::new() - .route("/login", get(login_page)) .route("/login", post(login)) - .route("/register", get(register_page)) .route("/register", post(register)) .route("/api/auth_config", get(api_auth_config)) .route("/auth/oidc/login", get(oidc_login)) .route("/auth/oidc/callback", get(oidc_callback)) .route("/logout", get(logout)); - protected.merge(uploads).merge(public) + // Everything else (/, /login, /setup, /register, hashed JS/WASM/CSS) is + // served from the embedded SPA bundle, falling back to index.html for + // client-side routes. + api.merge(public).fallback(static_handler) } -// ---- Protected Handlers ---- +// ---- Static SPA serving ---- + +/// Serve an embedded SPA asset, falling back to `index.html` for client routes. +async fn static_handler(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; -/// GET / — Serve the main book listing page. -async fn index() -> Html<&'static str> { - Html(include_str!("../web/index.html")) + if let Some(file) = WebAssets::get(path) { + return asset_response(path, file.data.into_owned()); + } + + // Unknown path: hand the SPA its shell so client-side routing can render it. + match WebAssets::get("index.html") { + Some(file) => Html(file.data.into_owned()).into_response(), + None => ( + StatusCode::SERVICE_UNAVAILABLE, + "Web UI not built (run `trunk build` in web-ui/)", + ) + .into_response(), + } } -/// GET /setup — Serve the browser-based Kobo device setup page. -async fn setup() -> Html<&'static str> { - Html(include_str!("../web/setup.html")) +/// Wrap embedded asset bytes in a response with a content type from the path. +fn asset_response(path: &str, data: Vec) -> Response { + let mime = match path.rsplit('.').next() { + Some("html") => "text/html; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("wasm") => "application/wasm", + Some("css") => "text/css; charset=utf-8", + Some("json") => "application/json", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("ico") => "image/x-icon", + _ => "application/octet-stream", + }; + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime)); + (headers, data).into_response() } +// ---- Protected Handlers ---- + /// GET /api/books — Return all books as JSON. async fn api_books( + auth_session: AuthSession, State(state): State, ) -> Result>, (StatusCode, String)> { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + let books = state.books.list_catalog().await.map_err(|e| { error!("Failed to fetch books: {e:?}"); (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) @@ -154,9 +197,14 @@ async fn api_me(auth_session: AuthSession) -> Result, StatusCode> /// GET /covers/{book_id} — Serve a book cover image. async fn cover( + auth_session: AuthSession, State(state): State, Path(book_id): Path, ) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + let book = state .books .fetch_book_by_id(book_id) @@ -223,6 +271,7 @@ async fn api_list_tokens( id: t.id, api_endpoint: format!("{base_url}/kobo"), token: t.auth_token, + device_serial: t.device_serial, }) .collect(); @@ -254,6 +303,8 @@ async fn api_create_token( id, api_endpoint: format!("{base_url}/kobo"), token, + // A freshly minted token is not yet bound to any device. + device_serial: None, })) } @@ -323,16 +374,50 @@ async fn api_ingest_calibre( Ok(Json(serde_json::json!({ "ingested": count }))) } +/// Query parameters for `DELETE /api/tokens/{token_id}`. +#[derive(Deserialize)] +struct DeleteTokenParams { + /// Proceed even when the token is bound to a device. + #[serde(default)] + force: bool, +} + /// DELETE /api/tokens/{token_id} — Delete a specific Kobo auth token. +/// +/// Deleting a token a Kobo device is actively syncing with breaks that device +/// (its Bearer stops resolving, so `/v1/initialization` falls back to Kobo and +/// covers/annotations break). So when the token is bound in +/// `device_registrations` and `?force=true` is not set, this returns `409` with +/// the bound serial instead of deleting — a confirmation, not a hard denial, so +/// a lost or stolen device's token can still be revoked with `force`. async fn api_delete_token( auth_session: AuthSession, State(state): State, Path(token_id): Path, -) -> Result { + Query(params): Query, +) -> Result { let user = auth_session .user .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + if !params.force { + let bound = state + .app_db + .token_device_serial(token_id, user.id()) + .await + .map_err(|e| { + error!("Failed to check token binding: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + if let Some(serial) = bound { + return Ok(( + StatusCode::CONFLICT, + Json(serde_json::json!({ "device_serial": serial })), + ) + .into_response()); + } + } + let deleted = state .app_db .delete_auth_token_by_id(token_id, user.id()) @@ -343,7 +428,7 @@ async fn api_delete_token( })?; if deleted { - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT.into_response()) } else { Err((StatusCode::NOT_FOUND, "Token not found".to_string())) } @@ -351,14 +436,6 @@ async fn api_delete_token( // ---- Public Handlers (Login / Logout) ---- -/// GET /login — Serve the login page. -async fn login_page(auth_session: AuthSession) -> impl IntoResponse { - if auth_session.user.is_some() { - return Redirect::to("/").into_response(); - } - Html(include_str!("../web/login.html")).into_response() -} - /// POST /login — Authenticate the user with local credentials. async fn login( mut auth_session: AuthSession, @@ -499,21 +576,6 @@ async fn oidc_callback( } } -/// GET /register — Serve the signup page, or redirect to login when -/// registration is disabled or the user is already logged in. -async fn register_page( - auth_session: AuthSession, - State(state): State, -) -> impl IntoResponse { - if auth_session.user.is_some() { - return Redirect::to("/").into_response(); - } - if !state.local_login || !state.config.allow_registration { - return Redirect::to("/login").into_response(); - } - Html(include_str!("../web/register.html")).into_response() -} - /// POST /register — Create a new web user, then log them in. async fn register( mut auth_session: AuthSession, diff --git a/web-ui/.gitignore b/web-ui/.gitignore new file mode 100644 index 0000000..8b84bc4 --- /dev/null +++ b/web-ui/.gitignore @@ -0,0 +1,2 @@ +/dist +/target diff --git a/web-ui/Cargo.lock b/web-ui/Cargo.lock new file mode 100644 index 0000000..9d6277b --- /dev/null +++ b/web-ui/Cargo.lock @@ -0,0 +1,1946 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.19", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.119", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasm-bindgen", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6420f887c48417e9e86c6cf61274eb231830cccc100e49613f7952e269a1fe1" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.3.0", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.19", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4202275d95a142fa209a1e35e91c250a710c5600731372cd3464a39ed01573d6" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hydration_context" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96" +dependencies = [ + "futures", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kobors-web-ui" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "gloo-net 0.7.0", + "js-sys", + "leptos", + "leptos_router", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "leptos" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "getrandom", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.19", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.19", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "or_poisoned", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.11.0", + "convert_case_extras", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "leptos_router" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "318c035b5995324c2f60900978ac67da3b21e6062a6e904f8b93b38eddc4e3a2" +dependencies = [ + "any_spawner", + "either_of", + "futures", + "gloo-net 0.6.0", + "js-sys", + "leptos", + "leptos_router_macro", + "or_poisoned", + "reactive_graph", + "rustc_version", + "send_wrapper", + "tachys", + "thiserror 2.0.19", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_router_macro" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409c0bd99f986c3cfa1a4db2443c835bc602ded1a12784e22ecb28c3ed5a2ae2" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "leptos_server" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reactive_graph" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.19", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", + "syn_derive", + "thiserror 2.0.19", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "server_fn" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "futures", + "gloo-net 0.6.0", + "http", + "js-sys", + "or_poisoned", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.19", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tachys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8" +dependencies = [ + "base16", + "quote", + "sha2", + "syn 2.0.119", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml new file mode 100644 index 0000000..7e001a8 --- /dev/null +++ b/web-ui/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "kobors-web-ui" +version = "0.1.0" +edition = "2021" +description = "Leptos CSR frontend for the kobors Kobo sync server" +publish = false + +# Own workspace root: keep this wasm crate independent of the parent server +# package so `cargo build` at the repo root never tries to build it for the host +# target (Leptos CSR only compiles for wasm32). +[workspace] + +[dependencies] +leptos = { version = "0.8", features = ["csr"] } +leptos_router = "0.8" + +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +gloo-net = { version = "0.7", features = ["http"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +console_error_panic_hook = "0.1" + +[dependencies.web-sys] +version = "0.3" +features = [ + "Window", + "Document", + "Location", + "Navigator", + "Clipboard", + "Element", + "HtmlElement", + "HtmlInputElement", + "HtmlSelectElement", + "Event", + "EventTarget", + "File", + "FileList", + "FormData", + "Blob", +] + +# A CSR SPA has no meaningful non-wasm build; keep it lean for size. +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/web-ui/index.html b/web-ui/index.html new file mode 100644 index 0000000..7796b27 --- /dev/null +++ b/web-ui/index.html @@ -0,0 +1,11 @@ + + + + + + kobors + + + + + diff --git a/web-ui/setup_glue.js b/web-ui/setup_glue.js new file mode 100644 index 0000000..86e3c93 --- /dev/null +++ b/web-ui/setup_glue.js @@ -0,0 +1,140 @@ +// Browser-storage glue for the device-setup page. +// +// The File System Access API and sql.js (SQLite compiled to WASM) are +// inherently JS-bound, so this module keeps that I/O in JS while the Leptos +// component owns the UI and state. `setupKobo` reports progress through the +// `onStep(kind, message)` callback (`kind` is "pending" | "ok" | "err") and +// resolves on success / rejects with an Error on failure. +// +// sql.js is loaded from a CDN (prototype). For a fully offline/self-hosted +// deploy, vendor sql-wasm.js + sql-wasm.wasm and serve them from kobors. + +const SQLJS_BASE = "https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/"; + +/// Whether the browser exposes the File System Access API. +export function supportsFsAccess() { + return typeof window !== "undefined" && !!window.showDirectoryPicker; +} + +function b64url(obj) { + return btoa(JSON.stringify(obj)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +// Unsigned, far-future-exp KoboAccessToken JWT; firmware never verifies it. +// The kobors auth token rides in the `kobors_token` claim. +function koborsJwt(token) { + return ( + b64url({ alg: "none", typ: "JWT" }) + + "." + + b64url({ sub: "kobors", exp: 4102444800, kobors_token: token }) + + "." + ); +} + +// Rewrite Kobo eReader.conf: drop any api_endpoint lines, ensure the +// [OneStoreServices] section exists, then set api_endpoint under it. +function patchConf(text, apiEndpoint) { + let lines = text.split(/\r?\n/).filter((l) => !/^\s*api_endpoint\s*=/.test(l)); + if (!lines.some((l) => l.trim() === "[OneStoreServices]")) { + if (lines.length && lines[lines.length - 1].trim() !== "") lines.push(""); + lines.push("[OneStoreServices]"); + } + const idx = lines.findIndex((l) => l.trim() === "[OneStoreServices]"); + lines.splice(idx + 1, 0, "api_endpoint=" + apiEndpoint); + return lines.join("\n").replace(/\n*$/, "\n"); +} + +async function readText(fileHandle) { + return (await fileHandle.getFile()).text(); +} + +async function writeFile(fileHandle, data) { + const w = await fileHandle.createWritable(); + await w.write(data); + await w.close(); +} + +let sqlLoad = null; +function loadSqlScript() { + if (!sqlLoad) { + sqlLoad = new Promise((resolve, reject) => { + if (window.initSqlJs) { + resolve(); + return; + } + const s = document.createElement("script"); + s.src = SQLJS_BASE + "sql-wasm.js"; + s.onload = () => resolve(); + s.onerror = () => reject(new Error("Failed to load sql.js from CDN")); + document.head.appendChild(s); + }); + } + return sqlLoad; +} + +export async function setupKobo(apiEndpoint, token, onStep) { + onStep("pending", "Waiting for you to pick the Kobo drive…"); + const root = await window.showDirectoryPicker({ id: "kobo", mode: "readwrite" }); + + let kobo; + try { + kobo = await root.getDirectoryHandle(".kobo"); + } catch { + throw new Error("That folder has no .kobo directory — is it the KOBOeReader drive?"); + } + onStep("ok", `Selected “${root.name}”`); + + // 1) api_endpoint in Kobo eReader.conf + onStep("pending", "Updating Kobo eReader.conf…"); + const koboDir = await kobo.getDirectoryHandle("Kobo", { create: true }); + const confHandle = await koboDir.getFileHandle("Kobo eReader.conf", { create: true }); + let confText = ""; + try { + confText = await readText(confHandle); + } catch { + confText = ""; + } + await writeFile(confHandle, patchConf(confText, apiEndpoint)); + onStep("ok", "api_endpoint → " + apiEndpoint); + + // 2) fake signed-in user row in KoboReader.sqlite + onStep("pending", "Patching KoboReader.sqlite…"); + await loadSqlScript(); + const SQL = await window.initSqlJs({ locateFile: (f) => SQLJS_BASE + f }); + const dbHandle = await kobo.getFileHandle("KoboReader.sqlite"); + const bytes = new Uint8Array(await (await dbHandle.getFile()).arrayBuffer()); + const db = new SQL.Database(bytes); + + // Only set columns that exist on this firmware's schema. + const cols = new Set(); + const info = db.exec("PRAGMA table_info(user)"); + if (info.length) for (const row of info[0].values) cols.add(row[1]); + if (!cols.has("UserID")) throw new Error("No `user` table found in KoboReader.sqlite."); + + const want = { + UserID: "-", + UserKey: "kobors", + UserEmail: "reader@kobors.local", + UserDisplayName: "Reader", + Storefront: "US", + IsOneStoreAccount: "true", + KoboAccessToken: koborsJwt(token), + KoboAccessTokenExpiry: "2099-12-31T23:59:59.0000000Z", + }; + const use = Object.entries(want).filter(([k]) => cols.has(k)); + const sql = + "INSERT OR REPLACE INTO user (" + + use.map(([k]) => k).join(", ") + + ") VALUES (" + + use.map(() => "?").join(", ") + + ")"; + db.run(sql, use.map(([, v]) => v)); + + const out = db.export(); + db.close(); + await writeFile(dbHandle, out); + onStep("ok", "Signed-in user row written (skips the setup wizard)"); +} diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs new file mode 100644 index 0000000..d5067b9 --- /dev/null +++ b/web-ui/src/api.rs @@ -0,0 +1,195 @@ +//! Typed wrappers over the kobors JSON API. +//! +//! Every call targets a same-origin `/api/...` route the axum server already +//! exposes; the SPA is authenticated by the browser session cookie, so no +//! credentials are threaded through here. A `401` surfaces as +//! [`ApiError::Unauthorized`] so callers can bounce to the login route. + +use serde::Deserialize; +use wasm_bindgen::JsValue; +use web_sys::FormData; + +/// A book in the catalog, as returned by `GET /api/books`. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct Book { + pub id: i64, + pub uuid: String, + pub title: String, + pub authors: Vec, + pub has_cover: bool, + /// `"calibre"` or `"upload"` — only uploads get a delete control. + pub source: String, +} + +/// A Kobo auth token, as returned by `GET`/`POST /api/tokens`. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct Token { + pub id: i64, + pub token: String, + /// Token-free base to configure as the device `api_endpoint`. + pub api_endpoint: String, + /// Serial of a device bound to this token, if any. + #[serde(default)] + pub device_serial: Option, +} + +/// Outcome of a token deletion request. +#[derive(Debug, Clone, PartialEq)] +pub enum DeleteOutcome { + /// The token was deleted. + Deleted, + /// The token is bound to the named device; deletion needs `force`. + DeviceBound(String), +} + +/// The current user, as returned by `GET /api/me`. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct User { + pub username: String, +} + +/// Public login-method flags from `GET /api/auth_config`. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct AuthConfig { + pub oidc_enabled: bool, + pub local_login: bool, + pub registration_enabled: bool, +} + +/// Result of an uploaded-book request (`POST /api/upload`). +#[derive(Debug, Clone, Deserialize)] +pub struct UploadedBook { + pub title: String, +} + +/// A failed API call: either an unauthenticated response or any other error. +#[derive(Debug, Clone, PartialEq)] +pub enum ApiError { + /// The server returned `401` — the caller should redirect to `/login`. + Unauthorized, + /// Any other failure, with a human-readable message. + Other(String), +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::Unauthorized => f.write_str("Not logged in"), + ApiError::Other(msg) => f.write_str(msg), + } + } +} + +/// Shorthand result type for API calls. +pub type ApiResult = Result; + +impl From for ApiError { + fn from(e: gloo_net::Error) -> Self { + ApiError::Other(e.to_string()) + } +} + +/// Map a non-success status to an [`ApiError`], reading the body for context. +async fn check(resp: gloo_net::http::Response) -> ApiResult { + if resp.ok() { + return Ok(resp); + } + if resp.status() == 401 { + return Err(ApiError::Unauthorized); + } + let body = resp.text().await.unwrap_or_default(); + let msg = if body.is_empty() { + format!("HTTP {}", resp.status()) + } else { + body + }; + Err(ApiError::Other(msg)) +} + +/// `GET /api/auth_config` — which login methods to render. +pub async fn auth_config() -> ApiResult { + let resp = gloo_net::http::Request::get("/api/auth_config") + .send() + .await?; + Ok(check(resp).await?.json().await?) +} + +/// `GET /api/me` — the current user, or `None` when unauthenticated. +pub async fn me() -> ApiResult> { + let resp = gloo_net::http::Request::get("/api/me").send().await?; + match resp.status() { + 401 => Ok(None), + _ => Ok(Some(check(resp).await?.json().await?)), + } +} + +/// `GET /api/books` — the full catalog. +pub async fn books() -> ApiResult> { + let resp = gloo_net::http::Request::get("/api/books").send().await?; + Ok(check(resp).await?.json().await?) +} + +/// `GET /api/tokens` — the user's Kobo auth tokens. +pub async fn tokens() -> ApiResult> { + let resp = gloo_net::http::Request::get("/api/tokens").send().await?; + Ok(check(resp).await?.json().await?) +} + +/// `POST /api/tokens` — mint a new Kobo auth token. +pub async fn create_token() -> ApiResult { + let resp = gloo_net::http::Request::post("/api/tokens").send().await?; + Ok(check(resp).await?.json().await?) +} + +/// `DELETE /api/tokens/{id}` — revoke a token. +/// +/// Without `force`, a token bound to a device yields `409` and the bound serial +/// ([`DeleteOutcome::DeviceBound`]) instead of being deleted, so the caller can +/// confirm before breaking a device's sync. +pub async fn delete_token(id: i64, force: bool) -> ApiResult { + let url = if force { + format!("/api/tokens/{id}?force=true") + } else { + format!("/api/tokens/{id}") + }; + let resp = gloo_net::http::Request::delete(&url).send().await?; + match resp.status() { + 401 => Err(ApiError::Unauthorized), + 409 => { + #[derive(Deserialize)] + struct Bound { + device_serial: String, + } + let bound: Bound = resp.json().await?; + Ok(DeleteOutcome::DeviceBound(bound.device_serial)) + } + _ => check(resp).await.map(|_| DeleteOutcome::Deleted), + } +} + +/// `POST /api/register-device` — arm a token so the next `device_auth` binds to it. +pub async fn register_device(token: &str) -> ApiResult<()> { + let resp = gloo_net::http::Request::post("/api/register-device") + .header("Content-Type", "application/json") + .body(format!("{{\"token\":{}}}", serde_json::to_string(token).unwrap_or_default()))? + .send() + .await?; + check(resp).await.map(|_| ()) +} + +/// `POST /api/upload` — upload an EPUB via multipart form data. +pub async fn upload(form: FormData) -> ApiResult { + let resp = gloo_net::http::Request::post("/api/upload") + .body(JsValue::from(form))? + .send() + .await?; + Ok(check(resp).await?.json().await?) +} + +/// `DELETE /api/upload/{id}` — remove an uploaded book. +pub async fn delete_upload(id: i64) -> ApiResult<()> { + let resp = gloo_net::http::Request::delete(&format!("/api/upload/{id}")) + .send() + .await?; + check(resp).await.map(|_| ()) +} diff --git a/web-ui/src/components/app.rs b/web-ui/src/components/app.rs new file mode 100644 index 0000000..9851da0 --- /dev/null +++ b/web-ui/src/components/app.rs @@ -0,0 +1,40 @@ +//! Top-level router. All routes are client-side; the server serves this same +//! SPA shell for every non-API path (SPA fallback) and enforces auth on the +//! `/api/...` routes, so page components gate their own content via `/api/me`. + +use leptos::prelude::*; +use leptos_router::components::{Route, Router, Routes}; +use leptos_router::path; + +use crate::components::library::Library; +use crate::components::login::Login; +use crate::components::register::Register; +use crate::components::setup::Setup; + +#[component] +pub fn App() -> impl IntoView { + view! { + + + + + + + + + } +} + +/// Fallback view for an unmatched path. +#[component] +fn NotFound() -> impl IntoView { + view! { +
+
+ "kobors" +

"Page not found."

+ "Back to the library" +
+
+ } +} diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs new file mode 100644 index 0000000..d28d4b3 --- /dev/null +++ b/web-ui/src/components/library.rs @@ -0,0 +1,312 @@ +//! `/` — the main library view: current user, Kobo token management, and the +//! book grid with EPUB upload. All data comes from the protected `/api/...` +//! routes; an unauthenticated `/api/me` bounces the browser to `/login`. + +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; +use web_sys::{FormData, HtmlInputElement}; + +use crate::api::{self, ApiError, ApiResult, Book, Token, User}; + +/// A destructive action awaiting confirmation in the modal. +#[derive(Clone)] +enum Pending { + /// First-stage token delete: the server may still refuse with a 409 if the + /// token is device-bound, which escalates to [`Pending::ForceDeleteToken`]. + DeleteToken(i64), + /// Confirmed delete of a device-bound token (retries with `force`). The + /// device serial is already in the prompt text stored alongside it. + ForceDeleteToken(i64), + DeleteBook(i64), +} + +#[component] +pub fn Library() -> impl IntoView { + let navigate = use_navigate(); + + // ---- current user (also the auth gate) ---- + let me = RwSignal::new(None::>>); + { + let navigate = navigate.clone(); + spawn_local(async move { + let result = api::me().await; + if matches!(result, Ok(None) | Err(ApiError::Unauthorized)) { + navigate("/login", Default::default()); + } + me.set(Some(result)); + }); + } + + // ---- tokens (reloadable) ---- + let tokens = RwSignal::new(None::>>); + let reload_tokens = move || { + spawn_local(async move { tokens.set(Some(api::tokens().await)) }); + }; + reload_tokens(); + + // ---- books (reloadable) ---- + let books = RwSignal::new(None::>>); + let reload_books = move || { + spawn_local(async move { books.set(Some(api::books().await)) }); + }; + reload_books(); + + // ---- ui state ---- + let generating = RwSignal::new(false); + let upload_status = RwSignal::new(None::<(String, &'static str)>); + let copied = RwSignal::new(None::); + let confirm = RwSignal::new(None::<(String, Pending)>); + let file_input: NodeRef = NodeRef::new(); + + // ---- handlers ---- + let generate = move |_| { + generating.set(true); + spawn_local(async move { + let _ = api::create_token().await; + reload_tokens(); + generating.set(false); + }); + }; + + let copy = move |id: i64, endpoint: String| { + if let Some(clip) = web_sys::window().map(|w| w.navigator().clipboard()) { + let _ = clip.write_text(&endpoint); + copied.set(Some(id)); + set_timeout( + move || { + if copied.get_untracked() == Some(id) { + copied.set(None); + } + }, + std::time::Duration::from_millis(1500), + ); + } + }; + + let on_file = move |ev: leptos::ev::Event| { + let Some(target) = ev.target() else { return }; + let Ok(input) = target.dyn_into::() else { + return; + }; + let Some(file) = input.files().and_then(|f| f.get(0)) else { + return; + }; + let name = file.name(); + let Ok(form) = FormData::new() else { return }; + if form + .append_with_blob_and_filename("file", file.as_ref(), &name) + .is_err() + { + return; + } + upload_status.set(Some((format!("Uploading {name}…"), "alert-info"))); + spawn_local(async move { + match api::upload(form).await { + Ok(book) => { + upload_status.set(Some((format!("Added “{}”.", book.title), "alert-success"))); + reload_books(); + } + Err(e) => upload_status.set(Some((format!("Upload failed: {e}"), "alert-error"))), + } + input.set_value(""); + }); + }; + + view! { +
+ "kobors" +
+ + {move || match me.get() { + Some(Ok(Some(u))) => u.username, + _ => String::new(), + }} + + "Log out" +
+
+ +
+ // ---- Kobo setup ---- +
+
+

"Kobo Setup"

+
+ "Set up a device" + +
+
+ {move || match tokens.get() { + None => view! {

"Loading tokens…"

}.into_any(), + Some(Err(e)) => view! { +

{format!("Failed to load tokens: {e}")}

+ }.into_any(), + Some(Ok(list)) if list.is_empty() => view! { +

+ "No tokens yet. Generate one to connect your Kobo device." +

+ }.into_any(), + Some(Ok(list)) => { + let cards = list.into_iter().map(|t| { + let endpoint = t.api_endpoint.clone(); + let ep_copy = endpoint.clone(); + let id = t.id; + let serial = t.device_serial.clone(); + view! { +
+
+
+ {t.token} + {serial.map(|s| view! { + + "\u{1F4F1} " {s} + + })} +
+
+ + +
+

+ "Set as " "api_endpoint" + " on the device, or use " + "Set up a device" "." +

+
+ +
+ } + }).collect_view(); + view! {
{cards}
}.into_any() + } + }} +
+ +
+ + // ---- Library ---- +
+
+

"Library"

+
+ + +
+
+ {move || upload_status.get().map(|(msg, kind)| view! { +

{msg}

+ })} + {move || match books.get() { + None => view! {

"Loading books…"

}.into_any(), + Some(Err(e)) => view! { +

{format!("Failed to load books: {e}")}

+ }.into_any(), + Some(Ok(list)) if list.is_empty() => view! { +

"No books found."

+ }.into_any(), + Some(Ok(list)) => { + let cards = list.into_iter().map(|b| { + let id = b.id; + let is_upload = b.source == "upload"; + let title = b.title.clone(); + let author = if b.authors.is_empty() { + "Unknown".to_string() + } else { + b.authors.join(", ") + }; + view! { +
+ {is_upload.then(|| view! { + + })} +
+ {if b.has_cover { + view! { }.into_any() + } else { + view! { {title.clone()} }.into_any() + }} +
+

{title.clone()}

+

{author}

+
+ } + }).collect_view(); + view! {
{cards}
}.into_any() + } + }} +
+
+ + // ---- Confirm modal ---- + {move || confirm.get().map(|(msg, pending)| { + view! { + + } + })} + } +} diff --git a/web-ui/src/components/login.rs b/web-ui/src/components/login.rs new file mode 100644 index 0000000..7726b00 --- /dev/null +++ b/web-ui/src/components/login.rs @@ -0,0 +1,89 @@ +//! `/login` — renders only the login methods the server has enabled. +//! +//! Credentials are submitted as a native form POST to `/login` (unchanged +//! server handler, which redirects on success), so the SPA never handles the +//! password itself. SSO is a plain link to `/auth/oidc/login`. + +use leptos::prelude::*; +use leptos_router::hooks::{use_navigate, use_query_map}; +use wasm_bindgen_futures::spawn_local; + +use crate::api::{self, AuthConfig}; + +#[component] +pub fn Login() -> impl IntoView { + // Already logged in? Send them to the library. + let navigate = use_navigate(); + spawn_local(async move { + if let Ok(Some(_)) = api::me().await { + navigate("/", Default::default()); + } + }); + + let query = use_query_map(); + let error_msg = move || { + query.read().get("error").map(|e| { + if e == "oidc" { + "SSO login failed. Please try again." + } else { + "Invalid username or password." + } + }) + }; + + // If the config can't load, fall back to showing the password form. + let config = LocalResource::new(|| async move { + api::auth_config().await.unwrap_or(AuthConfig { + oidc_enabled: false, + local_login: true, + registration_enabled: false, + }) + }); + + view! { +
+
+ "kobors" + {move || { + error_msg() + .map(|m| view! {
{m}
}) + }} + {move || match config.get() { + None => view! {
}.into_any(), + Some(cfg) => { + let sso = cfg.oidc_enabled; + let local = cfg.local_login; + view! { + {sso.then(|| view! { + + "Continue with SSO" + + })} + {(sso && local).then(|| view! {
"or"
})} + {local.then(|| view! { +
+
+ + +
+
+ + +
+ +
+ })} + {cfg.registration_enabled.then(|| view! { +

+ "Need an account? " "Sign up" +

+ })} + }.into_any() + } + }} +
+
+ } +} diff --git a/web-ui/src/components/mod.rs b/web-ui/src/components/mod.rs new file mode 100644 index 0000000..914becc --- /dev/null +++ b/web-ui/src/components/mod.rs @@ -0,0 +1,7 @@ +//! UI components, one module per page plus shared pieces. + +pub mod app; +pub mod library; +pub mod login; +pub mod register; +pub mod setup; diff --git a/web-ui/src/components/register.rs b/web-ui/src/components/register.rs new file mode 100644 index 0000000..3347fb5 --- /dev/null +++ b/web-ui/src/components/register.rs @@ -0,0 +1,58 @@ +//! `/register` — create a local account (native form POST to `/register`). +//! +//! Registration is only meaningful when local login is enabled; the server +//! redirects away when it is off, so this page mirrors the login page's shell. + +use leptos::prelude::*; +use leptos_router::hooks::{use_navigate, use_query_map}; +use wasm_bindgen_futures::spawn_local; + +use crate::api; + +#[component] +pub fn Register() -> impl IntoView { + // Already logged in? Send them to the library. + let navigate = use_navigate(); + spawn_local(async move { + if let Ok(Some(_)) = api::me().await { + navigate("/", Default::default()); + } + }); + + let query = use_query_map(); + let error_msg = move || { + query.read().get("error").map(|e| match e.as_str() { + "taken" => "That username is already taken.", + "short" => "Password must be at least 8 characters.", + _ => "Could not create the account. Try again.", + }) + }; + + view! { +
+
+ "kobors" + {move || { + error_msg() + .map(|m| view! {
{m}
}) + }} +
+
+ + +
+
+ + +

"At least 8 characters."

+
+ +
+

+ "Already have an account? " "Log in" +

+
+
+ } +} diff --git a/web-ui/src/components/setup.rs b/web-ui/src/components/setup.rs new file mode 100644 index 0000000..ce071f7 --- /dev/null +++ b/web-ui/src/components/setup.rs @@ -0,0 +1,238 @@ +//! `/setup` — one-click browser configuration of a factory-reset Kobo. +//! +//! The UI, token selection, and progress log are Leptos; the actual device I/O +//! (File System Access API + sql.js) lives in `setup_glue.js` and streams +//! progress back through an `onStep` callback. Registering the token with the +//! server is done from here via the Rust API client. + +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; + +use crate::api::{self, ApiError, ApiResult, Token}; + +#[wasm_bindgen(module = "/setup_glue.js")] +extern "C" { + #[wasm_bindgen(js_name = supportsFsAccess)] + fn supports_fs_access() -> bool; + + #[wasm_bindgen(js_name = setupKobo, catch)] + async fn setup_kobo( + api_endpoint: &str, + token: &str, + on_step: &js_sys::Function, + ) -> Result; +} + +/// Extract a human-readable message from a rejected JS value. +fn js_err_message(v: &JsValue) -> String { + if let Some(e) = v.dyn_ref::() { + let m: String = e.message().into(); + if !m.is_empty() { + return m; + } + } + v.as_string().unwrap_or_else(|| "Setup failed.".to_string()) +} + +#[component] +pub fn Setup() -> impl IntoView { + let navigate = use_navigate(); + let supported = supports_fs_access(); + + let tokens = RwSignal::new(None::>>); + let selected = RwSignal::new(None::); + let running = RwSignal::new(false); + let done = RwSignal::new(false); + // (kind, message) where kind is "pending" | "ok" | "err". + let log = RwSignal::new(Vec::<(String, String)>::new()); + + // Load tokens (also the auth gate), defaulting the selection to the first. + { + let navigate = navigate.clone(); + spawn_local(async move { + let result = api::tokens().await; + if matches!(result, Err(ApiError::Unauthorized)) { + navigate("/login", Default::default()); + } + if let Ok(list) = &result { + if let Some(first) = list.first() { + selected.set(Some(first.id)); + } + } + tokens.set(Some(result)); + }); + } + + let selected_token = move || { + let id = selected.get()?; + match tokens.get() { + Some(Ok(list)) => list.into_iter().find(|t| t.id == id), + _ => None, + } + }; + + let endpoint_preview = + move || selected_token().map(|t| t.api_endpoint).unwrap_or_else(|| "—".to_string()); + + let generate = move |_| { + spawn_local(async move { + if let Ok(t) = api::create_token().await { + let new_id = t.id; + let result = api::tokens().await; + if result.is_ok() { + selected.set(Some(new_id)); + } + tokens.set(Some(result)); + } + }); + }; + + let run = move |_| { + let Some(t) = selected_token() else { return }; + log.set(Vec::new()); + done.set(false); + running.set(true); + let endpoint = t.api_endpoint.clone(); + let token = t.token.clone(); + spawn_local(async move { + let cb = Closure::::new(move |kind: String, msg: String| { + log.update(|v| v.push((kind, msg))); + }); + let result = setup_kobo(&endpoint, &token, cb.as_ref().unchecked_ref()).await; + match result { + Ok(_) => { + log.update(|v| v.push(("pending".into(), "Registering device…".into()))); + match api::register_device(&token).await { + Ok(_) => { + log.update(|v| { + v.push(( + "ok".into(), + "Token armed for this device (binds on first sync)".into(), + )) + }); + done.set(true); + } + Err(e) => log.update(|v| { + v.push(( + "err".into(), + format!("Registration failed — sync may not authenticate: {e}"), + )) + }), + } + } + Err(e) => log.update(|v| v.push(("err".into(), js_err_message(&e)))), + } + drop(cb); + running.set(false); + }); + }; + + view! { +
+ + "kobors" + " / Set up device" + + "← Library" +
+ +
+
+

"One-click Kobo setup"

+

+ "Configures a " "factory-reset" + " Kobo (no Kobo account) to sync from this server. Plug the Kobo in via USB and \ + let it mount as a drive, then follow the steps. Everything runs in your browser \ + — the device files never leave your machine." +

+
+ + {(!supported).then(|| view! { +
+ "This page needs the File System Access API. Use a Chromium browser \ + (Chrome, Edge, Brave) over HTTPS or localhost." +
+ })} + + // Step 1: token +
+
+

"1.""Choose an auth token"

+ +
+ +

+ "The device will point at: " + {endpoint_preview} +

+
+ + // Step 2: run +
+

"2.""Connect & configure"

+ +

+ "You'll be asked to pick the Kobo's " "KOBOeReader" + " drive and grant write access." +

+ + {move || { + let entries = log.get(); + (!entries.is_empty()).then(|| { + let items = entries.into_iter().map(|(kind, msg)| { + let (cls, mark) = match kind.as_str() { + "ok" => ("ok", "✓ "), + "err" => ("err", "✗ "), + _ => ("pending", "… "), + }; + view! {
  • {format!("{mark}{msg}")}
  • } + }).collect_view(); + view! {
      {items}
    } + }) + }} + + {move || done.get().then(|| view! { +
    +

    "Device configured."

    +
      +
    1. "Safely eject / unmount the drive (so the changes flush)."
    2. +
    3. "Unplug the Kobo and reboot it."
    4. +
    5. "It boots to the home screen — trigger " "Sync" " (Menu → Sync)."
    6. +
    +
    + })} +
    +
    + } +} diff --git a/web-ui/src/main.rs b/web-ui/src/main.rs new file mode 100644 index 0000000..2d3de99 --- /dev/null +++ b/web-ui/src/main.rs @@ -0,0 +1,15 @@ +//! kobors web UI — a Leptos client-side-rendered SPA. +//! +//! Mounts the [`App`] router into ``. All data comes from the same-origin +//! `/api/...` routes served by the kobors axum server; this crate compiles only +//! to `wasm32-unknown-unknown` and is bundled by Trunk. + +mod api; +mod components; + +use crate::components::app::App; + +fn main() { + console_error_panic_hook::set_once(); + leptos::mount::mount_to_body(App); +} diff --git a/web-ui/styles.css b/web-ui/styles.css new file mode 100644 index 0000000..8bc4758 --- /dev/null +++ b/web-ui/styles.css @@ -0,0 +1,356 @@ +/* kobors web UI — refreshed dark theme. + Hand-written CSS (no Tailwind/CDN): the SPA ships one self-contained + stylesheet, keeping the build offline-friendly and the design fully ours. */ + +:root { + --bg: #0a0b0f; + --bg-elev: #13151c; + --bg-elev-2: #191c25; + --border: #262a36; + --border-strong: #333949; + --text: #e7e9ee; + --text-dim: #9aa0ae; + --text-faint: #626878; + --accent: #7c6cf0; + --accent-hover: #8f80f5; + --accent-quiet: #7c6cf025; + --success: #34d399; + --success-bg: #064e3b40; + --danger: #f87171; + --danger-bg: #7f1d1d40; + --warn: #fbbf24; + --warn-bg: #78350f40; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 8px 30px rgba(0, 0, 0, 0.45); + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + background: + radial-gradient(1200px 600px at 50% -10%, #16182130, transparent), + var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; } +code { font-family: var(--mono); } + +.hidden { display: none !important; } +.muted { color: var(--text-dim); } +.faint { color: var(--text-faint); } +.mono { font-family: var(--mono); } +.small { font-size: 13px; } +.tiny { font-size: 12px; } +.grow { flex: 1; min-width: 0; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.spacer { flex: 1; } +.stack { display: flex; flex-direction: column; gap: 12px; } +.row { display: flex; align-items: center; gap: 12px; } + +/* ---- Header ---- */ + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); + background: rgba(10, 11, 15, 0.7); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 20; +} + +.brand { + font-size: 18px; + font-weight: 650; + letter-spacing: -0.02em; + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; +} +.brand::before { + content: ""; + width: 18px; + height: 18px; + border-radius: 5px; + background: linear-gradient(135deg, var(--accent), #d16cf0); +} +.brand .crumb { color: var(--text-faint); font-weight: 500; } + +.container { + max-width: 1140px; + margin: 0 auto; + padding: 32px 24px 64px; +} +.container.narrow { max-width: 720px; } + +/* ---- Sections ---- */ + +.section { margin-bottom: 40px; } +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} +.section-head h2 { + font-size: 16px; + font-weight: 600; + margin: 0; + letter-spacing: -0.01em; +} +.divider { border: none; border-top: 1px solid var(--border); margin: 40px 0; } + +/* ---- Buttons ---- */ + +.btn { + appearance: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 8px 14px; + font: inherit; + font-size: 14px; + font-weight: 550; + color: var(--text); + background: var(--bg-elev-2); + border-color: var(--border); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, opacity 0.15s, transform 0.05s; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 7px; + white-space: nowrap; +} +.btn:hover { background: var(--border); } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-sm { padding: 6px 10px; font-size: 13px; } +.btn-primary { background: var(--accent); border-color: transparent; color: #fff; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-accent { background: var(--success-bg); border-color: #10b98155; color: var(--success); } +.btn-accent:hover { background: #065f4650; } +.btn-ghost { background: transparent; } +.btn-ghost:hover { background: var(--bg-elev-2); } +.btn-danger { color: var(--danger); background: transparent; border-color: transparent; } +.btn-danger:hover { background: var(--danger-bg); } +.btn-block { width: 100%; justify-content: center; padding: 10px; } + +/* ---- Cards ---- */ + +.card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.token-card { + display: flex; + align-items: flex-start; + gap: 14px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} +.token-card .endpoint-row { display: flex; gap: 8px; align-items: center; } + +/* ---- Inputs ---- */ + +.input, .select { + width: 100%; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + padding: 9px 12px; + font: inherit; + font-size: 14px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.input:focus, .select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-quiet); +} +.input.readonly { font-family: var(--mono); font-size: 12.5px; color: var(--text-dim); } +.field { margin-bottom: 16px; } +.label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 6px; } +.hint { font-size: 12px; color: var(--text-faint); margin-top: 6px; } + +/* ---- Book grid ---- */ + +.book-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 22px; +} +.book-card { position: relative; } +.book-cover { + aspect-ratio: 2 / 3; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; + margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, transform 0.15s; +} +.book-card:hover .book-cover { border-color: var(--border-strong); transform: translateY(-2px); } +.book-cover img { width: 100%; height: 100%; object-fit: cover; } +.book-cover .placeholder { + padding: 12px; + text-align: center; + font-size: 13px; + color: var(--text-faint); +} +.book-title { font-size: 14px; font-weight: 550; line-height: 1.25; } +.book-author { font-size: 12.5px; color: var(--text-dim); } +.book-del { + position: absolute; + top: 6px; + right: 6px; + z-index: 2; + width: 28px; + height: 28px; + border-radius: 7px; + border: none; + background: rgba(220, 38, 38, 0.92); + color: #fff; + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} +.book-card:hover .book-del { opacity: 1; } +.book-del:hover { background: #dc2626; } + +/* ---- Alerts ---- */ + +.alert { + border-radius: var(--radius-sm); + padding: 12px 14px; + font-size: 14px; + border: 1px solid transparent; +} +.alert-error { background: var(--danger-bg); border-color: #b91c1c66; color: #fca5a5; } +.alert-success { background: var(--success-bg); border-color: #10b98155; color: #6ee7b7; } +.alert-warn { background: var(--warn-bg); border-color: #b4530966; color: #fcd34d; } +.alert-info { background: var(--bg-elev-2); border-color: var(--border); color: var(--text-dim); } + +/* ---- Auth pages ---- */ + +.auth-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.auth-card { width: 100%; max-width: 380px; } +.auth-card .brand { justify-content: center; font-size: 24px; margin-bottom: 28px; display: flex; } +.auth-divider { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-faint); + font-size: 12px; + margin: 18px 0; +} +.auth-divider::before, .auth-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.auth-foot { text-align: center; color: var(--text-faint); font-size: 13px; margin-top: 22px; } +.auth-foot a { color: var(--text-dim); text-decoration: none; } +.auth-foot a:hover { color: var(--text); } + +/* ---- Modal ---- */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); +} +.modal { + position: relative; + width: 100%; + max-width: 400px; + background: var(--bg-elev); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; +} +.modal p { margin: 0 0 22px; font-size: 14px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; } + +/* ---- Setup log ---- */ + +.log { list-style: none; padding: 0; margin: 4px 0 0; font-family: var(--mono); font-size: 13px; } +.log li { padding: 3px 0; display: flex; gap: 8px; } +.log .ok { color: var(--success); } +.log .err { color: var(--danger); } +.log .pending { color: var(--text-dim); } + +.step-num { color: var(--text-faint); font-weight: 600; margin-right: 6px; } + +/* ---- Misc ---- */ + +.link { color: var(--text-dim); text-decoration: underline; text-underline-offset: 2px; } +.link:hover { color: var(--text); } + +.badge { + flex-shrink: 0; + font-size: 11px; + font-weight: 550; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-quiet); + color: var(--accent-hover); + border: 1px solid #7c6cf055; + white-space: nowrap; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.center-note { text-align: center; color: var(--text-dim); padding: 40px 0; } diff --git a/web/index.html b/web/index.html deleted file mode 100644 index a63b652..0000000 --- a/web/index.html +++ /dev/null @@ -1,328 +0,0 @@ - - - - - - kobors - - - -
    -

    kobors

    -
    - - Log out -
    -
    - -
    - -
    -
    -

    Kobo Setup

    -
    - - Set up a device - - -
    -
    -

    Loading tokens...

    - - - -
    - -
    - - -
    -
    -

    Library

    -
    - - -
    -
    - -

    Loading books...

    - - - -
    -
    - - - - - - - diff --git a/web/login.html b/web/login.html deleted file mode 100644 index 3736ab2..0000000 --- a/web/login.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - kobors — login - - - -
    -

    kobors

    - - - - - - - - - - -
    - - - - diff --git a/web/register.html b/web/register.html deleted file mode 100644 index 8e16d14..0000000 --- a/web/register.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - kobors — sign up - - - -
    -

    kobors

    - - - -
    -
    - - -
    -
    - - -

    At least 8 characters.

    -
    - -
    - -

    - Already have an account? - Log in -

    -
    - - - - diff --git a/web/setup.html b/web/setup.html deleted file mode 100644 index e3b439f..0000000 --- a/web/setup.html +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - Set up device — kobors - - - - - -
    -

    - kobors - / Set up device -

    - ← Library -
    - -
    -
    -

    One-click Kobo setup

    -

    - Configures a factory-reset Kobo (no Kobo account) to sync from this - server. Plug the Kobo in via USB and let it mount as a drive, then follow the steps. - Everything runs in your browser — the device files never leave your machine. -

    -
    - - - - - -
    -
    -

    1. Choose an auth token

    - -
    - -

    - The device will point at: - — -

    -
    - - -
    -

    2. Connect & configure

    - -

    - You'll be asked to pick the Kobo's KOBOeReader drive - and grant write access. -

    - - - - - - -
    -
    - - - -