From c320b507f9a7f6adae11482e75dd296ea1f2277f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 12:52:46 +0200 Subject: [PATCH 01/17] feat(sync-v2): complete native server convergence Signed-off-by: InstaZDLL --- Cargo.lock | 110 +- Cargo.toml | 2 +- README.md | 8 +- docs/M4-handoff.md | 18 +- docs/rfcs/RFC-002-waveflow-server-v2.md | 2 +- docs/rfcs/RFC-003-waveflow-sync-v2.md | 101 ++ migrations-v2/20260809000000_sync_journal.sql | 39 + src/catalog.rs | 41 + src/database.rs | 36 + src/http.rs | 1024 ++++++++++++++++- src/lib.rs | 65 +- src/services.rs | 695 ++++++++++- src/subsonic.rs | 11 +- src/sync.rs | 293 +++++ tests/v2_foundations.rs | 526 +++++++++ webapp/src/api.ts | 97 +- webapp/src/main.tsx | 6 +- webapp/src/pages.tsx | 27 +- 18 files changed, 3008 insertions(+), 93 deletions(-) create mode 100644 docs/rfcs/RFC-003-waveflow-sync-v2.md create mode 100644 migrations-v2/20260809000000_sync_journal.sql create mode 100644 src/sync.rs diff --git a/Cargo.lock b/Cargo.lock index 07998cb..8ac11fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,6 +183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -201,8 +202,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1 0.10.7", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -933,6 +936,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -942,7 +957,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -1844,6 +1859,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1857,10 +1878,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1882,6 +1913,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1891,6 +1932,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -2231,6 +2281,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2465,7 +2526,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", "thiserror", @@ -2941,6 +3002,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3100,6 +3173,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" @@ -3294,6 +3383,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -3616,6 +3714,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 2089233..551a9b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ legacy-v1 = [] anyhow = "1" async-stream = "0.3" argon2 = { version = "0.5", features = ["std"] } -axum = "0.8" +axum = { version = "0.8", features = ["ws"] } base64 = "0.22" blake3 = "1" bytes = "1" diff --git a/README.md b/README.md index e0d6ce7..053e0ad 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,13 @@ The server listens on `127.0.0.1:4533` by default and exposes: - `GET /health`: process liveness; - `GET /ready`: SQLite readiness, independent of scan progress; - `GET /openapi.json` and `GET /reference`: API contract; -- `POST /api/v2/auth/login`, `/refresh`, `/logout`: rotating local sessions. +- `POST /api/v2/auth/login`, `/refresh`, `/logout`: rotating native sessions; +- `/api/v2/web/auth/*`: memory-only browser access token plus HttpOnly rotating + refresh cookie, origin validation and CSRF protection; +- `/api/v2/sync/snapshot`, `/changes`, `/ack`, `/socket`: idempotent user-data + synchronization defined by `docs/rfcs/RFC-003-waveflow-sync-v2.md`; +- `/api/v2/admin/users`, `/libraries`, `/transcode/status`: native server + administration and dedicated Subsonic credential rotation; - `POST /api/v2/libraries/{id}/scans`: manual scan trigger; - `GET /api/v2/scans/{id}` and `/events`: status and SSE progress; - `GET /api/v2/libraries/{id}/tracks?q=...&offset=...&limit=...`: tenant-scoped catalogue/FTS browsing, paged up to 500 tracks per request. diff --git a/docs/M4-handoff.md b/docs/M4-handoff.md index 13f082f..392a6cd 100644 --- a/docs/M4-handoff.md +++ b/docs/M4-handoff.md @@ -104,8 +104,11 @@ régression. ## Ce qui reste -1. **Trancher la sécurité de session navigateur avant `v2.0` stable**, comme - détaillé dans les dettes ci-dessous. +1. **Valider puis fusionner le complément serveur M4.** Il ajoute le journal de + synchronisation documenté par RFC-003, complète l'administration native et + ferme la dette de session navigateur. Le workflow DCO accepte désormais + l'adresse de signature réellement émise par Dependabot, sans dérogation + manuelle aux protections de branche. 2. **Taguer une release uniquement sur demande explicite du user.** M3 et sa validation Symfonium sont terminés ; aucune action de compatibilité ne reste ouverte pour cette porte. @@ -130,10 +133,7 @@ vérifié. - `webapp/` n'a pas de test de composant ni de parcours : la suite couvre les gardes de redirection et les design tokens. La CI web lint (biome), construit et lance vitest. -- Les jetons de session vivent en `localStorage`, donc exposés à une XSS. C'est - le compromis SPA habituel ; un cookie éviterait cela mais ajouterait une - authentification ambiante et une surface CSRF à une API sinon purement par - en-tête. **Porte de sortie : trancher avant le tag `v2.0` stable** (pas avant - la beta) — soit adopter un cookie `httpOnly` + protection CSRF, soit acter le - risque par écrit dans ce document avec la justification retenue. Ne pas taguer - la stable tant que l'une des deux branches n'est pas tranchée. +- La dette de session navigateur est fermée par le complément M4 : access token + court en mémoire, refresh rotatif dans un cookie HttpOnly/SameSite, contrôle + d'origine et double-submit CSRF sur refresh/logout. Aucun secret de session + n'est conservé dans `localStorage`. diff --git a/docs/rfcs/RFC-002-waveflow-server-v2.md b/docs/rfcs/RFC-002-waveflow-server-v2.md index 5c896ce..6be420a 100644 --- a/docs/rfcs/RFC-002-waveflow-server-v2.md +++ b/docs/rfcs/RFC-002-waveflow-server-v2.md @@ -40,7 +40,7 @@ Audio files are always read-only. Canonical-path and symlink checks apply before The M3 beta exposes a tested Subsonic/OpenSubsonic façade. GET, form POST, XML and JSON share the same services. Only implemented extensions are advertised. Credentials in query parameters are removed from request logging. -M4 adds `/api/v2`, Authorization Code with PKCE for WaveFlow Desktop, rotating native tokens and user-data-only synchronization. The server catalogue appears in Desktop as a separate remote source. Existing local and server catalogues are not automatically merged. +M4 adds `/api/v2`, Authorization Code with PKCE for WaveFlow Desktop, rotating native tokens and user-data-only synchronization. The server catalogue appears in Desktop as a separate remote source. Existing local and server catalogues are not automatically merged. The cursor, idempotency, ACK and WebSocket contracts are frozen in [RFC-003](RFC-003-waveflow-sync-v2.md). All web, native and Subsonic writes pass through common services so playlists, favorites, ratings, queue and history converge independent of the calling protocol. diff --git a/docs/rfcs/RFC-003-waveflow-sync-v2.md b/docs/rfcs/RFC-003-waveflow-sync-v2.md new file mode 100644 index 0000000..201f64a --- /dev/null +++ b/docs/rfcs/RFC-003-waveflow-sync-v2.md @@ -0,0 +1,101 @@ +# RFC-003 — WaveFlow Desktop user-data sync v2 + +- Status: accepted +- Date: 2026-08-09 +- Scope: WaveFlow Server v2 M4 and the future Desktop remote-source adapter + +## Decision + +The server is authoritative for its catalogue. Desktop exposes it as a separate +remote source and synchronizes user-owned state only: playlists, favorites, +ratings, scrobbles/history, play queue and public shares. This protocol never +imports server tracks into the local catalogue and never guesses a local/server +track match. Reconciliation remains M5 and requires its own RFC. + +All public IDs are UUIDs and all timestamps are Unix milliseconds. REST is the +durable source of truth. The WebSocket is only an edge-triggered notification +that a newer cursor may exist. + +## Authentication + +Desktop obtains a short-lived access token and rotating refresh token through +Authorization Code + PKCE. A mutation may send: + +- `X-WaveFlow-Operation-Id: ` — stable ID for this logical mutation; +- `X-WaveFlow-Device-Id: ` — the non-revoked device created by login or + the PKCE exchange. + +The server rejects a device owned by another account. If no operation ID is +provided, the server generates one; clients that need retry safety must provide +one. An operation ID is unique per user and must never be reused for another +logical mutation. + +## Bootstrap and incremental reads + +`GET /api/v2/sync/snapshot` returns one writer-consistent representation: + +```json +{ + "cursor": 42, + "playlists": [], + "favorites": [], + "ratings": [], + "queue": null, + "history": [], + "shares": [] +} +``` + +The client atomically replaces its remote user-data projection, then continues +from `cursor`. + +`GET /api/v2/sync/changes?after=&limit=<1..500>` returns changes in +strict ascending cursor order. `next_cursor` is the last returned cursor, or +the supplied cursor for an empty page. While `has_more` is true, the client +immediately requests the next page. + +Each change has `cursor`, `event_id`, `operation_id`, optional +`origin_device_id`, `entity_type`, `entity_id`, `action`, `payload` and +`changed_at`. Supported pairs are: + +| Entity | Actions | Payload | +| --- | --- | --- | +| `playlist` | `upsert`, `delete` | id, name/comment/public when known, ordered `track_ids` | +| `favorite` | `upsert`, `delete` | `entity_type`, `entity_id`, `starred` | +| `rating` | `upsert`, `delete` | `entity_type`, `entity_id`, `rating` (0 means clear) | +| `scrobble` | `upsert`, `append` | `track_id`, `submission`, `played_at` | +| `queue` | `upsert` | ordered `track_ids`, current track, `position_ms`, client | +| `share` | `upsert`, `delete` | id and the changed share fields | + +Unknown entity types, actions and payload fields must be ignored and retained +only if a client needs to relay diagnostic data. A client that cannot apply a +known event discards its local projection and fetches a fresh snapshot. + +## Idempotency, acknowledgement and wake-up + +The operation reservation, domain mutation and journal append commit in one +SQLite transaction behind the process-wide writer gate. Repeating the same +operation ID returns the original result and never creates a second domain row +or journal event. + +`PUT /api/v2/sync/ack` with `{ "device_id": "", "cursor": 42 }` +records a monotonic per-device acknowledgement. A cursor below the stored ACK +does not move it backwards; a cursor beyond the user's latest event is rejected. +ACKs are observability and future-retention inputs, not a prerequisite for +reading later pages. + +`GET /api/v2/sync/socket?after=` upgrades to WebSocket and sends JSON +messages shaped as `{ "cursor": 43 }`. Authentication uses the same Bearer +header as REST. On every notice, reconnect, timeout or lag, the client asks +`/sync/changes`; it never treats socket delivery as state delivery. + +## Cross-protocol convergence + +Native, embedded-web and Subsonic mutations all call the same domain services. +Operations initiated by web/Subsonic receive server-generated operation IDs and +therefore appear in the same journal. Tenant filters are applied in repository +queries before data reaches any facade. + +The journal is append-only in v2.0. Retention/compaction may be introduced only +with a snapshot floor that prevents an offline client from silently skipping +events. diff --git a/migrations-v2/20260809000000_sync_journal.sql b/migrations-v2/20260809000000_sync_journal.sql new file mode 100644 index 0000000..322349e --- /dev/null +++ b/migrations-v2/20260809000000_sync_journal.sql @@ -0,0 +1,39 @@ +-- M4 user-data synchronization. The catalogue remains server-authoritative; +-- only mutations owned by an account enter this journal. +CREATE TABLE sync_operation ( + user_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE, + operation_id TEXT NOT NULL, + origin_device_id TEXT REFERENCES device(id) ON DELETE SET NULL, + result_entity_id TEXT, + event_cursor INTEGER, + created_at INTEGER NOT NULL, + applied_at INTEGER, + PRIMARY KEY (user_id, operation_id) +) STRICT; + +CREATE TABLE sync_event ( + cursor INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE, + operation_id TEXT NOT NULL, + origin_device_id TEXT REFERENCES device(id) ON DELETE SET NULL, + entity_type TEXT NOT NULL CHECK ( + entity_type IN ('playlist', 'favorite', 'rating', 'scrobble', 'queue', 'share') + ), + entity_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('upsert', 'delete', 'append')), + payload_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(payload_json)), + changed_at INTEGER NOT NULL, + UNIQUE (user_id, operation_id), + FOREIGN KEY (user_id, operation_id) + REFERENCES sync_operation(user_id, operation_id) ON DELETE CASCADE +) STRICT; +CREATE INDEX sync_event_user_cursor_idx ON sync_event(user_id, cursor); + +CREATE TABLE sync_ack ( + user_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES device(id) ON DELETE CASCADE, + cursor INTEGER NOT NULL CHECK (cursor >= 0), + acknowledged_at INTEGER NOT NULL, + PRIMARY KEY (user_id, device_id) +) STRICT; diff --git a/src/catalog.rs b/src/catalog.rs index 34d4217..1c57491 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -16,6 +16,16 @@ pub struct LibraryRecord { pub root_path: PathBuf, } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct LibraryAccess { + pub id: Uuid, + pub name: String, + pub visibility: crate::database::LibraryVisibility, + pub role: crate::database::LibraryRole, + pub last_scan_started_at: Option, + pub last_scan_completed_at: Option, +} + #[derive(Debug, Clone)] pub struct ExistingTrack { pub id: Uuid, @@ -122,6 +132,37 @@ pub struct TrackRecord { } impl Database { + pub async fn libraries_for_user( + &self, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT l.id, l.name, l.visibility, m.role, l.last_scan_started_at, \ + l.last_scan_completed_at \ + FROM library l JOIN library_member m ON m.library_id=l.id \ + WHERE m.user_id=? ORDER BY l.name COLLATE NOCASE, l.id", + ) + .bind(user_id.to_string()) + .fetch_all(self.pool()) + .await?; + rows.into_iter() + .map(|row| { + Ok(LibraryAccess { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + visibility: crate::database::LibraryVisibility::from_str( + row.try_get::<&str, _>("visibility")?, + ) + .map_err(|error| sqlx::Error::Decode(error.into()))?, + role: crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + .map_err(|error| sqlx::Error::Decode(error.into()))?, + last_scan_started_at: row.try_get("last_scan_started_at")?, + last_scan_completed_at: row.try_get("last_scan_completed_at")?, + }) + }) + .collect() + } + pub async fn all_libraries(&self) -> Result, sqlx::Error> { let rows = sqlx::query("SELECT id, name, root_path FROM library ORDER BY created_at") .fetch_all(self.pool()) diff --git a/src/database.rs b/src/database.rs index e3f66e5..7c970a2 100644 --- a/src/database.rs +++ b/src/database.rs @@ -169,6 +169,42 @@ pub struct AuthorizationRecord { } impl Database { + pub async fn setup_required(&self) -> Result { + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM account") + .fetch_one(&self.pool) + .await?; + Ok(count == 0) + } + + pub async fn bootstrap_admin( + &self, + username: &str, + password_hash: &str, + now_ms: i64, + ) -> Result, sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.pool.begin().await?; + let id = Uuid::new_v4(); + let inserted = sqlx::query( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + SELECT ?, ?, ?, 'admin', ?, ? WHERE NOT EXISTS (SELECT 1 FROM account)", + ) + .bind(id.to_string()) + .bind(username.trim()) + .bind(password_hash) + .bind(now_ms) + .bind(now_ms) + .execute(&mut *tx) + .await? + .rows_affected() + == 1; + if inserted { + insert_audit(&mut tx, Some(id), "instance.bootstrapped", Some(id), now_ms).await?; + } + tx.commit().await?; + Ok(inserted.then_some(id)) + } + pub async fn open(config: &Config) -> anyhow::Result { tokio::fs::create_dir_all(&config.data_dir).await?; let options = SqliteConnectOptions::new() diff --git a/src/http.rs b/src/http.rs index 2679b24..47c982f 100644 --- a/src/http.rs +++ b/src/http.rs @@ -3,8 +3,11 @@ use std::{convert::Infallible, time::Duration}; use axum::{ - extract::{Path, Query, State}, - http::{header, HeaderMap, StatusCode}, + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Path, Query, State, + }, + http::{header, HeaderMap, HeaderValue, StatusCode}, response::{ sse::{Event, KeepAlive}, IntoResponse, Response, Sse, @@ -12,12 +15,17 @@ use axum::{ routing::{get, post, put}, Json, Router, }; +use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use crate::{authentication::AuthError, AppState}; +const WEB_REFRESH_COOKIE: &str = "waveflow-refresh"; +const WEB_CSRF_COOKIE: &str = "waveflow-csrf"; +const WEB_CSRF_HEADER: &str = "x-waveflow-csrf"; + #[derive(Debug, Serialize, ToSchema)] pub struct ProbeResponse { pub status: &'static str, @@ -43,6 +51,15 @@ pub struct RefreshRequest { pub refresh_token: String, } +#[derive(Debug, Serialize, ToSchema)] +pub struct WebAuthResponse { + pub access_token: String, + pub token_type: &'static str, + pub expires_in: u64, + pub user: crate::authentication::AuthUser, + pub device_id: Uuid, +} + #[derive(Debug, Serialize, ToSchema)] pub struct ErrorResponse { pub code: &'static str, @@ -120,6 +137,30 @@ pub struct SaveQueueRequest { pub client: Option, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateShareRequest { + pub track_ids: Vec, + pub description: Option, + pub expires_at: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateShareRequest { + pub description: Option, + pub expires_at: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ShareResponse { + pub id: Uuid, + pub url: String, + pub description: Option, + pub expires_at: Option, + pub created_at: i64, + pub visit_count: i64, + pub track_ids: Vec, +} + #[derive(Debug, Deserialize, ToSchema)] pub struct AuthorizeRequest { pub client_id: String, @@ -164,17 +205,124 @@ pub struct NowPlayingEntry { pub started_at: i64, } +#[derive(Debug, Deserialize)] +pub struct SyncQuery { + pub after: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize)] +pub struct HistoryQuery { + pub limit: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct TranscodeStatusResponse { + pub available: bool, + pub active: usize, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateUserRequest { + pub username: String, + pub web_password: String, + pub role: crate::database::AccountRole, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateUserRequest { + pub role: Option, + pub disabled: Option, + pub library_ids: Option>, + pub subsonic_password: Option, + pub web_password: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetSubsonicCredentialRequest { + pub password: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SubsonicCredentialResponse { + /// Shown once. Only its SHA-256 hash is stored by the server. + pub api_key: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SetupStatusResponse { + pub required: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetupRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SetupResponse { + pub user_id: Uuid, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateLibraryRequest { + pub name: String, + pub path: String, + pub visibility: crate::database::LibraryVisibility, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct CreateLibraryResponse { + pub library_id: Uuid, + pub scan_id: Uuid, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetLibraryMemberRequest { + pub role: crate::database::LibraryRole, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SyncAckRequest { + pub device_id: Uuid, + pub cursor: i64, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SyncSnapshot { + pub cursor: i64, + pub playlists: Vec, + pub favorites: Vec, + pub ratings: Vec, + pub queue: Option, + pub history: Vec, + pub shares: Vec, +} + pub fn router(state: AppState) -> Router { Router::new() .route("/health", get(health)) .route("/ready", get(ready)) + .route("/api/v2/setup", get(setup_status).post(setup)) .route("/api/v2/auth/login", post(login)) .route("/api/v2/auth/refresh", post(refresh)) .route("/api/v2/auth/logout", post(logout)) + .route("/api/v2/web/auth/login", post(web_login)) + .route("/api/v2/web/auth/refresh", post(web_refresh)) + .route("/api/v2/web/auth/logout", post(web_logout)) .route("/api/v2/oauth/authorize", post(oauth_authorize)) // No auth layer: the code plus its PKCE verifier are the credential. .route("/api/v2/oauth/token", post(oauth_token)) .route("/api/v2/libraries/{library_id}/scans", post(start_scan)) + .route( + "/api/v2/libraries", + get(list_libraries).post(create_library), + ) + .route( + "/api/v2/libraries/{library_id}/members/{user_id}", + put(set_library_member).delete(remove_library_member), + ) .route("/api/v2/scans/{scan_id}", get(scan_status)) .route("/api/v2/scans/{scan_id}/events", get(scan_events)) .route("/api/v2/libraries/{library_id}/tracks", get(list_tracks)) @@ -199,9 +347,30 @@ pub fn router(state: AppState) -> Router { put(add_favorite).delete(remove_favorite), ) .route("/api/v2/ratings/{entity_type}/{entity_id}", put(set_rating)) + .route("/api/v2/ratings", get(list_ratings)) .route("/api/v2/scrobbles", post(create_scrobble)) + .route("/api/v2/history", get(list_history)) .route("/api/v2/now-playing", get(list_now_playing)) .route("/api/v2/queue", get(get_queue).put(save_queue)) + .route("/api/v2/shares", get(list_shares).post(create_share)) + .route( + "/api/v2/shares/{share_id}", + axum::routing::patch(update_share).delete(delete_share), + ) + .route("/api/v2/sync/changes", get(sync_changes)) + .route("/api/v2/sync/snapshot", get(sync_snapshot)) + .route("/api/v2/sync/ack", put(sync_ack)) + .route("/api/v2/sync/socket", get(sync_socket)) + .route("/api/v2/transcode/status", get(transcode_status)) + .route("/api/v2/admin/users", get(list_users).post(create_user)) + .route( + "/api/v2/admin/users/{username}", + axum::routing::patch(update_user).delete(delete_user), + ) + .route( + "/api/v2/admin/users/{username}/subsonic-credential", + put(set_subsonic_credential).delete(revoke_subsonic_credential), + ) .with_state(state) } @@ -252,6 +421,29 @@ pub async fn ready(State(state): State) -> Response { } } +#[utoipa::path(get, path = "/api/v2/setup", tag = "authentication", responses((status = 200, body = SetupStatusResponse)))] +pub async fn setup_status( + State(state): State, +) -> Result, ApiError> { + let required = state.db.setup_required().await.map_err(db_error)?; + Ok(Json(SetupStatusResponse { required })) +} + +#[utoipa::path(post, path = "/api/v2/setup", tag = "authentication", request_body = SetupRequest, responses((status = 201, body = SetupResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn setup( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + validate_web_origin(&state, &headers)?; + let user_id = state + .services + .bootstrap_admin(&request.username, &request.password) + .await + .map_err(service_error)?; + Ok((StatusCode::CREATED, Json(SetupResponse { user_id }))) +} + #[utoipa::path( post, path = "/api/v2/auth/login", @@ -322,6 +514,85 @@ pub async fn logout( Ok(StatusCode::NO_CONTENT) } +/// Browser sessions keep only the short-lived access token in JavaScript. The +/// rotating refresh token is an HttpOnly, same-site cookie and is therefore +/// never exposed to the embedded SPA. +#[utoipa::path( + post, + path = "/api/v2/web/auth/login", + tag = "authentication", + request_body = LoginRequest, + responses( + (status = 200, body = WebAuthResponse), + (status = 401, body = ErrorResponse), + (status = 403, body = ErrorResponse) + ) +)] +pub async fn web_login( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + validate_web_origin(&state, &headers)?; + let tokens = state + .auth + .login(&request.username, &request.password, &request.device_name) + .await + .map_err(ApiError::from)?; + web_auth_response(&state, &headers, tokens) +} + +#[utoipa::path( + post, + path = "/api/v2/web/auth/refresh", + tag = "authentication", + responses( + (status = 200, body = WebAuthResponse), + (status = 401, body = ErrorResponse), + (status = 403, body = ErrorResponse) + ) +)] +pub async fn web_refresh( + State(state): State, + headers: HeaderMap, +) -> Result { + validate_web_request(&state, &headers)?; + let refresh_token = cookie_value(&headers, WEB_REFRESH_COOKIE).ok_or(ApiError::Unauthorized)?; + let tokens = state + .auth + .refresh(refresh_token) + .await + .map_err(ApiError::from)?; + web_auth_response(&state, &headers, tokens) +} + +#[utoipa::path( + post, + path = "/api/v2/web/auth/logout", + tag = "authentication", + responses( + (status = 204), + (status = 401, body = ErrorResponse), + (status = 403, body = ErrorResponse) + ) +)] +pub async fn web_logout( + State(state): State, + headers: HeaderMap, +) -> Result { + validate_web_request(&state, &headers)?; + let access_token = bearer_token(&headers).ok_or(ApiError::Unauthorized)?; + state + .auth + .logout(access_token) + .await + .map_err(ApiError::from)?; + let mut response = StatusCode::NO_CONTENT.into_response(); + append_cookie(&mut response, expired_cookie(WEB_REFRESH_COOKIE, true))?; + append_cookie(&mut response, expired_cookie(WEB_CSRF_COOKIE, false))?; + Ok(response) +} + #[utoipa::path(post, path = "/api/v2/libraries/{library_id}/scans", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 202, body = ScanQueuedResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn start_scan( State(state): State, @@ -346,6 +617,135 @@ pub async fn start_scan( Ok((StatusCode::ACCEPTED, Json(ScanQueuedResponse { scan_id }))) } +#[utoipa::path(get, path = "/api/v2/libraries", tag = "catalog", responses((status = 200, body = [crate::catalog::LibraryAccess]), (status = 401, body = ErrorResponse)))] +pub async fn list_libraries( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers).await?; + state + .db + .libraries_for_user(user.id) + .await + .map(Json) + .map_err(db_error) +} + +#[utoipa::path(post, path = "/api/v2/libraries", tag = "administration", request_body = CreateLibraryRequest, responses((status = 201, body = CreateLibraryResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_library( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; + let path = std::path::PathBuf::from(&request.path); + let metadata = std::fs::symlink_metadata(&path).map_err(|_| ApiError::Validation)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() || request.name.trim().is_empty() { + return Err(ApiError::Validation); + } + let canonical = std::fs::canonicalize(&path).map_err(|_| ApiError::Validation)?; + let library_id = state + .db + .create_library( + actor.id, + &request.name, + &canonical, + request.visibility, + crate::authentication::now_ms(), + ) + .await + .map_err(db_error)?; + let scan_id = state + .scanner + .trigger( + crate::catalog::LibraryRecord { + id: library_id, + name: request.name, + root_path: canonical, + }, + Some(actor.id), + "library_added", + ) + .await + .map_err(|error| { + tracing::error!(error = %error, library_id = %library_id, "initial scan queue failed"); + ApiError::Unavailable + })?; + Ok(( + StatusCode::CREATED, + Json(CreateLibraryResponse { + library_id, + scan_id, + }), + )) +} + +#[utoipa::path(put, path = "/api/v2/libraries/{library_id}/members/{user_id}", tag = "administration", params(("library_id" = Uuid, Path), ("user_id" = Uuid, Path)), request_body = SetLibraryMemberRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn set_library_member( + State(state): State, + Path((library_id, user_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; + if request.role == crate::database::LibraryRole::Owner + || state + .db + .account_by_id(user_id) + .await + .map_err(db_error)? + .is_none() + || !state + .db + .all_libraries() + .await + .map_err(db_error)? + .iter() + .any(|library| library.id == library_id) + { + return Err(ApiError::Validation); + } + state + .db + .add_library_member( + actor.id, + library_id, + user_id, + request.role, + crate::authentication::now_ms(), + ) + .await + .map_err(db_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(delete, path = "/api/v2/libraries/{library_id}/members/{user_id}", tag = "administration", params(("library_id" = Uuid, Path), ("user_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn remove_library_member( + State(state): State, + Path((library_id, user_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; + if state + .db + .remove_library_member( + actor.id, + library_id, + user_id, + crate::authentication::now_ms(), + ) + .await + .map_err(db_error)? + { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::NotFound) + } +} + #[utoipa::path(get, path = "/api/v2/scans/{scan_id}", tag = "catalog", params(("scan_id" = Uuid, Path)), responses((status = 200, body = crate::catalog::ScanJobRecord), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn scan_status( State(state): State, @@ -583,9 +983,10 @@ pub async fn create_playlist( Json(request): Json, ) -> Result<(StatusCode, Json), ApiError> { let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; let playlist = state .services - .create_playlist(user.id, &request.name, &request.track_ids) + .create_playlist_with_context(user.id, &request.name, &request.track_ids, context) .await .map_err(service_error)?; Ok((StatusCode::CREATED, Json(playlist))) @@ -614,9 +1015,10 @@ pub async fn update_playlist( Json(request): Json, ) -> Result, ApiError> { let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; state .services - .update_playlist( + .update_playlist_with_context( user.id, playlist_id, request.name.as_deref(), @@ -624,6 +1026,7 @@ pub async fn update_playlist( request.public, &request.add, &request.remove_indexes, + context, ) .await .map(Json) @@ -637,9 +1040,10 @@ pub async fn delete_playlist( headers: HeaderMap, ) -> Result { let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; state .services - .delete_playlist(user.id, playlist_id) + .delete_playlist_with_context(user.id, playlist_id, context) .await .map_err(service_error)?; Ok(StatusCode::NO_CONTENT) @@ -692,9 +1096,10 @@ async fn set_favorite( starred: bool, ) -> Result { let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; state .services - .set_star(user.id, entity_type, entity_id, starred) + .set_star_with_context(user.id, entity_type, entity_id, starred, context) .await .map_err(service_error)?; Ok(StatusCode::NO_CONTENT) @@ -708,14 +1113,29 @@ pub async fn set_rating( Json(request): Json, ) -> Result { let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; state .services - .set_rating(user.id, &entity_type, entity_id, request.rating) + .set_rating_with_context(user.id, &entity_type, entity_id, request.rating, context) .await .map_err(service_error)?; Ok(StatusCode::NO_CONTENT) } +#[utoipa::path(get, path = "/api/v2/ratings", tag = "user-data", responses((status = 200, body = [crate::services::RatingItem]), (status = 401, body = ErrorResponse)))] +pub async fn list_ratings( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers).await?; + state + .services + .ratings(user.id) + .await + .map(Json) + .map_err(service_error) +} + #[utoipa::path(post, path = "/api/v2/scrobbles", tag = "user-data", request_body = ScrobbleRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn create_scrobble( State(state): State, @@ -723,19 +1143,160 @@ pub async fn create_scrobble( Json(request): Json, ) -> Result { let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; state .services - .scrobble( + .scrobble_with_context( user.id, request.track_id, request.submission, request.played_at, + context, ) .await .map_err(service_error)?; Ok(StatusCode::NO_CONTENT) } +#[utoipa::path(get, path = "/api/v2/history", tag = "user-data", params(("limit" = Option, Query)), responses((status = 200, body = [crate::services::HistoryItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn list_history( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers).await?; + let limit = query.limit.unwrap_or(200); + if !(1..=crate::sync::MAX_SYNC_LIMIT).contains(&limit) { + return Err(ApiError::Validation); + } + state + .services + .history(user.id, limit) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(get, path = "/api/v2/transcode/status", tag = "catalog", responses((status = 200, body = TranscodeStatusResponse), (status = 401, body = ErrorResponse)))] +pub async fn transcode_status( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + authenticated(&state, &headers).await?; + Ok(Json(TranscodeStatusResponse { + available: true, + active: state.media.active_transcodes(), + })) +} + +#[utoipa::path(get, path = "/api/v2/admin/users", tag = "administration", responses((status = 200, body = [crate::services::UserItem]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn list_users( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let actor = authenticated(&state, &headers).await?; + state + .services + .users(actor.id) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(post, path = "/api/v2/admin/users", tag = "administration", request_body = CreateUserRequest, responses((status = 201, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_user( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let actor = authenticated(&state, &headers).await?; + let user = state + .services + .create_web_user( + actor.id, + &request.username, + &request.web_password, + request.role, + ) + .await + .map_err(service_error)?; + Ok((StatusCode::CREATED, Json(user))) +} + +#[utoipa::path(patch, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), request_body = UpdateUserRequest, responses((status = 200, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn update_user( + State(state): State, + Path(username): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let actor = authenticated(&state, &headers).await?; + state + .services + .update_user( + actor.id, + &username, + crate::services::UserUpdate { + admin: request + .role + .map(|role| role == crate::database::AccountRole::Admin), + disabled: request.disabled, + folder_ids: request.library_ids.as_deref(), + subsonic_password: request.subsonic_password.as_deref(), + web_password: request.web_password.as_deref(), + }, + ) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(delete, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_user( + State(state): State, + Path(username): Path, + headers: HeaderMap, +) -> Result { + let actor = authenticated(&state, &headers).await?; + state + .services + .delete_user(actor.id, &username) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(put, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), request_body = SetSubsonicCredentialRequest, responses((status = 200, body = SubsonicCredentialResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn set_subsonic_credential( + State(state): State, + Path(username): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let actor = authenticated(&state, &headers).await?; + let api_key = state + .services + .set_subsonic_credential(actor.id, &username, &request.password) + .await + .map_err(service_error)?; + Ok(Json(SubsonicCredentialResponse { api_key })) +} + +#[utoipa::path(delete, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn revoke_subsonic_credential( + State(state): State, + Path(username): Path, + headers: HeaderMap, +) -> Result { + let actor = authenticated(&state, &headers).await?; + state + .services + .revoke_subsonic_credential(actor.id, &username) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + #[utoipa::path(get, path = "/api/v2/now-playing", tag = "user-data", responses((status = 200, body = [NowPlayingEntry]), (status = 401, body = ErrorResponse)))] pub async fn list_now_playing( State(state): State, @@ -778,23 +1339,314 @@ pub async fn save_queue( Json(request): Json, ) -> Result { let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; state .services - .save_queue( + .save_queue_with_context( user.id, &request.track_ids, request.current, request.position_ms, request.client.as_deref(), + context, ) .await .map_err(service_error)?; Ok(StatusCode::NO_CONTENT) } +#[utoipa::path(get, path = "/api/v2/shares", tag = "user-data", responses((status = 200, body = [ShareResponse]), (status = 401, body = ErrorResponse)))] +pub async fn list_shares( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers).await?; + let shares = state + .services + .shares(user.id) + .await + .map_err(service_error)? + .into_iter() + .map(|share| share_response(&state, share)) + .collect(); + Ok(Json(shares)) +} + +#[utoipa::path(post, path = "/api/v2/shares", tag = "user-data", request_body = CreateShareRequest, responses((status = 201, body = ShareResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_share( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; + let share = state + .services + .create_share_with_context( + user.id, + &request.track_ids, + request.description.as_deref(), + request.expires_at, + context, + ) + .await + .map_err(service_error)?; + Ok((StatusCode::CREATED, Json(share_response(&state, share)))) +} + +#[utoipa::path(patch, path = "/api/v2/shares/{share_id}", tag = "user-data", params(("share_id" = Uuid, Path)), request_body = UpdateShareRequest, responses((status = 200, body = ShareResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn update_share( + State(state): State, + Path(share_id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; + let share = state + .services + .update_share_with_context( + user.id, + share_id, + request.description.as_deref(), + request.expires_at, + context, + ) + .await + .map_err(service_error)?; + Ok(Json(share_response(&state, share))) +} + +#[utoipa::path(delete, path = "/api/v2/shares/{share_id}", tag = "user-data", params(("share_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_share( + State(state): State, + Path(share_id): Path, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .delete_share_with_context(user.id, share_id, context) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +fn share_response(state: &AppState, share: crate::services::ShareItem) -> ShareResponse { + let path = format!("/share/{}", share.url_token); + let url = state + .public_url + .as_ref() + .map_or_else(|| path.clone(), |base| format!("{base}{path}")); + ShareResponse { + id: share.id, + url, + description: share.description, + expires_at: share.expires_at, + created_at: share.created_at, + visit_count: share.visit_count, + track_ids: share.songs.into_iter().map(|song| song.id).collect(), + } +} + +#[utoipa::path( + get, + path = "/api/v2/sync/changes", + tag = "sync", + params(("after" = Option, Query), ("limit" = Option, Query)), + responses( + (status = 200, body = crate::sync::SyncPage), + (status = 401, body = ErrorResponse), + (status = 422, body = ErrorResponse) + ) +)] +pub async fn sync_changes( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + let user = authenticated(&state, &headers).await?; + let after = query.after.unwrap_or(0); + let limit = query.limit.unwrap_or(crate::sync::DEFAULT_SYNC_LIMIT); + if after < 0 || limit <= 0 || limit > crate::sync::MAX_SYNC_LIMIT { + return Err(ApiError::Validation); + } + state + .sync + .changes(user.id, after, limit) + .await + .map(Json) + .map_err(db_error) +} + +#[utoipa::path( + get, + path = "/api/v2/sync/snapshot", + tag = "sync", + responses((status = 200, body = SyncSnapshot), (status = 401, body = ErrorResponse)) +)] +pub async fn sync_snapshot( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers).await?; + // No service mutation can commit while this gate is held, so every read in + // the bootstrap describes the same journal cursor. + let _writer = state.db.writer_guard().await; + let cursor = state.sync.latest_cursor(user.id).await.map_err(db_error)?; + let playlists = state + .services + .playlists(user.id) + .await + .map_err(service_error)?; + let favorites = state + .services + .starred_ids(user.id) + .await + .map_err(service_error)? + .into_iter() + .map(|(entity_type, entity_id, starred_at)| StarredEntry { + entity_type, + entity_id, + starred_at, + }) + .collect(); + let ratings = state + .services + .ratings(user.id) + .await + .map_err(service_error)?; + let queue = state.services.queue(user.id).await.map_err(service_error)?; + let history = state + .services + .history(user.id, crate::sync::MAX_SYNC_LIMIT) + .await + .map_err(service_error)?; + let shares = state + .services + .shares(user.id) + .await + .map_err(service_error)? + .into_iter() + .map(|share| share_response(&state, share)) + .collect(); + Ok(Json(SyncSnapshot { + cursor, + playlists, + favorites, + ratings, + queue, + history, + shares, + })) +} + +#[utoipa::path( + put, + path = "/api/v2/sync/ack", + tag = "sync", + request_body = SyncAckRequest, + responses( + (status = 204), + (status = 401, body = ErrorResponse), + (status = 422, body = ErrorResponse) + ) +)] +pub async fn sync_ack( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let user = authenticated(&state, &headers).await?; + let acknowledged = state + .sync + .acknowledge(user.id, request.device_id, request.cursor) + .await + .map_err(db_error)?; + if !acknowledged { + return Err(ApiError::Validation); + } + Ok(StatusCode::NO_CONTENT) +} + +/// The socket is an edge-triggered wake-up channel. A client always follows a +/// notice with `GET /sync/changes`; the durable cursor, not socket delivery, is +/// the synchronization guarantee. +#[utoipa::path( + get, + path = "/api/v2/sync/socket", + tag = "sync", + params(("after" = Option, Query)), + responses( + (status = 101, description = "WebSocket cursor notifications"), + (status = 401, body = ErrorResponse), + (status = 422, body = ErrorResponse) + ) +)] +pub async fn sync_socket( + State(state): State, + headers: HeaderMap, + Query(query): Query, + upgrade: WebSocketUpgrade, +) -> Result { + let user = authenticated(&state, &headers).await?; + let after = query.after.unwrap_or(0); + if after < 0 { + return Err(ApiError::Validation); + } + Ok(upgrade + .on_upgrade(move |socket| serve_sync_socket(socket, state, user.id, after)) + .into_response()) +} + +async fn serve_sync_socket(socket: WebSocket, state: AppState, user_id: Uuid, after: i64) { + let (mut sender, mut receiver) = socket.split(); + let mut notices = state.sync.subscribe(); + if let Ok(cursor) = state.sync.latest_cursor(user_id).await { + if cursor > after && send_sync_notice(&mut sender, cursor).await.is_err() { + return; + } + } + loop { + tokio::select! { + incoming = receiver.next() => match incoming { + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + Some(Ok(_)) => {} + }, + notice = notices.recv() => match notice { + Ok((notice_user, notice)) if notice_user == user_id => { + if send_sync_notice(&mut sender, notice.cursor).await.is_err() { + break; + } + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + match state.sync.latest_cursor(user_id).await { + Ok(cursor) if send_sync_notice(&mut sender, cursor).await.is_err() => break, + Ok(_) => {} + Err(_) => break, + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } +} + +async fn send_sync_notice( + sender: &mut futures_util::stream::SplitSink, + cursor: i64, +) -> Result<(), axum::Error> { + let body = + serde_json::to_string(&crate::sync::SyncNotice { cursor }).expect("sync notice serializes"); + sender.send(Message::Text(body.into())).await +} + #[derive(Debug)] pub enum ApiError { Unauthorized, + Forbidden, Validation, Unavailable, NotFound, @@ -818,6 +1670,7 @@ impl IntoResponse for ApiError { "unauthorized", "Authentication failed", ), + Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden", "Request rejected"), Self::Validation => ( StatusCode::UNPROCESSABLE_ENTITY, "validation_error", @@ -834,6 +1687,109 @@ impl IntoResponse for ApiError { } } +fn web_auth_response( + state: &AppState, + headers: &HeaderMap, + tokens: crate::authentication::AuthTokens, +) -> Result { + let csrf_token = crate::security::generate_token("wfcsrf_"); + let secure = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .is_some_and(|origin| origin.starts_with("https://")) + || state + .public_url + .as_deref() + .is_some_and(|url| url.starts_with("https://")); + let refresh_cookie = format!( + "{WEB_REFRESH_COOKIE}={}; Path=/api/v2/web/auth; HttpOnly; SameSite=Strict; Max-Age={}{}", + tokens.refresh_token, + state.refresh_token_ttl.as_secs(), + if secure { "; Secure" } else { "" } + ); + let csrf_cookie = format!( + "{WEB_CSRF_COOKIE}={csrf_token}; Path=/; SameSite=Strict; Max-Age={}{}", + state.refresh_token_ttl.as_secs(), + if secure { "; Secure" } else { "" } + ); + let body = WebAuthResponse { + access_token: tokens.access_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + user: tokens.user, + device_id: tokens.device_id, + }; + let mut response = Json(body).into_response(); + append_cookie(&mut response, refresh_cookie)?; + append_cookie(&mut response, csrf_cookie)?; + Ok(response) +} + +fn append_cookie(response: &mut Response, value: String) -> Result<(), ApiError> { + let value = HeaderValue::from_str(&value).map_err(|_| ApiError::Unavailable)?; + response.headers_mut().append(header::SET_COOKIE, value); + Ok(()) +} + +fn expired_cookie(name: &str, http_only: bool) -> String { + format!( + "{name}=; Path={}; SameSite=Strict; Max-Age=0{}", + if http_only { "/api/v2/web/auth" } else { "/" }, + if http_only { "; HttpOnly" } else { "" } + ) +} + +fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .find_map(|(key, value)| (key == name && !value.is_empty()).then_some(value)) +} + +fn validate_web_request(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + validate_web_origin(state, headers)?; + let cookie = cookie_value(headers, WEB_CSRF_COOKIE).ok_or(ApiError::Forbidden)?; + let supplied = headers + .get(WEB_CSRF_HEADER) + .and_then(|value| value.to_str().ok()) + .ok_or(ApiError::Forbidden)?; + if !crate::security::constant_time_bytes_eq(cookie.as_bytes(), supplied.as_bytes()) { + return Err(ApiError::Forbidden); + } + Ok(()) +} + +fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .ok_or(ApiError::Forbidden)?; + if state.public_url.as_deref() == Some(origin) { + return Ok(()); + } + let parsed = url::Url::parse(origin).map_err(|_| ApiError::Forbidden)?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(ApiError::Forbidden); + } + let authority = &parsed[url::Position::BeforeHost..url::Position::AfterPort]; + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .ok_or(ApiError::Forbidden)?; + if authority.eq_ignore_ascii_case(host) { + Ok(()) + } else { + Err(ApiError::Forbidden) + } +} + fn bearer_token(headers: &HeaderMap) -> Option<&str> { headers .get(header::AUTHORIZATION)? @@ -851,6 +1807,56 @@ async fn authenticated( state.auth.authenticate(token).await.map_err(ApiError::from) } +fn require_admin(user: &crate::authentication::AuthUser) -> Result<(), ApiError> { + if user.role == crate::database::AccountRole::Admin { + Ok(()) + } else { + Err(ApiError::Forbidden) + } +} + +async fn mutation_context( + state: &AppState, + headers: &HeaderMap, + user_id: Uuid, +) -> Result { + let operation_id = headers + .get("x-waveflow-operation-id") + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or(ApiError::Validation) + }) + .transpose()? + .unwrap_or_else(Uuid::new_v4); + let origin_device_id = headers + .get("x-waveflow-device-id") + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or(ApiError::Validation) + }) + .transpose()?; + if let Some(device_id) = origin_device_id { + let owned = state + .sync + .device_belongs_to_user(user_id, device_id) + .await + .map_err(db_error)?; + if !owned { + return Err(ApiError::Validation); + } + } + Ok(crate::sync::MutationContext { + operation_id, + origin_device_id, + }) +} + fn db_error(error: sqlx::Error) -> ApiError { tracing::error!(error = %error, "catalog database operation failed"); ApiError::Unavailable diff --git a/src/lib.rs b/src/lib.rs index 7ab5240..cd28483 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod security; pub mod services; pub mod stream_ticket; pub mod subsonic; +pub mod sync; pub mod webui; use std::{sync::Arc, time::Duration}; @@ -46,10 +47,12 @@ pub struct AppState { pub scanner: scanner::ScanManager, pub media: media::MediaService, pub services: services::DomainServices, + pub sync: sync::SyncService, pub artwork_dir: std::path::PathBuf, pub instance_key_path: std::path::PathBuf, pub public_url: Option, pub stream_ticket_ttl: std::time::Duration, + pub refresh_token_ttl: std::time::Duration, } #[derive(OpenApi)] @@ -63,12 +66,21 @@ pub struct AppState { paths( http::health, http::ready, + http::setup_status, + http::setup, http::login, http::refresh, http::logout, + http::web_login, + http::web_refresh, + http::web_logout, http::oauth_authorize, http::oauth_token, http::start_scan, + http::list_libraries, + http::create_library, + http::set_library_member, + http::remove_library_member, http::scan_status, http::scan_events, http::list_tracks, @@ -86,10 +98,27 @@ pub struct AppState { http::add_favorite, http::remove_favorite, http::set_rating, + http::list_ratings, http::create_scrobble, + http::list_history, http::list_now_playing, http::get_queue, http::save_queue, + http::list_shares, + http::create_share, + http::update_share, + http::delete_share, + http::sync_changes, + http::sync_snapshot, + http::sync_ack, + http::sync_socket, + http::transcode_status, + http::list_users, + http::create_user, + http::update_user, + http::delete_user, + http::set_subsonic_credential, + http::revoke_subsonic_credential, media::stream_track, media::create_stream_ticket, media::stream_with_ticket @@ -97,8 +126,12 @@ pub struct AppState { components(schemas( http::ProbeResponse, http::ReadyResponse, + http::SetupStatusResponse, + http::SetupRequest, + http::SetupResponse, http::LoginRequest, http::RefreshRequest, + http::WebAuthResponse, http::ErrorResponse, authentication::AuthTokens, authentication::AuthUser, @@ -106,6 +139,7 @@ pub struct AppState { http::ScanQueuedResponse, catalog::ScanJobRecord, catalog::TrackRecord, + catalog::LibraryAccess, services::AlbumItem, services::ArtistItem, services::SongItem, @@ -115,16 +149,34 @@ pub struct AppState { services::SearchResult, services::PlaylistItem, services::QueueItem, + services::RatingItem, + services::HistoryItem, + services::UserItem, http::CreatePlaylistRequest, http::UpdatePlaylistRequest, http::RatingRequest, http::ScrobbleRequest, http::SaveQueueRequest, + http::CreateShareRequest, + http::UpdateShareRequest, + http::ShareResponse, http::AuthorizeRequest, http::AuthorizeResponse, http::TokenRequest, http::StarredEntry, http::NowPlayingEntry, + http::SyncAckRequest, + http::SyncSnapshot, + http::TranscodeStatusResponse, + http::CreateUserRequest, + http::UpdateUserRequest, + http::SetSubsonicCredentialRequest, + http::SubsonicCredentialResponse, + http::CreateLibraryRequest, + http::CreateLibraryResponse, + http::SetLibraryMemberRequest, + sync::SyncChange, + sync::SyncPage, media::StreamTicketResponse, scanner::ScanProgress )), @@ -132,6 +184,9 @@ pub struct AppState { (name = "probes", description = "Process and SQLite health"), (name = "authentication", description = "Local WaveFlow sessions") ,(name = "catalog", description = "Authoritative library scans and catalogue reads") + ,(name = "user-data", description = "Cross-protocol playlists and playback state") + ,(name = "sync", description = "Durable WaveFlow Desktop user-data synchronization") + ,(name = "administration", description = "Administrative user and credential management") ) )] pub struct ApiDoc; @@ -159,7 +214,8 @@ pub async fn initialize(config: &Config) -> anyhow::Result { config.scan_parallelism, ); let media = media::MediaService::initialize(config).await?; - let services = services::DomainServices::new(db.clone(), Arc::clone(&secret_box)); + let sync = sync::SyncService::new(db.clone()); + let services = services::DomainServices::new(db.clone(), Arc::clone(&secret_box), sync.clone()); Ok(AppState { db, auth, @@ -167,10 +223,12 @@ pub async fn initialize(config: &Config) -> anyhow::Result { scanner, media, services, + sync, artwork_dir: config.artwork_dir.clone(), instance_key_path: config.instance_key_path.clone(), public_url: config.public_url.clone(), stream_ticket_ttl: config.stream_ticket_ttl, + refresh_token_ttl: config.refresh_token_ttl, }) } @@ -236,12 +294,17 @@ pub fn app(config: &Config, state: AppState) -> Router { .allow_methods([ axum::http::Method::GET, axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::PATCH, + axum::http::Method::DELETE, axum::http::Method::OPTIONS, ]) .allow_headers([ axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, axum::http::header::RANGE, + axum::http::HeaderName::from_static("x-waveflow-operation-id"), + axum::http::HeaderName::from_static("x-waveflow-device-id"), ]) .expose_headers([ axum::http::header::ACCEPT_RANGES, diff --git a/src/services.rs b/src/services.rs index cf5740a..6d3b764 100644 --- a/src/services.rs +++ b/src/services.rs @@ -11,6 +11,7 @@ use crate::{ authentication::now_ms, database::{AccountRecord, AccountRole, Database}, security::{self, EncryptedSecret, SecretBox}, + sync::{MutationContext, OperationClaim, SyncService}, }; /// Tenant-filtered projections shared by the Subsonic facade and the native @@ -218,6 +219,21 @@ pub struct QueueItem { pub songs: Vec, } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RatingItem { + pub entity_type: String, + pub entity_id: Uuid, + pub rating: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct HistoryItem { + pub track_id: Uuid, + pub submission: bool, + pub played_at: i64, +} + #[derive(Debug, Clone)] pub struct ShareItem { pub id: Uuid, @@ -230,7 +246,7 @@ pub struct ShareItem { pub songs: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct UserItem { pub id: Uuid, pub username: String, @@ -240,10 +256,19 @@ pub struct UserItem { pub folder_ids: Vec, } +pub struct UserUpdate<'a> { + pub admin: Option, + pub disabled: Option, + pub folder_ids: Option<&'a [Uuid]>, + pub subsonic_password: Option<&'a str>, + pub web_password: Option<&'a str>, +} + #[derive(Clone)] pub struct DomainServices { db: Database, secret_box: Arc, + sync: SyncService, } #[derive(Debug, thiserror::Error)] @@ -263,8 +288,31 @@ pub enum ServiceError { } impl DomainServices { - pub fn new(db: Database, secret_box: Arc) -> Self { - Self { db, secret_box } + pub fn new(db: Database, secret_box: Arc, sync: SyncService) -> Self { + Self { + db, + secret_box, + sync, + } + } + + pub async fn bootstrap_admin( + &self, + username: &str, + password: &str, + ) -> Result { + validate_username(username)?; + if password.len() < 12 { + return Err(ServiceError::Invalid); + } + let password = password.to_owned(); + let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) + .await + .map_err(|_| ServiceError::Invalid)??; + self.db + .bootstrap_admin(username, &password_hash, now_ms()) + .await? + .ok_or(ServiceError::Conflict) } pub async fn credential_by_username( @@ -712,18 +760,59 @@ impl DomainServices { user_id: Uuid, name: &str, track_ids: &[Uuid], + ) -> Result { + self.create_playlist_with_context( + user_id, + name, + track_ids, + MutationContext::server_generated(), + ) + .await + } + + pub async fn create_playlist_with_context( + &self, + user_id: Uuid, + name: &str, + track_ids: &[Uuid], + context: MutationContext, ) -> Result { validate_name(name)?; self.songs_by_ids(user_id, track_ids).await?; let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; + return self.playlist(user_id, id).await; + } let id = Uuid::new_v4(); let now = now_ms(); sqlx::query("INSERT INTO playlist (id, owner_user_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") .bind(id.to_string()).bind(user_id.to_string()).bind(name.trim()).bind(now).bind(now) .execute(&mut *tx).await?; replace_playlist_tracks(&mut tx, id, track_ids, now).await?; + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "playlist", + id, + "upsert", + &serde_json::json!({ + "id": id, + "name": name.trim(), + "track_ids": track_ids, + }), + Some(id), + ) + .await?; tx.commit().await?; + self.sync.publish(user_id, receipt); self.playlist(user_id, id).await } @@ -737,6 +826,31 @@ impl DomainServices { public: Option, add: &[Uuid], remove_indexes: &[usize], + ) -> Result { + self.update_playlist_with_context( + user_id, + id, + name, + comment, + public, + add, + remove_indexes, + MutationContext::server_generated(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn update_playlist_with_context( + &self, + user_id: Uuid, + id: Uuid, + name: Option<&str>, + comment: Option<&str>, + public: Option, + add: &[Uuid], + remove_indexes: &[usize], + context: MutationContext, ) -> Result { let current = self.playlist(user_id, id).await?; if let Some(name) = name { @@ -756,6 +870,13 @@ impl DomainServices { ids.extend_from_slice(add); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return self.playlist(user_id, id).await; + } + let changed_at = now_ms(); sqlx::query( "UPDATE playlist SET name=COALESCE(?, name), comment=COALESCE(?, comment), \ public=COALESCE(?, public), updated_at=? WHERE id=? AND owner_user_id=?", @@ -763,7 +884,7 @@ impl DomainServices { .bind(name.map(str::trim)) .bind(comment) .bind(public.map(i64::from)) - .bind(now_ms()) + .bind(changed_at) .bind(id.to_string()) .bind(user_id.to_string()) .execute(&mut *tx) @@ -772,22 +893,75 @@ impl DomainServices { .bind(id.to_string()) .execute(&mut *tx) .await?; - replace_playlist_tracks(&mut tx, id, &ids, now_ms()).await?; + replace_playlist_tracks(&mut tx, id, &ids, changed_at).await?; + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "playlist", + id, + "upsert", + &serde_json::json!({ + "id": id, + "name": name.map(str::trim).unwrap_or(¤t.name), + "comment": comment.or(current.comment.as_deref()), + "public": public.unwrap_or(current.public), + "track_ids": ids, + }), + Some(id), + ) + .await?; tx.commit().await?; + self.sync.publish(user_id, receipt); self.playlist(user_id, id).await } pub async fn delete_playlist(&self, user_id: Uuid, id: Uuid) -> Result<(), ServiceError> { + self.delete_playlist_with_context(user_id, id, MutationContext::server_generated()) + .await + } + + pub async fn delete_playlist_with_context( + &self, + user_id: Uuid, + id: Uuid, + context: MutationContext, + ) -> Result<(), ServiceError> { let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return Ok(()); + } let changed = sqlx::query("DELETE FROM playlist WHERE id=? AND owner_user_id=?") .bind(id.to_string()) .bind(user_id.to_string()) - .execute(self.db.pool()) + .execute(&mut *tx) .await? .rows_affected(); if changed == 0 { + tx.rollback().await?; Err(ServiceError::NotFound) } else { + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "playlist", + id, + "delete", + &serde_json::json!({}), + Some(id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); Ok(()) } } @@ -798,22 +972,66 @@ impl DomainServices { entity_type: &str, entity_id: Uuid, starred: bool, + ) -> Result<(), ServiceError> { + self.set_star_with_context( + user_id, + entity_type, + entity_id, + starred, + MutationContext::server_generated(), + ) + .await + } + + pub async fn set_star_with_context( + &self, + user_id: Uuid, + entity_type: &str, + entity_id: Uuid, + starred: bool, + context: MutationContext, ) -> Result<(), ServiceError> { self.authorize_entity(user_id, entity_type, entity_id) .await?; let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return Ok(()); + } if starred { sqlx::query("INSERT INTO user_star (user_id, entity_type, entity_id, starred_at) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET starred_at=excluded.starred_at") .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(now_ms()) - .execute(self.db.pool()).await?; + .execute(&mut *tx).await?; } else { sqlx::query("DELETE FROM user_star WHERE user_id=? AND entity_type=? AND entity_id=?") .bind(user_id.to_string()) .bind(entity_type) .bind(entity_id.to_string()) - .execute(self.db.pool()) + .execute(&mut *tx) .await?; } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "favorite", + entity_id, + if starred { "upsert" } else { "delete" }, + &serde_json::json!({ + "entity_type": entity_type, + "entity_id": entity_id, + "starred": starred, + }), + Some(entity_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); Ok(()) } @@ -866,12 +1084,55 @@ impl DomainServices { .collect::, sqlx::Error>>().map_err(Into::into) } + pub async fn ratings(&self, user_id: Uuid) -> Result, ServiceError> { + sqlx::query( + "SELECT r.entity_type, r.entity_id, r.rating, r.updated_at FROM user_rating r \ + WHERE r.user_id=? AND ( \ + (r.entity_type='track' AND EXISTS (SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) OR \ + (r.entity_type='album' AND EXISTS (SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) OR \ + (r.entity_type='artist' AND EXISTS (SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) \ + ) ORDER BY r.updated_at DESC, r.entity_type, r.entity_id", + ) + .bind(user_id.to_string()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(|row| { + Ok(RatingItem { + entity_type: row.try_get("entity_type")?, + entity_id: parse_uuid(row.try_get("entity_id")?)?, + rating: row.try_get("rating")?, + updated_at: row.try_get("updated_at")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) + } + pub async fn set_rating( &self, user_id: Uuid, entity_type: &str, entity_id: Uuid, rating: i64, + ) -> Result<(), ServiceError> { + self.set_rating_with_context( + user_id, + entity_type, + entity_id, + rating, + MutationContext::server_generated(), + ) + .await + } + + pub async fn set_rating_with_context( + &self, + user_id: Uuid, + entity_type: &str, + entity_id: Uuid, + rating: i64, + context: MutationContext, ) -> Result<(), ServiceError> { if !(0..=5).contains(&rating) { return Err(ServiceError::Invalid); @@ -879,6 +1140,13 @@ impl DomainServices { self.authorize_entity(user_id, entity_type, entity_id) .await?; let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return Ok(()); + } if rating == 0 { sqlx::query( "DELETE FROM user_rating WHERE user_id=? AND entity_type=? AND entity_id=?", @@ -886,12 +1154,31 @@ impl DomainServices { .bind(user_id.to_string()) .bind(entity_type) .bind(entity_id.to_string()) - .execute(self.db.pool()) + .execute(&mut *tx) .await?; } else { sqlx::query("INSERT INTO user_rating (user_id, entity_type, entity_id, rating, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET rating=excluded.rating, updated_at=excluded.updated_at") - .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(rating).bind(now_ms()).execute(self.db.pool()).await?; + .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(rating).bind(now_ms()).execute(&mut *tx).await?; } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "rating", + entity_id, + if rating == 0 { "delete" } else { "upsert" }, + &serde_json::json!({ + "entity_type": entity_type, + "entity_id": entity_id, + "rating": rating, + }), + Some(entity_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); Ok(()) } @@ -901,11 +1188,35 @@ impl DomainServices { track_id: Uuid, submission: bool, time: Option, + ) -> Result<(), ServiceError> { + self.scrobble_with_context( + user_id, + track_id, + submission, + time, + MutationContext::server_generated(), + ) + .await + } + + pub async fn scrobble_with_context( + &self, + user_id: Uuid, + track_id: Uuid, + submission: bool, + time: Option, + context: MutationContext, ) -> Result<(), ServiceError> { self.authorize_entity(user_id, "track", track_id).await?; let now = time.unwrap_or_else(now_ms); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return Ok(()); + } sqlx::query( "INSERT INTO play_event (user_id, track_id, submission, played_at) VALUES (?, ?, ?, ?)", ) @@ -924,7 +1235,25 @@ impl DomainServices { sqlx::query("INSERT INTO now_playing (user_id, track_id, started_at, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET track_id=excluded.track_id, started_at=excluded.started_at, updated_at=excluded.updated_at") .bind(user_id.to_string()).bind(track_id.to_string()).bind(now).bind(now_ms()).execute(&mut *tx).await?; } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "scrobble", + track_id, + if submission { "append" } else { "upsert" }, + &serde_json::json!({ + "track_id": track_id, + "submission": submission, + "played_at": now, + }), + Some(track_id), + ) + .await?; tx.commit().await?; + self.sync.publish(user_id, receipt); Ok(()) } @@ -954,6 +1283,33 @@ impl DomainServices { Ok(result) } + pub async fn history( + &self, + user_id: Uuid, + limit: i64, + ) -> Result, ServiceError> { + sqlx::query( + "SELECT p.track_id, p.submission, p.played_at FROM play_event p \ + JOIN track t ON t.id=p.track_id JOIN library_member m ON m.library_id=t.library_id \ + WHERE p.user_id=? AND m.user_id=? ORDER BY p.played_at DESC, p.id DESC LIMIT ?", + ) + .bind(user_id.to_string()) + .bind(user_id.to_string()) + .bind(limit) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(|row| { + Ok(HistoryItem { + track_id: parse_uuid(row.try_get("track_id")?)?, + submission: row.try_get::("submission")? != 0, + played_at: row.try_get("played_at")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) + } + pub async fn save_queue( &self, user_id: Uuid, @@ -961,6 +1317,27 @@ impl DomainServices { current: Option, position_ms: i64, client: Option<&str>, + ) -> Result<(), ServiceError> { + self.save_queue_with_context( + user_id, + ids, + current, + position_ms, + client, + MutationContext::server_generated(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn save_queue_with_context( + &self, + user_id: Uuid, + ids: &[Uuid], + current: Option, + position_ms: i64, + client: Option<&str>, + context: MutationContext, ) -> Result<(), ServiceError> { if position_ms < 0 { return Err(ServiceError::Invalid); @@ -971,6 +1348,12 @@ impl DomainServices { } let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return Ok(()); + } sqlx::query("INSERT INTO play_queue (user_id, current_track_id, position_ms, changed_by, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET current_track_id=excluded.current_track_id, position_ms=excluded.position_ms, changed_by=excluded.changed_by, updated_at=excluded.updated_at") .bind(user_id.to_string()).bind(current.map(|id| id.to_string())).bind(position_ms).bind(client).bind(now_ms()).execute(&mut *tx).await?; sqlx::query("DELETE FROM play_queue_track WHERE user_id=?") @@ -987,7 +1370,26 @@ impl DomainServices { .execute(&mut *tx) .await?; } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "queue", + user_id, + "upsert", + &serde_json::json!({ + "track_ids": ids, + "current": current, + "position_ms": position_ms, + "client": client, + }), + Some(user_id), + ) + .await?; tx.commit().await?; + self.sync.publish(user_id, receipt); Ok(()) } @@ -1057,6 +1459,24 @@ impl DomainServices { ids: &[Uuid], description: Option<&str>, expires_at: Option, + ) -> Result { + self.create_share_with_context( + user_id, + ids, + description, + expires_at, + MutationContext::server_generated(), + ) + .await + } + + pub async fn create_share_with_context( + &self, + user_id: Uuid, + ids: &[Uuid], + description: Option<&str>, + expires_at: Option, + context: MutationContext, ) -> Result { if ids.is_empty() { return Err(ServiceError::Invalid); @@ -1069,6 +1489,18 @@ impl DomainServices { let now = now_ms(); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; + return self + .shares(user_id) + .await? + .into_iter() + .find(|share| share.id == id) + .ok_or(ServiceError::NotFound); + } sqlx::query("INSERT INTO share (id, owner_user_id, token_hash, token_nonce, token_ciphertext, description, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") .bind(id.to_string()).bind(user_id.to_string()).bind(token_hash.as_slice()).bind(encrypted.nonce.as_slice()).bind(encrypted.ciphertext).bind(description).bind(expires_at).bind(now).bind(now).execute(&mut *tx).await?; for (position, track) in ids.iter().enumerate() { @@ -1079,7 +1511,26 @@ impl DomainServices { .execute(&mut *tx) .await?; } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "share", + id, + "upsert", + &serde_json::json!({ + "id": id, + "track_ids": ids, + "description": description, + "expires_at": expires_at, + }), + Some(id), + ) + .await?; tx.commit().await?; + self.sync.publish(user_id, receipt); self.shares(user_id) .await? .into_iter() @@ -1133,13 +1584,63 @@ impl DomainServices { id: Uuid, description: Option<&str>, expires_at: Option, + ) -> Result { + self.update_share_with_context( + user_id, + id, + description, + expires_at, + MutationContext::server_generated(), + ) + .await + } + + pub async fn update_share_with_context( + &self, + user_id: Uuid, + id: Uuid, + description: Option<&str>, + expires_at: Option, + context: MutationContext, ) -> Result { let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return self + .shares(user_id) + .await? + .into_iter() + .find(|share| share.id == id) + .ok_or(ServiceError::NotFound); + } let changed = sqlx::query("UPDATE share SET description=COALESCE(?, description), expires_at=COALESCE(?, expires_at), updated_at=? WHERE id=? AND owner_user_id=?") - .bind(description).bind(expires_at).bind(now_ms()).bind(id.to_string()).bind(user_id.to_string()).execute(self.db.pool()).await?.rows_affected(); + .bind(description).bind(expires_at).bind(now_ms()).bind(id.to_string()).bind(user_id.to_string()).execute(&mut *tx).await?.rows_affected(); if changed == 0 { + tx.rollback().await?; return Err(ServiceError::NotFound); } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "share", + id, + "upsert", + &serde_json::json!({ + "id": id, + "description": description, + "expires_at": expires_at, + }), + Some(id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); self.shares(user_id) .await? .into_iter() @@ -1148,16 +1649,49 @@ impl DomainServices { } pub async fn delete_share(&self, user_id: Uuid, id: Uuid) -> Result<(), ServiceError> { + self.delete_share_with_context(user_id, id, MutationContext::server_generated()) + .await + } + + pub async fn delete_share_with_context( + &self, + user_id: Uuid, + id: Uuid, + context: MutationContext, + ) -> Result<(), ServiceError> { let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(_) = + self.sync.claim_operation(&mut tx, user_id, context).await? + { + tx.rollback().await?; + return Ok(()); + } let changed = sqlx::query("DELETE FROM share WHERE id=? AND owner_user_id=?") .bind(id.to_string()) .bind(user_id.to_string()) - .execute(self.db.pool()) + .execute(&mut *tx) .await? .rows_affected(); if changed == 0 { + tx.rollback().await?; Err(ServiceError::NotFound) } else { + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "share", + id, + "delete", + &serde_json::json!({}), + Some(id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); Ok(()) } } @@ -1181,6 +1715,88 @@ impl DomainServices { Ok(users) } + pub async fn create_web_user( + &self, + actor_id: Uuid, + username: &str, + password: &str, + role: AccountRole, + ) -> Result { + self.require_admin(actor_id).await?; + validate_username(username)?; + if password.len() < 12 { + return Err(ServiceError::Invalid); + } + let password = password.to_owned(); + let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) + .await + .map_err(|_| ServiceError::Invalid)??; + let id = self + .db + .create_account(username.trim(), &password_hash, role, now_ms()) + .await + .map_err(|error| { + if matches!(error, sqlx::Error::Database(ref db) if db.is_unique_violation()) { + ServiceError::Conflict + } else { + ServiceError::Database(error) + } + })?; + self.users(actor_id) + .await? + .into_iter() + .find(|user| user.id == id) + .ok_or(ServiceError::NotFound) + } + + /// Sets a dedicated Subsonic password and rotates the API key. The clear + /// API key is returned once; only its hash is persisted. + pub async fn set_subsonic_credential( + &self, + actor_id: Uuid, + username: &str, + password: &str, + ) -> Result { + self.require_admin(actor_id).await?; + if password.len() < 12 { + return Err(ServiceError::Invalid); + } + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + let encrypted = self.secret_box.encrypt(password.as_bytes())?; + let api_key = security::generate_token("wfsk_"); + let api_key_hash = security::token_hash(&api_key); + self.db + .set_subsonic_credential(actor_id, account.id, &encrypted, &api_key_hash, now_ms()) + .await?; + Ok(api_key) + } + + pub async fn revoke_subsonic_credential( + &self, + actor_id: Uuid, + username: &str, + ) -> Result<(), ServiceError> { + self.require_admin(actor_id).await?; + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + if self + .db + .revoke_subsonic_credential(actor_id, account.id, now_ms()) + .await? + { + Ok(()) + } else { + Err(ServiceError::NotFound) + } + } + pub async fn create_subsonic_user( &self, actor_id: Uuid, @@ -1270,13 +1886,14 @@ impl DomainServices { &self, actor_id: Uuid, username: &str, - admin: Option, - disabled: Option, - folder_ids: Option<&[Uuid]>, - password: Option<&str>, + update: UserUpdate<'_>, ) -> Result { self.require_admin(actor_id).await?; - if password.is_some_and(str::is_empty) { + if update.subsonic_password.is_some_and(str::is_empty) + || update + .web_password + .is_some_and(|password| password.len() < 12) + { return Err(ServiceError::Invalid); } let account = self @@ -1284,17 +1901,40 @@ impl DomainServices { .account_by_username(username) .await? .ok_or(ServiceError::NotFound)?; - let requested_folders = match folder_ids { + if account.id == actor_id && (update.admin == Some(false) || update.disabled == Some(true)) + { + return Err(ServiceError::Forbidden); + } + let requested_folders = match update.folder_ids { Some(ids) => Some(self.resolve_library_ids(Some(ids)).await?), None => None, }; - let encrypted = password + let encrypted = update + .subsonic_password .map(|password| self.secret_box.encrypt(password.as_bytes())) .transpose()?; + let web_password_hash = if let Some(password) = update.web_password { + let password = password.to_owned(); + Some( + tokio::task::spawn_blocking(move || security::hash_password(&password)) + .await + .map_err(|_| ServiceError::Invalid)??, + ) + } else { + None + }; + let revoke_sessions = web_password_hash.is_some(); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - sqlx::query("UPDATE account SET role=COALESCE(?, role), disabled=COALESCE(?, disabled), updated_at=? WHERE id=?") - .bind(admin.map(|value| if value { "admin" } else { "user" })).bind(disabled.map(i64::from)).bind(now_ms()).bind(account.id.to_string()).execute(&mut *tx).await?; + sqlx::query("UPDATE account SET role=COALESCE(?, role), disabled=COALESCE(?, disabled), password_hash=COALESCE(?, password_hash), updated_at=? WHERE id=?") + .bind(update.admin.map(|value| if value { "admin" } else { "user" })).bind(update.disabled.map(i64::from)).bind(web_password_hash.as_deref()).bind(now_ms()).bind(account.id.to_string()).execute(&mut *tx).await?; + if revoke_sessions { + sqlx::query("UPDATE session SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL") + .bind(now_ms()) + .bind(account.id.to_string()) + .execute(&mut *tx) + .await?; + } if let Some(encrypted) = encrypted { let changed = sqlx::query( "UPDATE subsonic_credential SET password_nonce=?, password_ciphertext=?, updated_at=? WHERE user_id=?", @@ -1582,6 +2222,19 @@ fn validate_name(name: &str) -> Result<(), ServiceError> { } } +fn validate_username(username: &str) -> Result<(), ServiceError> { + let username = username.trim(); + if !(3..=64).contains(&username.len()) + || !username.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + Err(ServiceError::Invalid) + } else { + Ok(()) + } +} + fn parse_uuid(value: String) -> Result { Uuid::from_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) } diff --git a/src/subsonic.rs b/src/subsonic.rs index 30cec93..80b4527 100644 --- a/src/subsonic.rs +++ b/src/subsonic.rs @@ -1363,10 +1363,13 @@ async fn admin( .update_user( principal.id, params.first("username").ok_or_else(missing)?, - params.bool_optional("adminRole")?, - params.bool_optional("locked")?, - folders.as_deref(), - password.as_deref(), + crate::services::UserUpdate { + admin: params.bool_optional("adminRole")?, + disabled: params.bool_optional("locked")?, + folder_ids: folders.as_deref(), + subsonic_password: password.as_deref(), + web_password: None, + }, ) .await .map_err(service_protocol)?; diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..9940434 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,293 @@ +//! Durable user-data change journal for WaveFlow Desktop synchronization. +//! +//! REST is the source of truth. The WebSocket only wakes a client when a newer +//! cursor exists, so reconnects and dropped frames cannot lose state. + +use serde::Serialize; +use serde_json::Value; +use sqlx::{Row, SqliteConnection}; +use tokio::sync::broadcast; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{authentication::now_ms, database::Database}; + +pub const DEFAULT_SYNC_LIMIT: i64 = 100; +pub const MAX_SYNC_LIMIT: i64 = 500; + +#[derive(Debug, Clone, Copy)] +pub struct MutationContext { + pub operation_id: Uuid, + pub origin_device_id: Option, +} + +impl MutationContext { + pub fn server_generated() -> Self { + Self { + operation_id: Uuid::new_v4(), + origin_device_id: None, + } + } +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SyncChange { + pub cursor: i64, + pub event_id: Uuid, + pub operation_id: Uuid, + pub origin_device_id: Option, + pub entity_type: String, + pub entity_id: Uuid, + pub action: String, + pub payload: Value, + pub changed_at: i64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SyncPage { + pub changes: Vec, + pub next_cursor: i64, + pub has_more: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct MutationReceipt { + pub operation_id: Uuid, + pub result_entity_id: Option, + pub cursor: i64, + pub replayed: bool, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum OperationClaim { + New, + Replayed(MutationReceipt), +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct SyncNotice { + pub cursor: i64, +} + +#[derive(Clone)] +pub struct SyncService { + db: Database, + notices: broadcast::Sender<(Uuid, SyncNotice)>, +} + +impl SyncService { + pub fn new(db: Database) -> Self { + let (notices, _) = broadcast::channel(256); + Self { db, notices } + } + + pub fn subscribe(&self) -> broadcast::Receiver<(Uuid, SyncNotice)> { + self.notices.subscribe() + } + + pub(crate) async fn claim_operation( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + context: MutationContext, + ) -> Result { + let inserted = sqlx::query( + "INSERT INTO sync_operation \ + (user_id, operation_id, origin_device_id, created_at) VALUES (?, ?, ?, ?) \ + ON CONFLICT (user_id, operation_id) DO NOTHING", + ) + .bind(user_id.to_string()) + .bind(context.operation_id.to_string()) + .bind(context.origin_device_id.map(|id| id.to_string())) + .bind(now_ms()) + .execute(&mut *connection) + .await? + .rows_affected(); + if inserted == 1 { + return Ok(OperationClaim::New); + } + + let row = sqlx::query( + "SELECT result_entity_id, event_cursor FROM sync_operation \ + WHERE user_id=? AND operation_id=? AND applied_at IS NOT NULL", + ) + .bind(user_id.to_string()) + .bind(context.operation_id.to_string()) + .fetch_optional(&mut *connection) + .await? + .ok_or_else(|| sqlx::Error::Protocol("sync operation is incomplete".into()))?; + Ok(OperationClaim::Replayed(MutationReceipt { + operation_id: context.operation_id, + result_entity_id: row + .try_get::, _>("result_entity_id")? + .map(parse_uuid) + .transpose()?, + cursor: row.try_get("event_cursor")?, + replayed: true, + })) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) async fn complete_operation( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + context: MutationContext, + entity_type: &str, + entity_id: Uuid, + action: &str, + payload: &Value, + result_entity_id: Option, + ) -> Result { + let changed_at = now_ms(); + let event_id = Uuid::new_v4(); + let cursor: i64 = sqlx::query_scalar( + "INSERT INTO sync_event \ + (event_id, user_id, operation_id, origin_device_id, entity_type, entity_id, \ + action, payload_json, changed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \ + RETURNING cursor", + ) + .bind(event_id.to_string()) + .bind(user_id.to_string()) + .bind(context.operation_id.to_string()) + .bind(context.origin_device_id.map(|id| id.to_string())) + .bind(entity_type) + .bind(entity_id.to_string()) + .bind(action) + .bind(payload.to_string()) + .bind(changed_at) + .fetch_one(&mut *connection) + .await?; + sqlx::query( + "UPDATE sync_operation SET result_entity_id=?, event_cursor=?, applied_at=? \ + WHERE user_id=? AND operation_id=?", + ) + .bind(result_entity_id.map(|id| id.to_string())) + .bind(cursor) + .bind(changed_at) + .bind(user_id.to_string()) + .bind(context.operation_id.to_string()) + .execute(&mut *connection) + .await?; + Ok(MutationReceipt { + operation_id: context.operation_id, + result_entity_id, + cursor, + replayed: false, + }) + } + + pub(crate) fn publish(&self, user_id: Uuid, receipt: MutationReceipt) { + if !receipt.replayed { + let _ = self.notices.send(( + user_id, + SyncNotice { + cursor: receipt.cursor, + }, + )); + } + } + + pub async fn changes( + &self, + user_id: Uuid, + after: i64, + limit: i64, + ) -> Result { + let rows = sqlx::query( + "SELECT cursor, event_id, operation_id, origin_device_id, entity_type, entity_id, \ + action, payload_json, changed_at \ + FROM sync_event WHERE user_id=? AND cursor>? ORDER BY cursor LIMIT ?", + ) + .bind(user_id.to_string()) + .bind(after) + .bind(limit + 1) + .fetch_all(self.db.pool()) + .await?; + let has_more = rows.len() as i64 > limit; + let changes = rows + .into_iter() + .take(limit as usize) + .map(change_from_row) + .collect::, _>>()?; + let next_cursor = changes.last().map_or(after, |change| change.cursor); + Ok(SyncPage { + changes, + next_cursor, + has_more, + }) + } + + pub async fn latest_cursor(&self, user_id: Uuid) -> Result { + sqlx::query_scalar("SELECT COALESCE(MAX(cursor), 0) FROM sync_event WHERE user_id=?") + .bind(user_id.to_string()) + .fetch_one(self.db.pool()) + .await + } + + pub async fn device_belongs_to_user( + &self, + user_id: Uuid, + device_id: Uuid, + ) -> Result { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM device \ + WHERE id=? AND user_id=? AND revoked_at IS NULL)", + ) + .bind(device_id.to_string()) + .bind(user_id.to_string()) + .fetch_one(self.db.pool()) + .await + } + + pub async fn acknowledge( + &self, + user_id: Uuid, + device_id: Uuid, + cursor: i64, + ) -> Result { + if cursor < 0 || cursor > self.latest_cursor(user_id).await? { + return Ok(false); + } + let _writer = self.db.writer_guard().await; + let result = sqlx::query( + "INSERT INTO sync_ack (user_id, device_id, cursor, acknowledged_at) \ + SELECT ?, ?, ?, ? WHERE EXISTS ( \ + SELECT 1 FROM device WHERE id=? AND user_id=? AND revoked_at IS NULL \ + ) ON CONFLICT (user_id, device_id) DO UPDATE SET \ + cursor=MAX(sync_ack.cursor, excluded.cursor), acknowledged_at=excluded.acknowledged_at", + ) + .bind(user_id.to_string()) + .bind(device_id.to_string()) + .bind(cursor) + .bind(now_ms()) + .bind(device_id.to_string()) + .bind(user_id.to_string()) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected() == 1) + } +} + +fn change_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let payload: String = row.try_get("payload_json")?; + Ok(SyncChange { + cursor: row.try_get("cursor")?, + event_id: parse_uuid(row.try_get("event_id")?)?, + operation_id: parse_uuid(row.try_get("operation_id")?)?, + origin_device_id: row + .try_get::, _>("origin_device_id")? + .map(parse_uuid) + .transpose()?, + entity_type: row.try_get("entity_type")?, + entity_id: parse_uuid(row.try_get("entity_id")?)?, + action: row.try_get("action")?, + payload: serde_json::from_str(&payload) + .map_err(|error| sqlx::Error::Decode(Box::new(error)))?, + changed_at: row.try_get("changed_at")?, + }) +} + +fn parse_uuid(value: String) -> Result { + Uuid::parse_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) +} diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index 78eea2a..f831824 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -185,6 +185,256 @@ async fn login_refresh_rotation_and_logout_work() { assert!(new_refresh.starts_with("wfr_")); } +#[tokio::test] +async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { + let (_temp, config, state) = test_app().await; + let password_hash = security::hash_password("correct horse battery staple").unwrap(); + state + .db + .create_account("web-listener", &password_hash, AccountRole::User, now_ms()) + .await + .unwrap(); + let router = waveflow_server::app(&config, state); + + let mut request = json_request( + "/api/v2/web/auth/login", + serde_json::json!({ + "username": "web-listener", + "password": "correct horse battery staple", + "device_name": "Embedded web player" + }), + ); + request + .headers_mut() + .insert("origin", "http://waveflow.test".parse().unwrap()); + request + .headers_mut() + .insert("host", "waveflow.test".parse().unwrap()); + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let cookies = response + .headers() + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect::>(); + let refresh_cookie = cookies + .iter() + .find(|cookie| cookie.starts_with("waveflow-refresh=")) + .unwrap(); + assert!(refresh_cookie.contains("HttpOnly")); + assert!(refresh_cookie.contains("SameSite=Strict")); + let refresh_pair = refresh_cookie.split(';').next().unwrap().to_owned(); + let csrf_pair = cookies + .iter() + .find(|cookie| cookie.starts_with("waveflow-csrf=")) + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let csrf = csrf_pair.split_once('=').unwrap().1.to_owned(); + let body = json_body(response).await; + assert!(body["access_token"].as_str().unwrap().starts_with("wfa_")); + assert!(body.get("refresh_token").is_none()); + + let missing_csrf = Request::post("/api/v2/web/auth/refresh") + .header("origin", "http://waveflow.test") + .header("host", "waveflow.test") + .header("cookie", format!("{refresh_pair}; {csrf_pair}")) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(missing_csrf).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + + let refresh = Request::post("/api/v2/web/auth/refresh") + .header("origin", "http://waveflow.test") + .header("host", "waveflow.test") + .header("cookie", format!("{refresh_pair}; {csrf_pair}")) + .header("x-waveflow-csrf", csrf) + .body(Body::empty()) + .unwrap(); + let response = router.clone().oneshot(refresh).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get_all("set-cookie") + .iter() + .filter(|value| value.to_str().unwrap().starts_with("waveflow-refresh=")) + .count(), + 1 + ); + + let mut foreign = json_request( + "/api/v2/web/auth/login", + serde_json::json!({ + "username": "web-listener", + "password": "correct horse battery staple", + "device_name": "Foreign page" + }), + ); + foreign + .headers_mut() + .insert("origin", "https://attacker.invalid".parse().unwrap()); + foreign + .headers_mut() + .insert("host", "waveflow.test".parse().unwrap()); + assert_eq!( + router.oneshot(foreign).await.unwrap().status(), + StatusCode::FORBIDDEN + ); +} + +#[tokio::test] +async fn setup_and_native_administration_cover_users_credentials_and_libraries() { + let (_temp, config, state) = test_app().await; + let music = config.data_dir.join("admin-library"); + std::fs::create_dir_all(&music).unwrap(); + let router = waveflow_server::app(&config, state.clone()); + + let status = router + .clone() + .oneshot(Request::get("/api/v2/setup").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(json_body(status).await["required"], true); + + let mut request = json_request( + "/api/v2/setup", + serde_json::json!({ + "username": "first-admin", + "password": "correct horse battery staple" + }), + ); + request + .headers_mut() + .insert("origin", "http://waveflow.test".parse().unwrap()); + request + .headers_mut() + .insert("host", "waveflow.test".parse().unwrap()); + assert_eq!( + router.clone().oneshot(request).await.unwrap().status(), + StatusCode::CREATED + ); + + let mut repeated = json_request( + "/api/v2/setup", + serde_json::json!({ + "username": "second-admin", + "password": "correct horse battery staple" + }), + ); + repeated + .headers_mut() + .insert("origin", "http://waveflow.test".parse().unwrap()); + repeated + .headers_mut() + .insert("host", "waveflow.test".parse().unwrap()); + assert_eq!( + router.clone().oneshot(repeated).await.unwrap().status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + + let admin_token = login_token(&router, "first-admin", "correct horse battery staple").await; + let create_user = Request::post("/api/v2/admin/users") + .header("authorization", format!("Bearer {admin_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "username": "native-listener", + "web_password": "another correct horse password", + "role": "user" + }) + .to_string(), + )) + .unwrap(); + let response = router.clone().oneshot(create_user).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let listener_id = Uuid::parse_str(json_body(response).await["id"].as_str().unwrap()).unwrap(); + + let credential = Request::put("/api/v2/admin/users/native-listener/subsonic-credential") + .header("authorization", format!("Bearer {admin_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "password": "dedicated subsonic password" }).to_string(), + )) + .unwrap(); + let response = router.clone().oneshot(credential).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let api_key = json_body(response).await["api_key"] + .as_str() + .unwrap() + .to_owned(); + assert!(api_key.starts_with("wfsk_")); + assert!(state + .services + .credential_by_api_key(&api_key) + .await + .unwrap() + .is_some()); + + let create_library = Request::post("/api/v2/libraries") + .header("authorization", format!("Bearer {admin_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "name": "Native library", + "path": std::fs::canonicalize(&music).unwrap().to_string_lossy(), + "visibility": "shared" + }) + .to_string(), + )) + .unwrap(); + let response = router.clone().oneshot(create_library).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let library_id = + Uuid::parse_str(json_body(response).await["library_id"].as_str().unwrap()).unwrap(); + + let set_member = Request::put(format!( + "/api/v2/libraries/{library_id}/members/{listener_id}" + )) + .header("authorization", format!("Bearer {admin_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "role": "listener" }).to_string(), + )) + .unwrap(); + assert_eq!( + router.clone().oneshot(set_member).await.unwrap().status(), + StatusCode::NO_CONTENT + ); + + let listener_token = + login_token(&router, "native-listener", "another correct horse password").await; + let libraries = router + .clone() + .oneshot( + Request::get("/api/v2/libraries") + .header("authorization", format!("Bearer {listener_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let libraries = json_body(libraries).await; + assert_eq!(libraries.as_array().unwrap().len(), 1); + assert_eq!(libraries[0]["id"], library_id.to_string()); + + let forbidden = router + .oneshot( + Request::get("/api/v2/admin/users") + .header("authorization", format!("Bearer {listener_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(forbidden.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn long_lived_native_api_tokens_authenticate_and_honor_revocation() { let (_temp, config, state) = test_app().await; @@ -304,6 +554,19 @@ async fn probes_and_openapi_are_available_without_scan_readiness() { assert_eq!(openapi.status(), StatusCode::OK); let document = json_body(openapi).await; assert!(document["paths"]["/api/v2/auth/login"].is_object()); + for path in [ + "/api/v2/setup", + "/api/v2/web/auth/login", + "/api/v2/libraries", + "/api/v2/admin/users", + "/api/v2/sync/snapshot", + "/api/v2/sync/changes", + "/api/v2/sync/ack", + "/api/v2/sync/socket", + "/api/v2/transcode/status", + ] { + assert!(document["paths"][path].is_object(), "missing {path}"); + } } #[tokio::test] @@ -1543,6 +1806,19 @@ async fn subsonic_xml_json_auth_catalog_and_user_data_are_compatible() { .await["subsonic-response"]["status"], "ok" ); + let journal_entities = sqlx::query_scalar::<_, String>( + "SELECT DISTINCT entity_type FROM sync_event WHERE user_id=? ORDER BY entity_type", + ) + .bind(admin.to_string()) + .fetch_all(state.db.pool()) + .await + .unwrap(); + assert!(journal_entities.contains(&"playlist".to_owned())); + assert!(journal_entities.contains(&"favorite".to_owned())); + assert!(journal_entities.contains(&"rating".to_owned())); + assert!(journal_entities.contains(&"scrobble".to_owned())); + assert!(journal_entities.contains(&"queue".to_owned())); + assert!(journal_entities.contains(&"share".to_owned())); let default_user = subsonic_json( &router, @@ -2818,6 +3094,256 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { assert_eq!(gone.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { + let (_temp, config, state) = test_app().await; + let password = "sync integration password"; + let hash = security::hash_password(password).unwrap(); + let owner = state + .db + .create_account("sync-owner", &hash, AccountRole::Admin, now_ms()) + .await + .unwrap(); + state + .db + .create_account("sync-intruder", &hash, AccountRole::User, now_ms()) + .await + .unwrap(); + let music = config.data_dir.join("sync-music"); + std::fs::create_dir_all(&music).unwrap(); + let library = state + .db + .create_library( + owner, + "Sync library", + &std::fs::canonicalize(&music).unwrap(), + LibraryVisibility::Private, + now_ms(), + ) + .await + .unwrap(); + let scan = state + .db + .create_scan_job(library, Some(owner), "manual") + .await + .unwrap(); + state.db.start_scan_job(scan, 1).await.unwrap(); + state + .db + .apply_catalog_track( + library, + scan, + &browse_input( + 0, + "Synchronized Song", + "Remote Album", + "Remote Artist", + Some(1), + Some(1), + ), + None, + false, + ) + .await + .unwrap(); + state.db.finish_scan_job(scan, 0).await.unwrap(); + let track = state.db.list_tracks_for_user(owner, library).await.unwrap()[0].id; + + let mut notices = state.sync.subscribe(); + let router = waveflow_server::app(&config, state.clone()); + let login = |username: &'static str| { + let router = router.clone(); + async move { + let response = router + .oneshot(json_request( + "/api/v2/auth/login", + serde_json::json!({ + "username": username, + "password": password, + "device_name": format!("{username} desktop") + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + json_body(response).await + } + }; + let owner_login = login("sync-owner").await; + let owner_token = owner_login["access_token"].as_str().unwrap().to_owned(); + let device_id = owner_login["device_id"].as_str().unwrap().to_owned(); + let intruder_token = login("sync-intruder").await["access_token"] + .as_str() + .unwrap() + .to_owned(); + + let mutate = + |method: &'static str, uri: String, operation_id: Uuid, body: Option| { + let router = router.clone(); + let owner_token = owner_token.clone(); + let device_id = device_id.clone(); + async move { + let request = Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {owner_token}")) + .header("x-waveflow-operation-id", operation_id.to_string()) + .header("x-waveflow-device-id", device_id); + let request = match body { + Some(body) => request + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + None => request.body(Body::empty()).unwrap(), + }; + router.oneshot(request).await.unwrap() + } + }; + + // Retrying a mutation with the same operation UUID must neither duplicate + // the business row nor append another event. + let favorite_operation = Uuid::new_v4(); + for _ in 0..2 { + let response = mutate( + "PUT", + format!("/api/v2/favorites/track/{track}"), + favorite_operation, + None, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + let star_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_star WHERE user_id=?") + .bind(owner.to_string()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(star_count, 1); + + let scrobble_operation = Uuid::new_v4(); + for _ in 0..2 { + let response = mutate( + "POST", + "/api/v2/scrobbles".into(), + scrobble_operation, + Some(serde_json::json!({ "track_id": track, "submission": true })), + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + let play_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM play_event WHERE user_id=?") + .bind(owner.to_string()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(play_count, 1, "a retried scrobble must stay idempotent"); + + let create_operation = Uuid::new_v4(); + let mut playlist_ids = Vec::new(); + for _ in 0..2 { + let response = mutate( + "POST", + "/api/v2/playlists".into(), + create_operation, + Some(serde_json::json!({ "name": "Synced", "track_ids": [track] })), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + playlist_ids.push(json_body(response).await["id"].as_str().unwrap().to_owned()); + } + assert_eq!(playlist_ids[0], playlist_ids[1]); + + let share_operation = Uuid::new_v4(); + let mut share_ids = Vec::new(); + for _ in 0..2 { + let response = mutate( + "POST", + "/api/v2/shares".into(), + share_operation, + Some(serde_json::json!({ + "track_ids": [track], + "description": "Synchronized share" + })), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + share_ids.push(json_body(response).await["id"].as_str().unwrap().to_owned()); + } + assert_eq!(share_ids[0], share_ids[1]); + + let notice = tokio::time::timeout(std::time::Duration::from_secs(1), notices.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(notice.0, owner); + assert!(notice.1.cursor > 0); + + let changes = router + .clone() + .oneshot( + Request::get("/api/v2/sync/changes?after=0&limit=1") + .header("authorization", format!("Bearer {owner_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(changes.status(), StatusCode::OK); + let changes = json_body(changes).await; + assert_eq!(changes["changes"].as_array().unwrap().len(), 1); + assert!(changes["has_more"].as_bool().unwrap()); + assert_eq!( + changes["changes"][0]["operation_id"], + favorite_operation.to_string() + ); + + let snapshot = router + .clone() + .oneshot( + Request::get("/api/v2/sync/snapshot") + .header("authorization", format!("Bearer {owner_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let snapshot = json_body(snapshot).await; + assert_eq!(snapshot["favorites"].as_array().unwrap().len(), 1); + assert_eq!(snapshot["history"].as_array().unwrap().len(), 1); + assert_eq!(snapshot["playlists"].as_array().unwrap().len(), 1); + assert_eq!(snapshot["shares"].as_array().unwrap().len(), 1); + let cursor = snapshot["cursor"].as_i64().unwrap(); + + let ack = router + .clone() + .oneshot( + Request::put("/api/v2/sync/ack") + .header("authorization", format!("Bearer {owner_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "device_id": device_id, "cursor": cursor }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(ack.status(), StatusCode::NO_CONTENT); + + let foreign = router + .oneshot( + Request::get("/api/v2/sync/changes?after=0") + .header("authorization", format!("Bearer {intruder_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(json_body(foreign).await["changes"] + .as_array() + .unwrap() + .is_empty()); +} + #[tokio::test] async fn embedded_web_client_serves_shell_without_shadowing_the_api() { let (_temp, config, state) = test_app().await; diff --git a/webapp/src/api.ts b/webapp/src/api.ts index 8d1ac7c..56ba221 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -1,16 +1,23 @@ /** * Thin client over /api/v2. * - * Access tokens are short-lived, so every call retries once through /refresh - * before surfacing a 401. Tokens live in localStorage: this is a SPA with no - * server-rendered session, and the alternative — a cookie — would add ambient - * authentication and a CSRF surface to an API that is otherwise header-only. + * Access tokens are short-lived and live in memory only. The server keeps the + * rotating refresh token in an HttpOnly, SameSite cookie; refresh and logout + * additionally require a double-submit CSRF value. */ -const ACCESS_KEY = "waveflow.access"; -const REFRESH_KEY = "waveflow.refresh"; +export type WebSession = { access_token: string }; -export type Tokens = { access_token: string; refresh_token: string }; +let session: WebSession | null = null; + +// M4 originally persisted both tokens. Remove those legacy entries on upgrade +// so an old rotating refresh token is not left readable by JavaScript. +try { + localStorage.removeItem("waveflow.access"); + localStorage.removeItem("waveflow.refresh"); +} catch { + // Storage may be disabled; sessions do not depend on it anymore. +} export type Album = { id: string; @@ -62,20 +69,8 @@ export class ApiError extends Error { } } -export function storedTokens(): Tokens | null { - const access_token = localStorage.getItem(ACCESS_KEY); - const refresh_token = localStorage.getItem(REFRESH_KEY); - return access_token && refresh_token ? { access_token, refresh_token } : null; -} - -function store(tokens: Tokens) { - localStorage.setItem(ACCESS_KEY, tokens.access_token); - localStorage.setItem(REFRESH_KEY, tokens.refresh_token); -} - -export function clearTokens() { - localStorage.removeItem(ACCESS_KEY); - localStorage.removeItem(REFRESH_KEY); +export function hasSession(): boolean { + return session !== null; } async function parse(response: Response): Promise { @@ -104,29 +99,31 @@ function refresh(): Promise { } async function performRefresh(): Promise { - const tokens = storedTokens(); - if (!tokens) return false; - const response = await fetch("/api/v2/auth/refresh", { + const csrf = cookieValue("waveflow-csrf"); + if (!csrf) return false; + const response = await fetch("/api/v2/web/auth/refresh", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ refresh_token: tokens.refresh_token }), + headers: { "x-waveflow-csrf": csrf }, }); if (!response.ok) { - clearTokens(); + session = null; return false; } - store(await parse(response)); + session = await parse(response); return true; } +export async function ensureSession(): Promise { + return hasSession() || refresh(); +} + async function call( path: string, init: RequestInit = {}, retry = true, ): Promise { - const tokens = storedTokens(); const headers = new Headers(init.headers); - if (tokens) headers.set("authorization", `Bearer ${tokens.access_token}`); + if (session) headers.set("authorization", `Bearer ${session.access_token}`); if (init.body) headers.set("content-type", "application/json"); const response = await fetch(path, { ...init, headers }); if (response.status === 401 && retry && (await refresh())) { @@ -139,7 +136,7 @@ async function call( } export async function login(username: string, password: string): Promise { - const response = await fetch("/api/v2/auth/login", { + const response = await fetch("/api/v2/web/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -151,15 +148,47 @@ export async function login(username: string, password: string): Promise { if (!response.ok) { throw new ApiError(response.status, "login failed"); } - store(await parse(response)); + session = await parse(response); +} + +export const setupRequired = () => + call<{ required: boolean }>("/api/v2/setup", {}, false).then( + (status) => status.required, + ); + +export async function bootstrapAdmin( + username: string, + password: string, +): Promise { + const response = await fetch("/api/v2/setup", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!response.ok) { + throw new ApiError(response.status, "setup failed"); + } } export async function logout(): Promise { try { - await call("/api/v2/auth/logout", { method: "POST" }); + const csrf = cookieValue("waveflow-csrf"); + await call("/api/v2/web/auth/logout", { + method: "POST", + headers: csrf ? { "x-waveflow-csrf": csrf } : undefined, + }); } finally { - clearTokens(); + session = null; + } +} + +function cookieValue(name: string): string | null { + const prefix = `${name}=`; + for (const part of document.cookie.split(";")) { + const value = part.trim(); + if (value.startsWith(prefix)) return value.slice(prefix.length); } + return null; } /** Walks the paged endpoint to completion; the server caps a page at 500. */ diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index e60ad30..956103d 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -11,7 +11,7 @@ import { import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { logout, storedTokens } from "./api"; +import { ensureSession, logout } from "./api"; import { AlbumPage, AlbumsPage, @@ -64,8 +64,8 @@ const loginRoute = createRoute({ const authedRoute = createRoute({ getParentRoute: () => rootRoute, id: "authed", - beforeLoad: () => { - if (!storedTokens()) { + beforeLoad: async () => { + if (!(await ensureSession())) { // Remember where the user was headed so a desktop authorisation link // survives the detour through sign-in instead of dropping its PKCE // parameters and forcing the client to start over. diff --git a/webapp/src/pages.tsx b/webapp/src/pages.tsx index 9690dc9..0d1e3a5 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -7,6 +7,7 @@ import { type Artist, type ArtistDetail, authorize, + bootstrapAdmin, formatDuration, getAlbum, getArtist, @@ -19,6 +20,7 @@ import { safeInternalPath, search, setFavorite, + setupRequired, } from "./api"; import { usePlayer } from "./player"; @@ -56,6 +58,11 @@ export function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [setup, setSetup] = useState(false); + + useEffect(() => { + void setupRequired().then(setSetup, () => setSetup(false)); + }, []); async function submit(event: FormEvent) { event.preventDefault(); @@ -63,11 +70,14 @@ export function LoginPage() { setError(null); try { try { + if (setup) await bootstrapAdmin(username, password); await login(username, password); } catch { - // Only a failed sign-in means bad credentials. Anything after it is a - // different problem and must not be reported as one. - setError("Wrong username or password."); + setError( + setup + ? "Setup failed. Use a valid username and at least 12 password characters." + : "Wrong username or password.", + ); return; } const next = safeInternalPath( @@ -84,7 +94,12 @@ export function LoginPage() { return (
-

WaveFlow

+

{setup ? "Create the administrator" : "WaveFlow"}

+ {setup ? ( +

+ This is a new server. Choose the first administrator account. +

+ ) : null} {error &&

{error}

}
From 8b61f21f5def0b0b3eac663999c627bcd25f23f6 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 12:52:51 +0200 Subject: [PATCH 02/17] fix(dco): accept dependabot signing identity Signed-off-by: InstaZDLL --- .github/workflows/dco.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index 430e51c..1072801 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -41,6 +41,11 @@ jobs: author_email=$(git log -1 --format='%ae' "$sha") author_name=$(git log -1 --format='%an' "$sha") expected="Signed-off-by: ${author_name} <${author_email}>" + # GitHub authors Dependabot commits with its noreply identity but + # signs the generated message with its documented support address. + if [ "$author_name" = "dependabot[bot]" ] && [ "$author_email" = "49699333+dependabot[bot]@users.noreply.github.com" ]; then + expected="Signed-off-by: dependabot[bot] " + fi if ! git log -1 --format='%B' "$sha" | grep -qFx "$expected"; then echo "::error::Commit $sha is missing the DCO sign-off line." echo " Expected trailer: $expected" From 66d4383656c74f8d3e8f19ef4e39e051250e1552 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 12:56:18 +0200 Subject: [PATCH 03/17] docs(sync-v2): clarify idempotent replay semantics Signed-off-by: InstaZDLL --- docs/rfcs/RFC-003-waveflow-sync-v2.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/RFC-003-waveflow-sync-v2.md b/docs/rfcs/RFC-003-waveflow-sync-v2.md index 201f64a..07a2dcb 100644 --- a/docs/rfcs/RFC-003-waveflow-sync-v2.md +++ b/docs/rfcs/RFC-003-waveflow-sync-v2.md @@ -75,8 +75,9 @@ known event discards its local projection and fetches a fresh snapshot. The operation reservation, domain mutation and journal append commit in one SQLite transaction behind the process-wide writer gate. Repeating the same -operation ID returns the original result and never creates a second domain row -or journal event. +operation ID is recognized as already applied and never creates a second domain +row or journal event. Resource endpoints return the current representation when +it still exists; the journal receipt remains the durable proof of application. `PUT /api/v2/sync/ack` with `{ "device_id": "", "cursor": 42 }` records a monotonic per-device acknowledgement. A cursor below the stored ACK From e7c8b64954d05dcb9b75641da816de29b4832b0a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 13:21:36 +0200 Subject: [PATCH 04/17] feat(webapp): complete functional server workflows Signed-off-by: InstaZDLL --- docs/M4-handoff.md | 27 ++- src/http.rs | 19 ++ src/lib.rs | 1 + tests/v2_foundations.rs | 20 ++ webapp/src/api.ts | 145 +++++++++++- webapp/src/main.tsx | 101 +++++--- webapp/src/pages.tsx | 507 +++++++++++++++++++++++++++++++++++++++- webapp/src/player.tsx | 89 ++++++- webapp/src/styles.css | 214 +++++++++++++++++ 9 files changed, 1081 insertions(+), 42 deletions(-) diff --git a/docs/M4-handoff.md b/docs/M4-handoff.md index 392a6cd..37912ad 100644 --- a/docs/M4-handoff.md +++ b/docs/M4-handoff.md @@ -39,17 +39,25 @@ la façade Subsonic. ```text GET /api/v2/albums · /albums/{id} · /artists · /artists/{id} · /search +GET /api/v2/tracks/{id} GET /api/v2/playlists · POST · PATCH · DELETE GET /api/v2/favorites · PUT|DELETE /favorites/{kind}/{id} PUT /api/v2/ratings/{kind}/{id} · POST /scrobbles · GET /now-playing GET|PUT /api/v2/queue +GET|POST|PATCH|DELETE /api/v2/shares +GET|POST /api/v2/libraries · PUT|DELETE /libraries/{id}/members +GET|POST|PATCH /api/v2/users · PUT|DELETE /users/{name}/subsonic-credential +GET /api/v2/sync/snapshot · /changes · WS /sync/socket · POST /sync/ack POST /api/v2/oauth/authorize · POST /api/v2/oauth/token POST /api/v2/tracks/{id}/stream-ticket · GET /api/v2/stream/{ticket} ``` **Client web embarqué** (`webapp/`, Vite + React + TanStack Router), compilé dans le binaire par `rust_embed`. Connexion, albums, artistes, recherche, -favoris, lecture, écran de consentement OAuth. +favoris, playlists, file d'attente persistante, partages, lecture, écran de +consentement OAuth et administration des bibliothèques, scans, comptes et +identifiants Subsonic. Le lecteur n'est monté qu'après authentification afin de +charger la file du bon compte à chaque nouvelle session. **Retrait de la v1** : `src/api/*`, `db.rs`, `apply.rs`, `sync.rs`, les migrations PostgreSQL et 21 fichiers de tests. Tout était déjà mort (non déclaré @@ -105,10 +113,11 @@ régression. ## Ce qui reste 1. **Valider puis fusionner le complément serveur M4.** Il ajoute le journal de - synchronisation documenté par RFC-003, complète l'administration native et - ferme la dette de session navigateur. Le workflow DCO accepte désormais - l'adresse de signature réellement émise par Dependabot, sans dérogation - manuelle aux protections de branche. + synchronisation documenté par RFC-003, complète l'administration native, + ferme la dette de session navigateur et livre tous les parcours fonctionnels + du client web prévus pour M4. Le workflow DCO accepte désormais l'adresse de + signature réellement émise par Dependabot, sans dérogation manuelle aux + protections de branche. 2. **Taguer une release uniquement sur demande explicite du user.** M3 et sa validation Symfonium sont terminés ; aucune action de compatibilité ne reste ouverte pour cette porte. @@ -130,9 +139,11 @@ vérifié. ## Dettes identifiées, non traitées - `search3` n'exploite pas FTS5 (voir ci-dessus). -- `webapp/` n'a pas de test de composant ni de parcours : la suite couvre les - gardes de redirection et les design tokens. La CI web lint (biome), construit - et lance vitest. +- `webapp/` n'a pas encore de test de composant ou de parcours automatisé. La + suite couvre les gardes de redirection et les design tokens ; un smoke test + navigateur manuel sur installation vide valide setup, session, rôles, + playlists, favoris, queue, partages, bibliothèques, scans, comptes et rotation + d'identifiant Subsonic. La CI web lint (Biome), construit et lance Vitest. - La dette de session navigateur est fermée par le complément M4 : access token court en mémoire, refresh rotatif dans un cookie HttpOnly/SameSite, contrôle d'origine et double-submit CSRF sur refresh/logout. Aucun secret de session diff --git a/src/http.rs b/src/http.rs index 47c982f..2b72e1d 100644 --- a/src/http.rs +++ b/src/http.rs @@ -326,6 +326,7 @@ pub fn router(state: AppState) -> Router { .route("/api/v2/scans/{scan_id}", get(scan_status)) .route("/api/v2/scans/{scan_id}/events", get(scan_events)) .route("/api/v2/libraries/{library_id}/tracks", get(list_tracks)) + .route("/api/v2/tracks/{track_id}", get(get_track)) .route("/api/v2/albums", get(list_albums)) .route("/api/v2/albums/{album_id}", get(get_album)) .route("/api/v2/artists", get(list_artists)) @@ -822,6 +823,24 @@ pub async fn list_tracks( Ok(Json(tracks)) } +#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::SongItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn get_track( + State(state): State, + Path(track_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers).await?; + state + .services + .songs_by_ids(user.id, &[track_id]) + .await + .map_err(service_error)? + .into_iter() + .next() + .map(Json) + .ok_or(ApiError::NotFound) +} + #[utoipa::path(get, path = "/api/v2/albums", tag = "catalog", params(("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::AlbumItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] pub async fn list_albums( State(state): State, diff --git a/src/lib.rs b/src/lib.rs index cd28483..e1deefc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,6 +84,7 @@ pub struct AppState { http::scan_status, http::scan_events, http::list_tracks, + http::get_track, http::list_albums, http::get_album, http::list_artists, diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index f831824..dc0a676 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -2927,6 +2927,26 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { let first = detail["songs"][0]["id"].as_str().unwrap().to_owned(); let second = detail["songs"][1]["id"].as_str().unwrap().to_owned(); + // Individual tracks can be resolved for favorites and queue hydration, + // while the same public id remains opaque to another tenant. + let track = send( + "GET", + format!("/api/v2/tracks/{first}"), + owner_token.clone(), + None, + ) + .await; + assert_eq!(track.status(), StatusCode::OK); + assert_eq!(json_body(track).await["id"], first); + let foreign_track = send( + "GET", + format!("/api/v2/tracks/{first}"), + intruder_token.clone(), + None, + ) + .await; + assert_eq!(foreign_track.status(), StatusCode::NOT_FOUND); + // Playlists: create, read back, mutate, then delete. let created = send( "POST", diff --git a/webapp/src/api.ts b/webapp/src/api.ts index 56ba221..9ddba6e 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -6,7 +6,17 @@ * additionally require a double-submit CSRF value. */ -export type WebSession = { access_token: string }; +export type SessionUser = { + id: string; + username: string; + role: "admin" | "user"; +}; + +export type WebSession = { + access_token: string; + user: SessionUser; + device_id: string; +}; let session: WebSession | null = null; @@ -60,6 +70,58 @@ export type SearchResult = { songs: Song[]; }; +export type Playlist = { + id: string; + name: string; + comment: string | null; + public: boolean; + created_at: number; + updated_at: number; + songs: Song[]; +}; + +export type Favorite = { + entity_type: string; + entity_id: string; + starred_at: number; +}; + +export type Queue = { + current: string | null; + position_ms: number; + changed_by: string | null; + updated_at: number; + songs: Song[]; +}; + +export type Share = { + id: string; + url: string; + description: string | null; + expires_at: number | null; + created_at: number; + visit_count: number; + track_ids: string[]; +}; + +export type Library = { + id: string; + name: string; + visibility: "private" | "shared"; + role: "owner" | "manager" | "listener"; + last_scan_started_at: number | null; + last_scan_completed_at: number | null; +}; + +export type User = { + id: string; + username: string; + role: "admin" | "user"; + disabled: boolean; + has_subsonic_credential: boolean; + folder_ids: string[]; +}; + export class ApiError extends Error { constructor( readonly status: number, @@ -73,6 +135,10 @@ export function hasSession(): boolean { return session !== null; } +export function currentUser(): SessionUser | null { + return session?.user ?? null; +} + async function parse(response: Response): Promise { if (response.status === 204) return undefined as T; const text = await response.text(); @@ -210,6 +276,83 @@ export const getArtist = (id: string) => call(`/api/v2/artists/${id}`); export const search = (query: string) => call(`/api/v2/search?q=${encodeURIComponent(query)}`); +export const getTrack = (id: string) => call(`/api/v2/tracks/${id}`); + +export const listPlaylists = () => call("/api/v2/playlists"); +export const createPlaylist = (name: string, trackIds: string[] = []) => + call("/api/v2/playlists", { + method: "POST", + body: JSON.stringify({ name, track_ids: trackIds }), + }); +export const deletePlaylist = (id: string) => + call(`/api/v2/playlists/${id}`, { method: "DELETE" }); +export const appendToPlaylist = (id: string, trackIds: string[]) => + call(`/api/v2/playlists/${id}`, { + method: "PATCH", + body: JSON.stringify({ add: trackIds }), + }); + +export const listFavorites = () => call("/api/v2/favorites"); +export const getQueue = () => call("/api/v2/queue"); +export const saveQueue = ( + songs: Song[], + current: string | null, + positionMs: number, +) => + call("/api/v2/queue", { + method: "PUT", + body: JSON.stringify({ + track_ids: songs.map((song) => song.id), + current, + position_ms: positionMs, + client: "WaveFlow Web", + }), + }); + +export const listShares = () => call("/api/v2/shares"); +export const createShare = (trackIds: string[], description: string) => + call("/api/v2/shares", { + method: "POST", + body: JSON.stringify({ track_ids: trackIds, description }), + }); +export const deleteShare = (id: string) => + call(`/api/v2/shares/${id}`, { method: "DELETE" }); + +export const listLibraries = () => call("/api/v2/libraries"); +export const addLibrary = ( + name: string, + path: string, + visibility: "private" | "shared", +) => + call<{ library_id: string; scan_id: string }>("/api/v2/libraries", { + method: "POST", + body: JSON.stringify({ name, path, visibility }), + }); +export const startScan = (libraryId: string) => + call<{ scan_id: string }>(`/api/v2/libraries/${libraryId}/scans`, { + method: "POST", + }); + +export const listUsers = () => call("/api/v2/admin/users"); +export const createUser = ( + username: string, + webPassword: string, + role: "admin" | "user", +) => + call("/api/v2/admin/users", { + method: "POST", + body: JSON.stringify({ username, web_password: webPassword, role }), + }); +export const setUserDisabled = (username: string, disabled: boolean) => + call(`/api/v2/admin/users/${encodeURIComponent(username)}`, { + method: "PATCH", + body: JSON.stringify({ disabled }), + }); +export const setSubsonicCredential = (username: string, password: string) => + call<{ api_key: string }>( + `/api/v2/admin/users/${encodeURIComponent(username)}/subsonic-credential`, + { method: "PUT", body: JSON.stringify({ password }) }, + ); export const setFavorite = (kind: string, id: string, on: boolean) => call(`/api/v2/favorites/${kind}/${id}`, { diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 956103d..299d841 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -11,44 +11,57 @@ import { import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { ensureSession, logout } from "./api"; +import { currentUser, ensureSession, logout } from "./api"; import { + AdminPage, AlbumPage, AlbumsPage, ArtistPage, ArtistsPage, AuthorizePage, + FavoritesPage, LoginPage, + PlaylistsPage, + QueuePage, SearchPage, + SharesPage, } from "./pages"; import { PlayerBar, PlayerProvider } from "./player"; import "./styles.css"; function Shell() { const navigate = useNavigate(); + const user = currentUser(); return ( -
- -
- -
- -
+ +
+ +
+ +
+ +
+
); } @@ -123,6 +136,39 @@ const searchRoute = createRoute({ component: SearchPage, }); +const favoritesRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/favourites", + component: FavoritesPage, +}); + +const playlistsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/playlists", + component: PlaylistsPage, +}); + +const queueRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/queue", + component: QueuePage, +}); + +const sharesRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/shares", + component: SharesPage, +}); + +const adminRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/admin", + beforeLoad: () => { + if (currentUser()?.role !== "admin") throw redirect({ to: "/" }); + }, + component: AdminPage, +}); + const routeTree = rootRoute.addChildren([ loginRoute, authedRoute.addChildren([ @@ -131,6 +177,11 @@ const routeTree = rootRoute.addChildren([ artistsRoute, artistRoute, searchRoute, + favoritesRoute, + playlistsRoute, + queueRoute, + sharesRoute, + adminRoute, authorizeRoute, ]), ]); @@ -148,8 +199,6 @@ if (!container) throw new Error("missing #root"); createRoot(container).render( - - - + , ); diff --git a/webapp/src/pages.tsx b/webapp/src/pages.tsx index 0d1e3a5..3b3ab7b 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -6,21 +6,40 @@ import { type AlbumDetail, type Artist, type ArtistDetail, + addLibrary, + appendToPlaylist, authorize, bootstrapAdmin, + createPlaylist, + createShare, + createUser, + currentUser, + deletePlaylist, + deleteShare, formatDuration, getAlbum, getArtist, + getTrack, isAllowedRedirect, listAlbums, listArtists, + listFavorites, + listLibraries, + listPlaylists, + listShares, + listUsers, login, + type Playlist, type SearchResult, type Song, safeInternalPath, search, setFavorite, + setSubsonicCredential, + setUserDisabled, setupRequired, + startScan, + type User, } from "./api"; import { usePlayer } from "./player"; @@ -161,7 +180,7 @@ export function AlbumsPage() { ); } -function SongTable({ songs }: { songs: Song[] }) { +export function SongTable({ songs }: { songs: Song[] }) { const player = usePlayer(); const [stars, setStars] = useState>({}); @@ -214,6 +233,490 @@ function SongTable({ songs }: { songs: Song[] }) { ); } +export function FavoritesPage() { + const { value, error } = useAsync(async () => { + const favorites = await listFavorites(); + const tracks = favorites.filter((item) => item.entity_type === "track"); + return Promise.all(tracks.map((item) => getTrack(item.entity_id))); + }, []); + if (!value) return ; + return ( +
+ + {value.length ? ( + + ) : ( + + )} +
+ ); +} + +export function PlaylistsPage() { + const player = usePlayer(); + const [revision, setRevision] = useState(0); + const [name, setName] = useState(""); + const [mutationError, setMutationError] = useState(null); + const { value, error } = useAsync(listPlaylists, [revision]); + + async function create(event: FormEvent) { + event.preventDefault(); + setMutationError(null); + try { + await createPlaylist(name); + setName(""); + setRevision((value) => value + 1); + } catch { + setMutationError("The playlist could not be created."); + } + } + + async function addQueue(playlist: Playlist) { + if (!player.queue.length) return; + try { + await appendToPlaylist( + playlist.id, + player.queue.map((song) => song.id), + ); + setRevision((value) => value + 1); + } catch { + setMutationError("The queue could not be added to this playlist."); + } + } + + async function removePlaylist(id: string) { + setMutationError(null); + try { + await deletePlaylist(id); + setRevision((value) => value + 1); + } catch { + setMutationError("The playlist could not be deleted."); + } + } + + if (!value) return ; + return ( +
+ +
void create(event)}> + setName(event.target.value)} + placeholder="New playlist name" + aria-label="New playlist name" + required + /> + +
+ {mutationError ?

{mutationError}

: null} + {value.length ? ( +
+ {value.map((playlist) => ( +
+
+
+

{playlist.name}

+ {playlist.songs.length} tracks +
+
+ + + +
+
+ {playlist.songs.length ? ( + + ) : ( +

This playlist is empty.

+ )} +
+ ))} +
+ ) : ( + + )} +
+ ); +} + +export function QueuePage() { + const player = usePlayer(); + return ( +
+ + {player.queue.length ? ( + <> +
+ + +
+
    + {player.queue.map((song, position) => ( +
  1. + + {song.artist ?? "Unknown artist"} + + {formatDuration(song.duration_ms)} + + +
  2. + ))} +
+ + ) : ( + + )} +
+ ); +} + +export function SharesPage() { + const player = usePlayer(); + const [description, setDescription] = useState(""); + const [revision, setRevision] = useState(0); + const [mutationError, setMutationError] = useState(null); + const { value, error } = useAsync(listShares, [revision]); + if (!value) return ; + + async function shareQueue(event: FormEvent) { + event.preventDefault(); + setMutationError(null); + try { + await createShare( + player.queue.map((song) => song.id), + description, + ); + setDescription(""); + setRevision((value) => value + 1); + } catch { + setMutationError("The share could not be created."); + } + } + + async function removeShare(id: string) { + setMutationError(null); + try { + await deleteShare(id); + setRevision((value) => value + 1); + } catch { + setMutationError("The share could not be deleted."); + } + } + + return ( +
+ +
void shareQueue(event)} + > + setDescription(event.target.value)} + placeholder="Description" + aria-label="Share description" + required + /> + +
+ {!player.queue.length ? ( +

Add tracks to the queue before creating a link.

+ ) : null} + {mutationError ?

{mutationError}

: null} + {value.length ? ( +
    + {value.map((share) => ( +
  • +
    + {share.description ?? "Music share"} + + {share.url} + +
    + {share.track_ids.length} tracks + +
  • + ))} +
+ ) : ( + + )} +
+ ); +} + +function CredentialForm({ user }: { user: User }) { + const [password, setPassword] = useState(""); + const [apiKey, setApiKey] = useState(null); + const [error, setError] = useState(null); + async function submit(event: FormEvent) { + event.preventDefault(); + setError(null); + try { + const result = await setSubsonicCredential(user.username, password); + setApiKey(result.api_key); + setPassword(""); + } catch { + setError("Credential rotation failed."); + } + } + return ( +
void submit(event)}> + setPassword(event.target.value)} + placeholder="Dedicated Subsonic password" + aria-label={`Subsonic password for ${user.username}`} + minLength={12} + required + /> + + {apiKey ? ( + + Copy this API key now: {apiKey} + + ) : null} + {error ? {error} : null} +
+ ); +} + +export function AdminPage() { + const signedInUser = currentUser(); + const [revision, setRevision] = useState(0); + const [notice, setNotice] = useState(null); + const [adminError, setAdminError] = useState(null); + const [libraryName, setLibraryName] = useState(""); + const [libraryPath, setLibraryPath] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const { value, error } = useAsync( + () => Promise.all([listLibraries(), listUsers()]), + [revision], + ); + if (!value) return ; + const [libraries, users] = value; + + async function registerLibrary(event: FormEvent) { + event.preventDefault(); + setAdminError(null); + try { + const result = await addLibrary(libraryName, libraryPath, "private"); + setLibraryName(""); + setLibraryPath(""); + setNotice(`Initial scan ${result.scan_id} queued.`); + setRevision((value) => value + 1); + } catch { + setAdminError("The library path could not be registered."); + } + } + + async function addUser(event: FormEvent) { + event.preventDefault(); + setAdminError(null); + try { + await createUser(username, password, "user"); + setUsername(""); + setPassword(""); + setRevision((value) => value + 1); + } catch { + setAdminError("The account could not be created."); + } + } + + async function toggleUser(user: User) { + setAdminError(null); + try { + await setUserDisabled(user.username, !user.disabled); + setRevision((value) => value + 1); + } catch { + setAdminError("The account status could not be changed."); + } + } + + async function scanLibrary(libraryId: string) { + setAdminError(null); + try { + const result = await startScan(libraryId); + setNotice(`Scan ${result.scan_id} queued.`); + } catch { + setAdminError("The scan could not be started."); + } + } + + return ( +
+ + {notice ?

{notice}

: null} + {adminError ?

{adminError}

: null} +
+
+

Libraries

+
void registerLibrary(event)} + > + setLibraryName(event.target.value)} + placeholder="Library name" + required + /> + setLibraryPath(event.target.value)} + placeholder="Absolute server folder path" + required + /> + +
+
    + {libraries.map((library) => ( +
  • +
    + {library.name} + {library.visibility} +
    + +
  • + ))} +
+
+
+

Accounts

+
void addUser(event)} + > + setUsername(event.target.value)} + placeholder="Username" + autoComplete="off" + required + /> + setPassword(event.target.value)} + placeholder="Web password, 12 characters minimum" + minLength={12} + autoComplete="new-password" + required + /> + +
+
+
+
+ {users.map((user) => ( +
+
+
+ {user.username} + + {user.role} · {user.folder_ids.length} libraries + +
+ +
+ +
+ ))} +
+
+ ); +} + +function PageHeader({ title, detail }: { title: string; detail: string }) { + return ( +
+

{title}

+

{detail}

+
+ ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} + +function queueOccurrenceKey(songs: Song[], position: number): string { + const id = songs[position]?.id ?? "missing"; + const occurrence = songs + .slice(0, position) + .filter((song) => song.id === id).length; + return `${id}-${occurrence}`; +} + export function AlbumPage({ albumId }: { albumId: string }) { const { value, error } = useAsync( () => getAlbum(albumId), @@ -413,7 +916,7 @@ export function AuthorizePage() {

Authorise {clientId}

It will be able to browse your libraries, play your music and manage - your playlists, favourites and ratings — everything this account can do. + your playlists, favourites and ratings, everything this account can do.

Sending you back to {redirectUri} diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index 238c127..b8fa27c 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -9,7 +9,14 @@ import { useState, } from "react"; -import { formatDuration, type Song, scrobble, streamUrl } from "./api"; +import { + formatDuration, + getQueue, + type Song, + saveQueue, + scrobble, + streamUrl, +} from "./api"; type PlayerState = { queue: Song[]; @@ -19,6 +26,8 @@ type PlayerState = { position: number; duration: number; play: (queue: Song[], index: number) => void; + remove: (index: number) => void; + clear: () => void; toggle: () => void; next: () => void; previous: () => void; @@ -46,9 +55,40 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // The ended handler is registered once, so it reads the queue length through // a ref rather than closing over a stale value. const queueLength = useRef(0); + const queueRef = useRef([]); + const indexRef = useRef(0); + const positionRef = useRef(0); + const hydrated = useRef(false); + const resumePosition = useRef(0); const current = queue[index] ?? null; queueLength.current = queue.length; + queueRef.current = queue; + indexRef.current = index; + positionRef.current = position; + + useEffect(() => { + let cancelled = false; + void getQueue() + .then((saved) => { + if (cancelled || !saved) return; + setQueue(saved.songs); + const savedIndex = saved.current + ? saved.songs.findIndex((song) => song.id === saved.current) + : 0; + setIndex(Math.max(savedIndex, 0)); + resumePosition.current = Math.max(saved.position_ms, 0) / 1000; + }) + // A transient queue failure must not become an unhandled browser error; + // playback can still start a new queue and retry on its first mutation. + .catch(() => undefined) + .finally(() => { + if (!cancelled) hydrated.current = true; + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { if (!audio.current) audio.current = new Audio(); @@ -62,7 +102,17 @@ export function PlayerProvider({ children }: { children: ReactNode }) { Math.min(value + 1, Math.max(queueLength.current - 1, 0)), ); const onPlay = () => setPlaying(true); - const onPause = () => setPlaying(false); + const onPause = () => { + setPlaying(false); + if (!hydrated.current) return; + const songs = queueRef.current; + const selected = songs[indexRef.current] ?? null; + void saveQueue( + songs, + selected?.id ?? null, + Math.round(positionRef.current * 1000), + ).catch(() => undefined); + }; element.addEventListener("timeupdate", onTime); element.addEventListener("loadedmetadata", onDuration); element.addEventListener("ended", onEnd); @@ -89,6 +139,10 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const url = await streamUrl(current.id); if (cancelled) return; element.src = url; + if (resumePosition.current > 0) { + element.currentTime = resumePosition.current; + resumePosition.current = 0; + } await element.play(); void scrobble(current.id, false).catch(() => undefined); } catch { @@ -114,6 +168,18 @@ export function PlayerProvider({ children }: { children: ReactNode }) { setIndex(at); }, []); + useEffect(() => { + if (!hydrated.current) return; + const timeout = window.setTimeout(() => { + void saveQueue( + queue, + current?.id ?? null, + Math.round(positionRef.current * 1000), + ).catch(() => undefined); + }, 400); + return () => window.clearTimeout(timeout); + }, [queue, current]); + const toggle = useCallback(() => { const element = audio.current; if (!element || !current) return; @@ -130,6 +196,19 @@ export function PlayerProvider({ children }: { children: ReactNode }) { position, duration, play, + remove: (at: number) => { + setQueue((songs) => songs.filter((_, position) => position !== at)); + setIndex((value) => + value > at + ? value - 1 + : Math.min(value, Math.max(queue.length - 2, 0)), + ); + }, + clear: () => { + audio.current?.pause(); + setQueue([]); + setIndex(0); + }, toggle, next: () => setIndex((value) => Math.min(value + 1, Math.max(queue.length - 1, 0))), @@ -166,17 +245,17 @@ export function PlayerBar() { onClick={player.previous} aria-label="Previous track" > - ⏮ + Previous

diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 1271ee7..91afb75 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -61,6 +61,24 @@ button:hover { border-color: var(--accent); } +button:active { + transform: translateY(1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button.danger { + color: #ff8585; +} + button.link, button.star { background: none; @@ -113,6 +131,9 @@ nav { top: 0; background: var(--bg); z-index: 2; + min-height: 3.7rem; + overflow-x: auto; + white-space: nowrap; } .brand { @@ -220,6 +241,13 @@ main { .songs { width: 100%; border-collapse: collapse; + display: block; + overflow-x: auto; +} + +.songs tbody { + display: table; + width: 100%; } .songs td { @@ -315,3 +343,189 @@ main { gap: 0.7rem; margin-top: 1.2rem; } + +.page-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.2rem; +} + +.page-header h2, +.page-header p, +.collection h3, +.admin-panel h3 { + margin: 0; +} + +.inline-form, +.actions, +.credential-form { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.inline-form { + margin-bottom: 1.2rem; +} + +.inline-form input { + flex: 1 1 16rem; +} + +.stack { + display: grid; + gap: 1rem; +} + +.collection, +.admin-panel, +.user-row, +.empty-state { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 1rem; +} + +.collection-header, +.user-row > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.8rem; +} + +.collection-header > div:first-child, +.user-row header > div { + display: grid; +} + +.section-actions { + margin-bottom: 1rem; +} + +.queue-list, +.resource-list { + list-style: none; + padding: 0; + margin: 0; +} + +.queue-list li, +.resource-list li { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(9rem, 0.7fr) auto auto; + align-items: center; + gap: 0.8rem; + padding: 0.65rem 0; + border-bottom: 1px solid var(--line); +} + +.queue-list li.active { + color: var(--accent); +} + +.queue-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resource-list li { + grid-template-columns: minmax(12rem, 1fr) auto auto; +} + +.resource-list li > div { + display: grid; + min-width: 0; +} + +.resource-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + color: var(--muted); + min-height: 9rem; + display: grid; + place-items: center; + text-align: center; +} + +.admin-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.stacked-form { + display: grid; + gap: 0.6rem; + margin: 0.9rem 0; +} + +.compact li { + grid-template-columns: minmax(10rem, 1fr) auto; +} + +.credential-form input { + flex: 1 1 16rem; +} + +.secret-output { + flex-basis: 100%; + color: var(--accent); + word-break: break-all; +} + +.notice { + color: var(--accent); +} + +@media (max-width: 760px) { + nav { + padding-inline: 1rem; + } + + .brand { + margin-right: 0.4rem; + } + + main { + padding: 1rem; + } + + .page-header, + .collection-header, + .user-row > header { + align-items: flex-start; + flex-direction: column; + } + + .admin-grid { + grid-template-columns: 1fr; + } + + .queue-list li, + .resource-list li { + grid-template-columns: minmax(0, 1fr) auto; + } + + .queue-list li .muted:first-of-type, + .resource-list li > div { + grid-column: 1 / -1; + } + + .credential-form { + align-items: stretch; + flex-direction: column; + } +} From e665192f38227f64a327b1cabf639002a9d00757 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 13:27:03 +0200 Subject: [PATCH 05/17] test(security): randomize integration credentials Signed-off-by: InstaZDLL --- tests/v2_foundations.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index dc0a676..b7214e3 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -188,7 +188,8 @@ async fn login_refresh_rotation_and_logout_work() { #[tokio::test] async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { let (_temp, config, state) = test_app().await; - let password_hash = security::hash_password("correct horse battery staple").unwrap(); + let password = Uuid::new_v4().to_string(); + let password_hash = security::hash_password(&password).unwrap(); state .db .create_account("web-listener", &password_hash, AccountRole::User, now_ms()) @@ -200,7 +201,7 @@ async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { "/api/v2/web/auth/login", serde_json::json!({ "username": "web-listener", - "password": "correct horse battery staple", + "password": &password, "device_name": "Embedded web player" }), ); @@ -272,7 +273,7 @@ async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { "/api/v2/web/auth/login", serde_json::json!({ "username": "web-listener", - "password": "correct horse battery staple", + "password": &password, "device_name": "Foreign page" }), ); @@ -291,6 +292,8 @@ async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { #[tokio::test] async fn setup_and_native_administration_cover_users_credentials_and_libraries() { let (_temp, config, state) = test_app().await; + let admin_password = Uuid::new_v4().to_string(); + let listener_password = Uuid::new_v4().to_string(); let music = config.data_dir.join("admin-library"); std::fs::create_dir_all(&music).unwrap(); let router = waveflow_server::app(&config, state.clone()); @@ -306,7 +309,7 @@ async fn setup_and_native_administration_cover_users_credentials_and_libraries() "/api/v2/setup", serde_json::json!({ "username": "first-admin", - "password": "correct horse battery staple" + "password": &admin_password }), ); request @@ -324,7 +327,7 @@ async fn setup_and_native_administration_cover_users_credentials_and_libraries() "/api/v2/setup", serde_json::json!({ "username": "second-admin", - "password": "correct horse battery staple" + "password": &admin_password }), ); repeated @@ -338,14 +341,14 @@ async fn setup_and_native_administration_cover_users_credentials_and_libraries() StatusCode::UNPROCESSABLE_ENTITY ); - let admin_token = login_token(&router, "first-admin", "correct horse battery staple").await; + let admin_token = login_token(&router, "first-admin", &admin_password).await; let create_user = Request::post("/api/v2/admin/users") .header("authorization", format!("Bearer {admin_token}")) .header("content-type", "application/json") .body(Body::from( serde_json::json!({ "username": "native-listener", - "web_password": "another correct horse password", + "web_password": &listener_password, "role": "user" }) .to_string(), @@ -407,8 +410,7 @@ async fn setup_and_native_administration_cover_users_credentials_and_libraries() StatusCode::NO_CONTENT ); - let listener_token = - login_token(&router, "native-listener", "another correct horse password").await; + let listener_token = login_token(&router, "native-listener", &listener_password).await; let libraries = router .clone() .oneshot( @@ -3117,8 +3119,8 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { #[tokio::test] async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { let (_temp, config, state) = test_app().await; - let password = "sync integration password"; - let hash = security::hash_password(password).unwrap(); + let password = Uuid::new_v4().to_string(); + let hash = security::hash_password(&password).unwrap(); let owner = state .db .create_account("sync-owner", &hash, AccountRole::Admin, now_ms()) @@ -3173,6 +3175,7 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { let router = waveflow_server::app(&config, state.clone()); let login = |username: &'static str| { let router = router.clone(); + let password = password.clone(); async move { let response = router .oneshot(json_request( From b8ada923b835428033500e362aebb2b6e8b11e7e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 14:30:36 +0200 Subject: [PATCH 06/17] fix(review): address m4 completion findings Signed-off-by: InstaZDLL --- Cargo.lock | 1 + Cargo.toml | 3 +- README.md | 2 + docs/M4-handoff.md | 13 +- src/authentication.rs | 9 ++ src/database.rs | 17 +++ src/http.rs | 244 +++++++++++++++++++--------------- src/lib.rs | 5 +- src/main.rs | 5 + src/media.rs | 9 ++ src/services.rs | 247 ++++++++++++++++++++++++++++------- src/sync.rs | 44 ++++++- tests/v2_foundations.rs | 281 +++++++++++++++++++++++++++++++++++----- webapp/src/api.ts | 15 ++- webapp/src/pages.tsx | 139 ++++++++++++-------- webapp/src/player.tsx | 137 ++++++++++++++++---- webapp/src/styles.css | 8 +- 17 files changed, 900 insertions(+), 279 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ac11fc..bc11d9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3510,6 +3510,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "tower", "tower-http 0.7.0", diff --git a/Cargo.toml b/Cargo.toml index 551a9b6..d0d8292 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ sha2 = "0.10" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "uuid"] } subtle = "2" thiserror = "2" -tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["io"] } tower = { version = "0.5", features = ["util"] } @@ -60,6 +60,7 @@ waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "d4c44eb5 [dev-dependencies] http-body-util = "0.1" tempfile = "3" +tokio-tungstenite = "0.29" [[test]] name = "v2_foundations" diff --git a/README.md b/README.md index 053e0ad..578678a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ The server listens on `127.0.0.1:4533` by default and exposes: synchronization defined by `docs/rfcs/RFC-003-waveflow-sync-v2.md`; - `/api/v2/admin/users`, `/libraries`, `/transcode/status`: native server administration and dedicated Subsonic credential rotation; +- `PUT|DELETE /api/v2/admin/users/{username}/subsonic-credential`: rotate or + revoke the dedicated Subsonic password and API key; - `POST /api/v2/libraries/{id}/scans`: manual scan trigger; - `GET /api/v2/scans/{id}` and `/events`: status and SSE progress; - `GET /api/v2/libraries/{id}/tracks?q=...&offset=...&limit=...`: tenant-scoped catalogue/FTS browsing, paged up to 500 tracks per request. diff --git a/docs/M4-handoff.md b/docs/M4-handoff.md index 37912ad..4acaf6d 100644 --- a/docs/M4-handoff.md +++ b/docs/M4-handoff.md @@ -44,12 +44,15 @@ GET /api/v2/playlists · POST · PATCH · DELETE GET /api/v2/favorites · PUT|DELETE /favorites/{kind}/{id} PUT /api/v2/ratings/{kind}/{id} · POST /scrobbles · GET /now-playing GET|PUT /api/v2/queue -GET|POST|PATCH|DELETE /api/v2/shares -GET|POST /api/v2/libraries · PUT|DELETE /libraries/{id}/members -GET|POST|PATCH /api/v2/users · PUT|DELETE /users/{name}/subsonic-credential -GET /api/v2/sync/snapshot · /changes · WS /sync/socket · POST /sync/ack +GET|POST /api/v2/shares · PATCH|DELETE /api/v2/shares/{share_id} +GET|POST /api/v2/libraries +PUT|DELETE /api/v2/libraries/{library_id}/members/{user_id} +GET|POST /api/v2/admin/users · PATCH|DELETE /api/v2/admin/users/{username} +PUT|DELETE /api/v2/admin/users/{username}/subsonic-credential +GET /api/v2/sync/snapshot · /changes · WS /sync/socket · PUT /sync/ack POST /api/v2/oauth/authorize · POST /api/v2/oauth/token -POST /api/v2/tracks/{id}/stream-ticket · GET /api/v2/stream/{ticket} +GET /api/v2/tracks/{track_id}/stream +POST /api/v2/tracks/{track_id}/stream-ticket · GET /api/v2/stream/{ticket} ``` **Client web embarqué** (`webapp/`, Vite + React + TanStack Router), compilé diff --git a/src/authentication.rs b/src/authentication.rs index cdb17af..e925ef8 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -208,6 +208,15 @@ impl AuthService { Ok(()) } + pub async fn revoke_refresh(&self, refresh_token: &str) -> Result<(), AuthError> { + let hash = security::token_hash(refresh_token); + self.db + .revoke_session_by_refresh_hash(&hash, now_ms()) + .await + .map_err(db_unavailable)?; + Ok(()) + } + pub async fn authenticate(&self, access_token: &str) -> Result { let hash = security::token_hash(access_token); let now = now_ms(); diff --git a/src/database.rs b/src/database.rs index 7c970a2..06e03cd 100644 --- a/src/database.rs +++ b/src/database.rs @@ -669,6 +669,23 @@ impl Database { Ok(result.rows_affected() == 1) } + pub async fn revoke_session_by_refresh_hash( + &self, + refresh_hash: &[u8], + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let result = sqlx::query( + "UPDATE session SET revoked_at = ? \ + WHERE refresh_token_hash = ? AND revoked_at IS NULL", + ) + .bind(now_ms) + .bind(refresh_hash) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + pub async fn create_api_token( &self, user_id: Uuid, diff --git a/src/http.rs b/src/http.rs index 2b72e1d..a9b7d5f 100644 --- a/src/http.rs +++ b/src/http.rs @@ -24,7 +24,9 @@ use crate::{authentication::AuthError, AppState}; const WEB_REFRESH_COOKIE: &str = "waveflow-refresh"; const WEB_CSRF_COOKIE: &str = "waveflow-csrf"; -const WEB_CSRF_HEADER: &str = "x-waveflow-csrf"; +pub const WEB_CSRF_HEADER: &str = "x-waveflow-csrf"; +pub const OPERATION_ID_HEADER: &str = "x-waveflow-operation-id"; +pub const DEVICE_ID_HEADER: &str = "x-waveflow-device-id"; #[derive(Debug, Serialize, ToSchema)] pub struct ProbeResponse { @@ -430,7 +432,7 @@ pub async fn setup_status( Ok(Json(SetupStatusResponse { required })) } -#[utoipa::path(post, path = "/api/v2/setup", tag = "authentication", request_body = SetupRequest, responses((status = 201, body = SetupResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +#[utoipa::path(post, path = "/api/v2/setup", tag = "authentication", params(("Origin" = String, Header, description = "Required browser origin")), request_body = SetupRequest, responses((status = 201, body = SetupResponse), (status = 403, description = "Origin header missing or rejected", body = ErrorResponse), (status = 422, body = ErrorResponse)))] pub async fn setup( State(state): State, headers: HeaderMap, @@ -582,15 +584,23 @@ pub async fn web_logout( headers: HeaderMap, ) -> Result { validate_web_request(&state, &headers)?; - let access_token = bearer_token(&headers).ok_or(ApiError::Unauthorized)?; - state - .auth - .logout(access_token) - .await - .map_err(ApiError::from)?; - let mut response = StatusCode::NO_CONTENT.into_response(); - append_cookie(&mut response, expired_cookie(WEB_REFRESH_COOKIE, true))?; - append_cookie(&mut response, expired_cookie(WEB_CSRF_COOKIE, false))?; + let result = match cookie_value(&headers, WEB_REFRESH_COOKIE) { + Some(refresh_token) => state.auth.revoke_refresh(refresh_token).await, + None => Err(AuthError::InvalidRefreshToken), + }; + let mut response = match result { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => ApiError::from(error).into_response(), + }; + let secure = secure_cookies(&state); + append_cookie( + &mut response, + expired_cookie(WEB_REFRESH_COOKIE, true, secure), + )?; + append_cookie( + &mut response, + expired_cookie(WEB_CSRF_COOKIE, false, secure), + )?; Ok(response) } @@ -641,11 +651,15 @@ pub async fn create_library( let actor = authenticated(&state, &headers).await?; require_admin(&actor)?; let path = std::path::PathBuf::from(&request.path); - let metadata = std::fs::symlink_metadata(&path).map_err(|_| ApiError::Validation)?; + let metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(|_| ApiError::Validation)?; if metadata.file_type().is_symlink() || !metadata.is_dir() || request.name.trim().is_empty() { return Err(ApiError::Validation); } - let canonical = std::fs::canonicalize(&path).map_err(|_| ApiError::Validation)?; + let canonical = tokio::fs::canonicalize(&path) + .await + .map_err(|_| ApiError::Validation)?; let library_id = state .db .create_library( @@ -1155,7 +1169,7 @@ pub async fn list_ratings( .map_err(service_error) } -#[utoipa::path(post, path = "/api/v2/scrobbles", tag = "user-data", request_body = ScrobbleRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(post, path = "/api/v2/scrobbles", tag = "user-data", request_body = ScrobbleRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] pub async fn create_scrobble( State(state): State, headers: HeaderMap, @@ -1203,17 +1217,18 @@ pub async fn transcode_status( ) -> Result, ApiError> { authenticated(&state, &headers).await?; Ok(Json(TranscodeStatusResponse { - available: true, + available: state.media.transcoding_available(), active: state.media.active_transcodes(), })) } -#[utoipa::path(get, path = "/api/v2/admin/users", tag = "administration", responses((status = 200, body = [crate::services::UserItem]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/admin/users", tag = "administration", responses((status = 200, body = [crate::services::UserItem]), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse)))] pub async fn list_users( State(state): State, headers: HeaderMap, ) -> Result>, ApiError> { let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; state .services .users(actor.id) @@ -1222,13 +1237,14 @@ pub async fn list_users( .map_err(service_error) } -#[utoipa::path(post, path = "/api/v2/admin/users", tag = "administration", request_body = CreateUserRequest, responses((status = 201, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +#[utoipa::path(post, path = "/api/v2/admin/users", tag = "administration", request_body = CreateUserRequest, responses((status = 201, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] pub async fn create_user( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result<(StatusCode, Json), ApiError> { let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; let user = state .services .create_web_user( @@ -1242,7 +1258,7 @@ pub async fn create_user( Ok((StatusCode::CREATED, Json(user))) } -#[utoipa::path(patch, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), request_body = UpdateUserRequest, responses((status = 200, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +#[utoipa::path(patch, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), request_body = UpdateUserRequest, responses((status = 200, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] pub async fn update_user( State(state): State, Path(username): Path, @@ -1250,6 +1266,7 @@ pub async fn update_user( Json(request): Json, ) -> Result, ApiError> { let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; state .services .update_user( @@ -1270,13 +1287,14 @@ pub async fn update_user( .map_err(service_error) } -#[utoipa::path(delete, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(delete, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn delete_user( State(state): State, Path(username): Path, headers: HeaderMap, ) -> Result { let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; state .services .delete_user(actor.id, &username) @@ -1285,7 +1303,7 @@ pub async fn delete_user( Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(put, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), request_body = SetSubsonicCredentialRequest, responses((status = 200, body = SubsonicCredentialResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +#[utoipa::path(put, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), request_body = SetSubsonicCredentialRequest, responses((status = 200, body = SubsonicCredentialResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] pub async fn set_subsonic_credential( State(state): State, Path(username): Path, @@ -1293,6 +1311,7 @@ pub async fn set_subsonic_credential( Json(request): Json, ) -> Result, ApiError> { let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; let api_key = state .services .set_subsonic_credential(actor.id, &username, &request.password) @@ -1301,13 +1320,14 @@ pub async fn set_subsonic_credential( Ok(Json(SubsonicCredentialResponse { api_key })) } -#[utoipa::path(delete, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(delete, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn revoke_subsonic_credential( State(state): State, Path(username): Path, headers: HeaderMap, ) -> Result { let actor = authenticated(&state, &headers).await?; + require_admin(&actor)?; state .services .revoke_subsonic_credential(actor.id, &username) @@ -1510,20 +1530,13 @@ pub async fn sync_snapshot( headers: HeaderMap, ) -> Result, ApiError> { let user = authenticated(&state, &headers).await?; - // No service mutation can commit while this gate is held, so every read in - // the bootstrap describes the same journal cursor. - let _writer = state.db.writer_guard().await; - let cursor = state.sync.latest_cursor(user.id).await.map_err(db_error)?; - let playlists = state + let snapshot = state .services - .playlists(user.id) + .sync_snapshot(user.id, crate::sync::MAX_SYNC_LIMIT) .await .map_err(service_error)?; - let favorites = state - .services - .starred_ids(user.id) - .await - .map_err(service_error)? + let favorites = snapshot + .favorites .into_iter() .map(|(entity_type, entity_id, starred_at)| StarredEntry { entity_type, @@ -1531,32 +1544,18 @@ pub async fn sync_snapshot( starred_at, }) .collect(); - let ratings = state - .services - .ratings(user.id) - .await - .map_err(service_error)?; - let queue = state.services.queue(user.id).await.map_err(service_error)?; - let history = state - .services - .history(user.id, crate::sync::MAX_SYNC_LIMIT) - .await - .map_err(service_error)?; - let shares = state - .services - .shares(user.id) - .await - .map_err(service_error)? + let shares = snapshot + .shares .into_iter() .map(|share| share_response(&state, share)) .collect(); Ok(Json(SyncSnapshot { - cursor, - playlists, + cursor: snapshot.cursor, + playlists: snapshot.playlists, favorites, - ratings, - queue, - history, + ratings: snapshot.ratings, + queue: snapshot.queue, + history: snapshot.history, shares, })) } @@ -1633,26 +1632,44 @@ async fn serve_sync_socket(socket: WebSocket, state: AppState, user_id: Uuid, af Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, Some(Ok(_)) => {} }, - notice = notices.recv() => match notice { - Ok((notice_user, notice)) if notice_user == user_id => { - if send_sync_notice(&mut sender, notice.cursor).await.is_err() { + notice = notices.recv() => match sync_notice_action(&state.sync, user_id, notice).await { + Ok(SyncNoticeAction::Send(cursor)) => { + if send_sync_notice(&mut sender, cursor).await.is_err() { break; } } - Ok(_) => {} - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - match state.sync.latest_cursor(user_id).await { - Ok(cursor) if send_sync_notice(&mut sender, cursor).await.is_err() => break, - Ok(_) => {} - Err(_) => break, - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + Ok(SyncNoticeAction::Continue) => {} + Ok(SyncNoticeAction::Close) | Err(_) => break, } } } } +#[derive(Debug, PartialEq, Eq)] +enum SyncNoticeAction { + Send(i64), + Continue, + Close, +} + +async fn sync_notice_action( + sync: &crate::sync::SyncService, + user_id: Uuid, + notice: Result<(Uuid, crate::sync::SyncNotice), tokio::sync::broadcast::error::RecvError>, +) -> Result { + match notice { + Ok((notice_user, notice)) if notice_user == user_id => { + Ok(SyncNoticeAction::Send(notice.cursor)) + } + Ok(_) => Ok(SyncNoticeAction::Continue), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => sync + .latest_cursor(user_id) + .await + .map(SyncNoticeAction::Send), + Err(tokio::sync::broadcast::error::RecvError::Closed) => Ok(SyncNoticeAction::Close), + } +} + async fn send_sync_notice( sender: &mut futures_util::stream::SplitSink, cursor: i64, @@ -1708,18 +1725,11 @@ impl IntoResponse for ApiError { fn web_auth_response( state: &AppState, - headers: &HeaderMap, + _headers: &HeaderMap, tokens: crate::authentication::AuthTokens, ) -> Result { let csrf_token = crate::security::generate_token("wfcsrf_"); - let secure = headers - .get(header::ORIGIN) - .and_then(|value| value.to_str().ok()) - .is_some_and(|origin| origin.starts_with("https://")) - || state - .public_url - .as_deref() - .is_some_and(|url| url.starts_with("https://")); + let secure = secure_cookies(state); let refresh_cookie = format!( "{WEB_REFRESH_COOKIE}={}; Path=/api/v2/web/auth; HttpOnly; SameSite=Strict; Max-Age={}{}", tokens.refresh_token, @@ -1750,14 +1760,22 @@ fn append_cookie(response: &mut Response, value: String) -> Result<(), ApiError> Ok(()) } -fn expired_cookie(name: &str, http_only: bool) -> String { +fn expired_cookie(name: &str, http_only: bool, secure: bool) -> String { format!( - "{name}=; Path={}; SameSite=Strict; Max-Age=0{}", + "{name}=; Path={}; SameSite=Strict; Max-Age=0{}{}", if http_only { "/api/v2/web/auth" } else { "/" }, - if http_only { "; HttpOnly" } else { "" } + if http_only { "; HttpOnly" } else { "" }, + if secure { "; Secure" } else { "" } ) } +fn secure_cookies(state: &AppState) -> bool { + state + .public_url + .as_deref() + .is_some_and(|url| url.starts_with("https://")) +} + fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers .get_all(header::COOKIE) @@ -1786,9 +1804,6 @@ fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiE .get(header::ORIGIN) .and_then(|value| value.to_str().ok()) .ok_or(ApiError::Forbidden)?; - if state.public_url.as_deref() == Some(origin) { - return Ok(()); - } let parsed = url::Url::parse(origin).map_err(|_| ApiError::Forbidden)?; if !matches!(parsed.scheme(), "http" | "https") || parsed.path() != "/" @@ -1797,6 +1812,14 @@ fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiE { return Err(ApiError::Forbidden); } + if let Some(public_url) = state.public_url.as_deref() { + let expected = url::Url::parse(public_url).map_err(|_| ApiError::Unavailable)?; + return if parsed.origin() == expected.origin() { + Ok(()) + } else { + Err(ApiError::Forbidden) + }; + } let authority = &parsed[url::Position::BeforeHost..url::Position::AfterPort]; let host = headers .get(header::HOST) @@ -1839,27 +1862,9 @@ async fn mutation_context( headers: &HeaderMap, user_id: Uuid, ) -> Result { - let operation_id = headers - .get("x-waveflow-operation-id") - .map(|value| { - value - .to_str() - .ok() - .and_then(|value| Uuid::parse_str(value).ok()) - .ok_or(ApiError::Validation) - }) - .transpose()? - .unwrap_or_else(Uuid::new_v4); - let origin_device_id = headers - .get("x-waveflow-device-id") - .map(|value| { - value - .to_str() - .ok() - .and_then(|value| Uuid::parse_str(value).ok()) - .ok_or(ApiError::Validation) - }) - .transpose()?; + let operation_id = + optional_uuid_header(headers, OPERATION_ID_HEADER)?.unwrap_or_else(Uuid::new_v4); + let origin_device_id = optional_uuid_header(headers, DEVICE_ID_HEADER)?; if let Some(device_id) = origin_device_id { let owned = state .sync @@ -1876,6 +1881,19 @@ async fn mutation_context( }) } +fn optional_uuid_header(headers: &HeaderMap, name: &'static str) -> Result, ApiError> { + headers + .get(name) + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or(ApiError::Validation) + }) + .transpose() +} + fn db_error(error: sqlx::Error) -> ApiError { tracing::error!(error = %error, "catalog database operation failed"); ApiError::Unavailable @@ -1890,6 +1908,7 @@ fn service_error(error: crate::services::ServiceError) -> ApiError { match error { ServiceError::NotFound | ServiceError::Forbidden => ApiError::NotFound, ServiceError::Invalid | ServiceError::Conflict => ApiError::Validation, + ServiceError::Unavailable => ApiError::Unavailable, ServiceError::Database(error) => db_error(error), ServiceError::Security(error) => { tracing::error!(error = %error, "catalog security operation failed"); @@ -1897,3 +1916,26 @@ fn service_error(error: crate::services::ServiceError) -> ApiError { } } } + +#[cfg(test)] +mod tests { + use super::{sync_notice_action, SyncNoticeAction}; + + #[tokio::test] + async fn lagged_sync_socket_recovers_from_the_durable_cursor() { + let temp = tempfile::tempdir().unwrap(); + let config = crate::Config::for_data_dir(temp.path().join("data")); + let db = crate::database::Database::open(&config).await.unwrap(); + db.migrate().await.unwrap(); + let sync = crate::sync::SyncService::new(db); + let action = sync_notice_action( + &sync, + uuid::Uuid::new_v4(), + Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), + ) + .await + .unwrap(); + + assert_eq!(action, SyncNoticeAction::Send(0)); + } +} diff --git a/src/lib.rs b/src/lib.rs index e1deefc..9edf094 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -304,8 +304,9 @@ pub fn app(config: &Config, state: AppState) -> Router { axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, axum::http::header::RANGE, - axum::http::HeaderName::from_static("x-waveflow-operation-id"), - axum::http::HeaderName::from_static("x-waveflow-device-id"), + axum::http::HeaderName::from_static(http::WEB_CSRF_HEADER), + axum::http::HeaderName::from_static(http::OPERATION_ID_HEADER), + axum::http::HeaderName::from_static(http::DEVICE_ID_HEADER), ]) .expose_headers([ axum::http::header::ACCEPT_RANGES, diff --git a/src/main.rs b/src/main.rs index 66de3a0..4234baf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,11 @@ async fn main() -> anyhow::Result<()> { async fn serve(config: Config, state: waveflow_server::AppState) -> anyhow::Result<()> { let bind_addr = config.bind_addr; + if config.public_url.is_none() { + tracing::warn!( + "WAVEFLOW_PUBLIC_URL is not configured; browser origin validation falls back to the request Host header and cookies cannot be marked Secure" + ); + } state.scanner.spawn_background(config.scan_interval); state.db.spawn_authorization_pruning(); let router = waveflow_server::app(&config, state); diff --git a/src/media.rs b/src/media.rs index 8b7541b..19c0953 100644 --- a/src/media.rs +++ b/src/media.rs @@ -59,6 +59,7 @@ struct MediaInner { cache_locks: DashMap>>, cache_access: DashMap, active_transcodes: AtomicUsize, + transcoding_available: bool, } #[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] @@ -113,6 +114,7 @@ impl MediaService { cache_locks: DashMap::new(), cache_access: DashMap::new(), active_transcodes: AtomicUsize::new(0), + transcoding_available: true, }), }) } @@ -125,6 +127,12 @@ impl MediaService { self.inner.active_transcodes.load(Ordering::Relaxed) } + /// The service is only constructed after both FFmpeg tools pass startup + /// detection, so an initialized instance is the capability signal. + pub fn transcoding_available(&self) -> bool { + self.inner.transcoding_available + } + pub async fn serve( &self, user_id: Uuid, @@ -934,6 +942,7 @@ mod tests { cache_locks: DashMap::new(), cache_access: access, active_transcodes: std::sync::atomic::AtomicUsize::new(0), + transcoding_available: true, }; prune_cache(&inner).await; assert!(!old.exists()); diff --git a/src/services.rs b/src/services.rs index 6d3b764..0bccbc4 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,9 +1,9 @@ //! Shared v2 domain services and tenant-filtered read models. -use std::{path::PathBuf, str::FromStr, sync::Arc}; +use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; use serde::Serialize; -use sqlx::Row; +use sqlx::{Row, SqliteConnection}; use utoipa::ToSchema; use uuid::Uuid; @@ -11,7 +11,7 @@ use crate::{ authentication::now_ms, database::{AccountRecord, AccountRole, Database}, security::{self, EncryptedSecret, SecretBox}, - sync::{MutationContext, OperationClaim, SyncService}, + sync::{MutationContext, MutationReceipt, OperationClaim, SyncService}, }; /// Tenant-filtered projections shared by the Subsonic facade and the native @@ -264,6 +264,16 @@ pub struct UserUpdate<'a> { pub web_password: Option<&'a str>, } +pub struct SyncSnapshotData { + pub cursor: i64, + pub playlists: Vec, + pub favorites: Vec<(String, Uuid, i64)>, + pub ratings: Vec, + pub queue: Option, + pub history: Vec, + pub shares: Vec, +} + #[derive(Clone)] pub struct DomainServices { db: Database, @@ -281,6 +291,8 @@ pub enum ServiceError { Invalid, #[error("conflict")] Conflict, + #[error("service unavailable")] + Unavailable, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] @@ -308,7 +320,7 @@ impl DomainServices { let password = password.to_owned(); let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) .await - .map_err(|_| ServiceError::Invalid)??; + .map_err(|_| ServiceError::Unavailable)??; self.db .bootstrap_admin(username, &password_hash, now_ms()) .await? @@ -667,19 +679,67 @@ impl DomainServices { user_id: Uuid, ids: &[Uuid], ) -> Result, ServiceError> { - let mut songs = Vec::new(); - for id in ids { - if let Some(song) = fetch_songs(&self.db, user_id, None, Some(*id)) - .await? - .into_iter() - .next() - { - songs.push(song); - } else { - return Err(ServiceError::NotFound); - } + let mut connection = self.db.pool().acquire().await?; + self.songs_by_ids_on(&mut connection, user_id, ids).await + } + + pub async fn sync_snapshot( + &self, + user_id: Uuid, + history_limit: i64, + ) -> Result { + let mut tx = self.db.pool().begin().await?; + let cursor = + sqlx::query_scalar("SELECT COALESCE(MAX(cursor), 0) FROM sync_event WHERE user_id=?") + .bind(user_id.to_string()) + .fetch_one(&mut *tx) + .await?; + let playlists = self.playlists_on(&mut tx, user_id).await?; + let favorites = self.starred_ids_on(&mut tx, user_id).await?; + let ratings = self.ratings_on(&mut tx, user_id).await?; + let queue = self.queue_on(&mut tx, user_id).await?; + let history = self.history_on(&mut tx, user_id, history_limit).await?; + let shares = self.shares_on(&mut tx, user_id).await?; + tx.commit().await?; + Ok(SyncSnapshotData { + cursor, + playlists, + favorites, + ratings, + queue, + history, + shares, + }) + } + + async fn songs_by_ids_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ids: &[Uuid], + ) -> Result, ServiceError> { + if ids.is_empty() { + return Ok(Vec::new()); } - Ok(songs) + let ids_json = serde_json::to_string(ids).map_err(|_| ServiceError::Invalid)?; + let rows = sqlx::query(concat!( + song_select!(), + " AND t.id IN (SELECT value FROM json_each(?))" + )) + .bind(user_id.to_string()) + .bind(ids_json) + .fetch_all(&mut *connection) + .await?; + let available = rows + .into_iter() + .map(song_from_row) + .collect::, _>>()? + .into_iter() + .map(|song| (song.id, song)) + .collect::>(); + ids.iter() + .map(|id| available.get(id).cloned().ok_or(ServiceError::NotFound)) + .collect() } pub async fn artwork_for_user( @@ -705,12 +765,21 @@ impl DomainServices { } pub async fn playlists(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.playlists_on(&mut connection, user_id).await + } + + async fn playlists_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { let rows = sqlx::query( "SELECT id, name, comment, public, created_at, updated_at FROM playlist \ WHERE owner_user_id=? ORDER BY updated_at DESC, id", ) .bind(user_id.to_string()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await?; let mut result = Vec::with_capacity(rows.len()); for row in rows { @@ -722,7 +791,7 @@ impl DomainServices { public: row.try_get::("public")? != 0, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, - songs: self.playlist_songs(user_id, id).await?, + songs: self.playlist_songs_on(connection, user_id, id).await?, }); } Ok(result) @@ -736,8 +805,9 @@ impl DomainServices { .ok_or(ServiceError::NotFound) } - async fn playlist_songs( + async fn playlist_songs_on( &self, + connection: &mut SqliteConnection, user_id: Uuid, playlist_id: Uuid, ) -> Result, ServiceError> { @@ -747,12 +817,12 @@ impl DomainServices { ) .bind(playlist_id.to_string()) .bind(user_id.to_string()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(parse_uuid) .collect::, _>>()?; - self.songs_by_ids(user_id, &ids).await + self.songs_by_ids_on(connection, user_id, &ids).await } pub async fn create_playlist( @@ -785,7 +855,9 @@ impl DomainServices { self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; + drop(_writer); return self.playlist(user_id, id).await; } let id = Uuid::new_v4(); @@ -812,6 +884,7 @@ impl DomainServices { ) .await?; tx.commit().await?; + drop(_writer); self.sync.publish(user_id, receipt); self.playlist(user_id, id).await } @@ -870,10 +943,12 @@ impl DomainServices { ids.extend_from_slice(add); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; + drop(_writer); return self.playlist(user_id, id).await; } let changed_at = now_ms(); @@ -914,6 +989,7 @@ impl DomainServices { ) .await?; tx.commit().await?; + drop(_writer); self.sync.publish(user_id, receipt); self.playlist(user_id, id).await } @@ -931,10 +1007,11 @@ impl DomainServices { ) -> Result<(), ServiceError> { let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; return Ok(()); } let changed = sqlx::query("DELETE FROM playlist WHERE id=? AND owner_user_id=?") @@ -995,10 +1072,11 @@ impl DomainServices { .await?; let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "favorite")?; return Ok(()); } if starred { @@ -1077,14 +1155,32 @@ impl DomainServices { pub async fn starred_ids( &self, user_id: Uuid, + ) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.starred_ids_on(&mut connection, user_id).await + } + + async fn starred_ids_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, ) -> Result, ServiceError> { sqlx::query("SELECT entity_type, entity_id, starred_at FROM user_star WHERE user_id=? ORDER BY starred_at DESC") - .bind(user_id.to_string()).fetch_all(self.db.pool()).await? + .bind(user_id.to_string()).fetch_all(&mut *connection).await? .into_iter().map(|row| Ok((row.try_get("entity_type")?, parse_uuid(row.try_get("entity_id")?)?, row.try_get("starred_at")?))) .collect::, sqlx::Error>>().map_err(Into::into) } pub async fn ratings(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.ratings_on(&mut connection, user_id).await + } + + async fn ratings_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { sqlx::query( "SELECT r.entity_type, r.entity_id, r.rating, r.updated_at FROM user_rating r \ WHERE r.user_id=? AND ( \ @@ -1094,7 +1190,7 @@ impl DomainServices { ) ORDER BY r.updated_at DESC, r.entity_type, r.entity_id", ) .bind(user_id.to_string()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(|row| { @@ -1141,10 +1237,11 @@ impl DomainServices { .await?; let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "rating")?; return Ok(()); } if rating == 0 { @@ -1208,13 +1305,19 @@ impl DomainServices { context: MutationContext, ) -> Result<(), ServiceError> { self.authorize_entity(user_id, "track", track_id).await?; - let now = time.unwrap_or_else(now_ms); + let current_time = now_ms(); + let now = time.unwrap_or(current_time); + const MAX_FUTURE_SKEW_MS: i64 = 5 * 60 * 1_000; + if now < 0 || now > current_time.saturating_add(MAX_FUTURE_SKEW_MS) { + return Err(ServiceError::Invalid); + } let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "scrobble")?; return Ok(()); } sqlx::query( @@ -1287,6 +1390,16 @@ impl DomainServices { &self, user_id: Uuid, limit: i64, + ) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.history_on(&mut connection, user_id, limit).await + } + + async fn history_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + limit: i64, ) -> Result, ServiceError> { sqlx::query( "SELECT p.track_id, p.submission, p.played_at FROM play_event p \ @@ -1296,7 +1409,7 @@ impl DomainServices { .bind(user_id.to_string()) .bind(user_id.to_string()) .bind(limit) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(|row| { @@ -1348,10 +1461,11 @@ impl DomainServices { } let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "queue")?; return Ok(()); } sqlx::query("INSERT INTO play_queue (user_id, current_track_id, position_ms, changed_by, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET current_track_id=excluded.current_track_id, position_ms=excluded.position_ms, changed_by=excluded.changed_by, updated_at=excluded.updated_at") @@ -1394,8 +1508,17 @@ impl DomainServices { } pub async fn queue(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.queue_on(&mut connection, user_id).await + } + + async fn queue_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { let row = sqlx::query("SELECT current_track_id, position_ms, changed_by, updated_at FROM play_queue WHERE user_id=?") - .bind(user_id.to_string()).fetch_optional(self.db.pool()).await?; + .bind(user_id.to_string()).fetch_optional(&mut *connection).await?; let Some(row) = row else { return Ok(None); }; @@ -1403,7 +1526,7 @@ impl DomainServices { "SELECT track_id FROM play_queue_track WHERE user_id=? ORDER BY position", ) .bind(user_id.to_string()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(parse_uuid) @@ -1416,13 +1539,22 @@ impl DomainServices { position_ms: row.try_get("position_ms")?, changed_by: row.try_get("changed_by")?, updated_at: row.try_get("updated_at")?, - songs: self.songs_by_ids(user_id, &ids).await?, + songs: self.songs_by_ids_on(connection, user_id, &ids).await?, })) } pub async fn shares(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.shares_on(&mut connection, user_id).await + } + + async fn shares_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { let rows = sqlx::query("SELECT id, token_nonce, token_ciphertext, description, expires_at, created_at, visit_count FROM share WHERE owner_user_id=? ORDER BY created_at DESC") - .bind(user_id.to_string()).fetch_all(self.db.pool()).await?; + .bind(user_id.to_string()).fetch_all(&mut *connection).await?; let mut shares = Vec::new(); for row in rows { let id = parse_uuid(row.try_get("id")?)?; @@ -1434,7 +1566,7 @@ impl DomainServices { "SELECT track_id FROM share_track WHERE share_id=? ORDER BY position", ) .bind(id.to_string()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(parse_uuid) @@ -1447,7 +1579,9 @@ impl DomainServices { expires_at: row.try_get("expires_at")?, created_at: row.try_get("created_at")?, visit_count: row.try_get("visit_count")?, - songs: self.songs_by_ids(user_id, &track_ids).await?, + songs: self + .songs_by_ids_on(connection, user_id, &track_ids) + .await?, }); } Ok(shares) @@ -1493,7 +1627,9 @@ impl DomainServices { self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "share")?; let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; + drop(_writer); return self .shares(user_id) .await? @@ -1530,6 +1666,7 @@ impl DomainServices { ) .await?; tx.commit().await?; + drop(_writer); self.sync.publish(user_id, receipt); self.shares(user_id) .await? @@ -1605,10 +1742,12 @@ impl DomainServices { ) -> Result { let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "share")?; + drop(_writer); return self .shares(user_id) .await? @@ -1616,12 +1755,14 @@ impl DomainServices { .find(|share| share.id == id) .ok_or(ServiceError::NotFound); } - let changed = sqlx::query("UPDATE share SET description=COALESCE(?, description), expires_at=COALESCE(?, expires_at), updated_at=? WHERE id=? AND owner_user_id=?") - .bind(description).bind(expires_at).bind(now_ms()).bind(id.to_string()).bind(user_id.to_string()).execute(&mut *tx).await?.rows_affected(); - if changed == 0 { + let persisted = sqlx::query("UPDATE share SET description=COALESCE(?, description), expires_at=COALESCE(?, expires_at), updated_at=? WHERE id=? AND owner_user_id=? RETURNING description, expires_at") + .bind(description).bind(expires_at).bind(now_ms()).bind(id.to_string()).bind(user_id.to_string()).fetch_optional(&mut *tx).await?; + let Some(persisted) = persisted else { tx.rollback().await?; return Err(ServiceError::NotFound); - } + }; + let persisted_description: Option = persisted.try_get("description")?; + let persisted_expires_at: Option = persisted.try_get("expires_at")?; let receipt = self .sync .complete_operation( @@ -1633,13 +1774,14 @@ impl DomainServices { "upsert", &serde_json::json!({ "id": id, - "description": description, - "expires_at": expires_at, + "description": persisted_description, + "expires_at": persisted_expires_at, }), Some(id), ) .await?; tx.commit().await?; + drop(_writer); self.sync.publish(user_id, receipt); self.shares(user_id) .await? @@ -1661,10 +1803,11 @@ impl DomainServices { ) -> Result<(), ServiceError> { let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(_) = + if let OperationClaim::Replayed(receipt) = self.sync.claim_operation(&mut tx, user_id, context).await? { tx.rollback().await?; + validate_replay_type(&receipt, "share")?; return Ok(()); } let changed = sqlx::query("DELETE FROM share WHERE id=? AND owner_user_id=?") @@ -1730,7 +1873,7 @@ impl DomainServices { let password = password.to_owned(); let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) .await - .map_err(|_| ServiceError::Invalid)??; + .map_err(|_| ServiceError::Unavailable)??; let id = self .db .create_account(username.trim(), &password_hash, role, now_ms()) @@ -1806,7 +1949,7 @@ impl DomainServices { folder_ids: Option<&[Uuid]>, ) -> Result { self.require_admin(actor_id).await?; - validate_name(username)?; + validate_username(username)?; if password.is_empty() { return Err(ServiceError::Invalid); } @@ -1918,7 +2061,7 @@ impl DomainServices { Some( tokio::task::spawn_blocking(move || security::hash_password(&password)) .await - .map_err(|_| ServiceError::Invalid)??, + .map_err(|_| ServiceError::Unavailable)??, ) } else { None @@ -2222,6 +2365,14 @@ fn validate_name(name: &str) -> Result<(), ServiceError> { } } +fn validate_replay_type(receipt: &MutationReceipt, expected: &str) -> Result<(), ServiceError> { + if receipt.entity_type == expected { + Ok(()) + } else { + Err(ServiceError::Conflict) + } +} + fn validate_username(username: &str) -> Result<(), ServiceError> { let username = username.trim(); if !(3..=64).contains(&username.len()) diff --git a/src/sync.rs b/src/sync.rs index 9940434..07551ec 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -50,15 +50,16 @@ pub struct SyncPage { pub has_more: bool, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct MutationReceipt { pub operation_id: Uuid, pub result_entity_id: Option, + pub entity_type: String, pub cursor: i64, pub replayed: bool, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) enum OperationClaim { New, Replayed(MutationReceipt), @@ -91,6 +92,27 @@ impl SyncService { user_id: Uuid, context: MutationContext, ) -> Result { + if let Some(device_id) = context.origin_device_id { + let owned = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM device \ + WHERE id=? AND user_id=? AND revoked_at IS NULL)", + ) + .bind(device_id.to_string()) + .bind(user_id.to_string()) + .fetch_one(&mut *connection) + .await?; + if !owned { + tracing::warn!( + %user_id, + operation_id = %context.operation_id, + %device_id, + "sync mutation rejected for an invalid origin device" + ); + return Err(sqlx::Error::Protocol( + "sync origin device does not belong to the user".into(), + )); + } + } let inserted = sqlx::query( "INSERT INTO sync_operation \ (user_id, operation_id, origin_device_id, created_at) VALUES (?, ?, ?, ?) \ @@ -108,20 +130,29 @@ impl SyncService { } let row = sqlx::query( - "SELECT result_entity_id, event_cursor FROM sync_operation \ - WHERE user_id=? AND operation_id=? AND applied_at IS NOT NULL", + "SELECT so.result_entity_id, so.event_cursor, se.entity_type \ + FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor \ + WHERE so.user_id=? AND so.operation_id=? AND so.applied_at IS NOT NULL", ) .bind(user_id.to_string()) .bind(context.operation_id.to_string()) .fetch_optional(&mut *connection) - .await? - .ok_or_else(|| sqlx::Error::Protocol("sync operation is incomplete".into()))?; + .await?; + let Some(row) = row else { + tracing::error!( + %user_id, + operation_id = %context.operation_id, + "sync operation exists but has no completed event" + ); + return Err(sqlx::Error::Protocol("sync operation is incomplete".into())); + }; Ok(OperationClaim::Replayed(MutationReceipt { operation_id: context.operation_id, result_entity_id: row .try_get::, _>("result_entity_id")? .map(parse_uuid) .transpose()?, + entity_type: row.try_get("entity_type")?, cursor: row.try_get("event_cursor")?, replayed: true, })) @@ -172,6 +203,7 @@ impl SyncService { Ok(MutationReceipt { operation_id: context.operation_id, result_entity_id, + entity_type: entity_type.to_owned(), cursor, replayed: false, }) diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index b7214e3..16777ca 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -2,9 +2,11 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; +use futures_util::StreamExt; use http_body_util::BodyExt; use sqlx::Row; use tempfile::TempDir; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tower::ServiceExt; use uuid::Uuid; use waveflow_server::{ @@ -225,6 +227,7 @@ async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { .unwrap(); assert!(refresh_cookie.contains("HttpOnly")); assert!(refresh_cookie.contains("SameSite=Strict")); + assert!(refresh_cookie.contains("Path=/api/v2/web/auth")); let refresh_pair = refresh_cookie.split(';').next().unwrap().to_owned(); let csrf_pair = cookies .iter() @@ -250,6 +253,18 @@ async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { StatusCode::FORBIDDEN ); + let wrong_csrf = Request::post("/api/v2/web/auth/refresh") + .header("origin", "http://waveflow.test") + .header("host", "waveflow.test") + .header("cookie", format!("{refresh_pair}; {csrf_pair}")) + .header("x-waveflow-csrf", "wfcsrf_wrong") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(wrong_csrf).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let refresh = Request::post("/api/v2/web/auth/refresh") .header("origin", "http://waveflow.test") .header("host", "waveflow.test") @@ -259,14 +274,72 @@ async fn browser_session_uses_http_only_refresh_cookie_origin_and_csrf() { .unwrap(); let response = router.clone().oneshot(refresh).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); + let refreshed_cookies = response + .headers() + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect::>(); + let refreshed_refresh = refreshed_cookies + .iter() + .find(|cookie| cookie.starts_with("waveflow-refresh=")) + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let refreshed_csrf = refreshed_cookies + .iter() + .find(|cookie| cookie.starts_with("waveflow-csrf=")) + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let refreshed_csrf_value = refreshed_csrf.split_once('=').unwrap().1; + + let logout = Request::post("/api/v2/web/auth/logout") + .header("origin", "http://waveflow.test/") + .header("host", "ignored.invalid") + .header("cookie", format!("{refreshed_refresh}; {refreshed_csrf}")) + .header("x-waveflow-csrf", refreshed_csrf_value) + .body(Body::empty()) + .unwrap(); + let logout = router.clone().oneshot(logout).await.unwrap(); + assert_eq!(logout.status(), StatusCode::NO_CONTENT); + let expired = logout + .headers() + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(); + assert_eq!(expired.len(), 2); + assert!(expired.iter().all(|cookie| cookie.contains("Max-Age=0"))); + assert!(expired.iter().any(|cookie| { + cookie.starts_with("waveflow-refresh=") + && cookie.contains("Path=/api/v2/web/auth") + && cookie.contains("HttpOnly") + })); + + let logout_without_refresh = Request::post("/api/v2/web/auth/logout") + .header("origin", "http://waveflow.test") + .header("cookie", &refreshed_csrf) + .header("x-waveflow-csrf", refreshed_csrf_value) + .body(Body::empty()) + .unwrap(); + let logout_without_refresh = router + .clone() + .oneshot(logout_without_refresh) + .await + .unwrap(); + assert_eq!(logout_without_refresh.status(), StatusCode::UNAUTHORIZED); assert_eq!( - response + logout_without_refresh .headers() .get_all("set-cookie") .iter() - .filter(|value| value.to_str().unwrap().starts_with("waveflow-refresh=")) .count(), - 1 + 2 ); let mut foreign = json_request( @@ -305,6 +378,23 @@ async fn setup_and_native_administration_cover_users_credentials_and_libraries() .unwrap(); assert_eq!(json_body(status).await["required"], true); + let missing_origin = json_request( + "/api/v2/setup", + serde_json::json!({ + "username": "first-admin", + "password": &admin_password + }), + ); + assert_eq!( + router + .clone() + .oneshot(missing_origin) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + let mut request = json_request( "/api/v2/setup", serde_json::json!({ @@ -434,7 +524,7 @@ async fn setup_and_native_administration_cover_users_credentials_and_libraries() ) .await .unwrap(); - assert_eq!(forbidden.status(), StatusCode::NOT_FOUND); + assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); } #[tokio::test] @@ -1839,6 +1929,28 @@ async fn subsonic_xml_json_auth_catalog_and_user_data_are_compatible() { .collect::>(); assert!(default_folders.contains(&library.to_string())); assert!(default_folders.contains(&secondary_library.to_string())); + + let invalid_username = router + .clone() + .oneshot( + Request::get(format!( + "/rest/createUser.view?apiKey={api_key}&v=1.16.1&c=golden&f=json&username=%C3%A9lodie&password=invalid-user-secret&email=invalid@example.invalid" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid_username.status(), StatusCode::BAD_REQUEST); + let invalid_username = json_body(invalid_username).await; + assert_eq!(invalid_username["subsonic-response"]["status"], "failed"); + assert!(state + .db + .account_by_username("élodie") + .await + .unwrap() + .is_none()); + assert_eq!( subsonic_json(&router, "deleteUser", api_key, "&username=sub-default").await ["subsonic-response"]["status"], @@ -3039,6 +3151,21 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { .await; assert_eq!(unknown_kind.status(), StatusCode::UNPROCESSABLE_ENTITY); + for invalid_time in [-1, now_ms().saturating_add(10 * 60 * 1_000)] { + let invalid = send( + "POST", + "/api/v2/scrobbles".into(), + owner_token.clone(), + Some(serde_json::json!({ + "track_id": first, + "submission": true, + "played_at": invalid_time + })), + ) + .await; + assert_eq!(invalid.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + let scrobbled = send( "POST", "/api/v2/scrobbles".into(), @@ -3195,10 +3322,9 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { let owner_login = login("sync-owner").await; let owner_token = owner_login["access_token"].as_str().unwrap().to_owned(); let device_id = owner_login["device_id"].as_str().unwrap().to_owned(); - let intruder_token = login("sync-intruder").await["access_token"] - .as_str() - .unwrap() - .to_owned(); + let intruder_login = login("sync-intruder").await; + let intruder_token = intruder_login["access_token"].as_str().unwrap().to_owned(); + let intruder_device_id = intruder_login["device_id"].as_str().unwrap().to_owned(); let mutate = |method: &'static str, uri: String, operation_id: Uuid, body: Option| { @@ -3243,6 +3369,15 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { .unwrap(); assert_eq!(star_count, 1); + let mismatched_replay = mutate( + "POST", + "/api/v2/playlists".into(), + favorite_operation, + Some(serde_json::json!({ "name": "Wrong replay type", "track_ids": [track] })), + ) + .await; + assert_eq!(mismatched_replay.status(), StatusCode::UNPROCESSABLE_ENTITY); + let scrobble_operation = Uuid::new_v4(); for _ in 0..2 { let response = mutate( @@ -3294,31 +3429,84 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { } assert_eq!(share_ids[0], share_ids[1]); - let notice = tokio::time::timeout(std::time::Duration::from_secs(1), notices.recv()) + let mut notice_cursors = Vec::new(); + for _ in 0..4 { + let notice = tokio::time::timeout(std::time::Duration::from_secs(1), notices.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(notice.0, owner); + notice_cursors.push(notice.1.cursor); + } + assert!(notice_cursors.windows(2).all(|pair| pair[0] < pair[1])); + assert!(matches!( + notices.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + + let mut after = 0; + let mut paged_changes = Vec::new(); + loop { + let changes = router + .clone() + .oneshot( + Request::get(format!("/api/v2/sync/changes?after={after}&limit=1")) + .header("authorization", format!("Bearer {owner_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(changes.status(), StatusCode::OK); + let page = json_body(changes).await; + let page_changes = page["changes"].as_array().unwrap(); + if page_changes.is_empty() { + assert!(!page["has_more"].as_bool().unwrap()); + break; + } + let returned_cursor = page_changes[0]["cursor"].as_i64().unwrap(); + assert_eq!(page["next_cursor"], returned_cursor); + paged_changes.push(page_changes[0].clone()); + after = returned_cursor; + if !page["has_more"].as_bool().unwrap() { + break; + } + } + assert_eq!(paged_changes.len(), 4); + assert_eq!( + paged_changes[0]["operation_id"], + favorite_operation.to_string() + ); + + // The real WebSocket route sends the durable cursor immediately when a + // reconnecting client is behind. The lagged-receiver branch is covered by + // the focused `http` unit test using the same serve-path helper. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server_router = router.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, server_router).await.unwrap(); + }); + let mut socket_request = format!("ws://{address}/api/v2/sync/socket?after=0") + .into_client_request() + .unwrap(); + socket_request.headers_mut().insert( + "authorization", + format!("Bearer {owner_token}").parse().unwrap(), + ); + let (mut socket, response) = tokio_tungstenite::connect_async(socket_request) .await - .unwrap() .unwrap(); - assert_eq!(notice.0, owner); - assert!(notice.1.cursor > 0); - - let changes = router - .clone() - .oneshot( - Request::get("/api/v2/sync/changes?after=0&limit=1") - .header("authorization", format!("Bearer {owner_token}")) - .body(Body::empty()) - .unwrap(), - ) + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + let notice = tokio::time::timeout(std::time::Duration::from_secs(1), socket.next()) .await + .unwrap() + .unwrap() .unwrap(); - assert_eq!(changes.status(), StatusCode::OK); - let changes = json_body(changes).await; - assert_eq!(changes["changes"].as_array().unwrap().len(), 1); - assert!(changes["has_more"].as_bool().unwrap()); - assert_eq!( - changes["changes"][0]["operation_id"], - favorite_operation.to_string() - ); + let notice: serde_json::Value = serde_json::from_str(notice.to_text().unwrap()).unwrap(); + assert_eq!(notice["cursor"], paged_changes[3]["cursor"]); + socket.close(None).await.unwrap(); + server.abort(); let snapshot = router .clone() @@ -3352,6 +3540,41 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { .unwrap(); assert_eq!(ack.status(), StatusCode::NO_CONTENT); + let future_ack = router + .clone() + .oneshot( + Request::put("/api/v2/sync/ack") + .header("authorization", format!("Bearer {owner_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "device_id": device_id, + "cursor": cursor + 1_000 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(future_ack.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let foreign_device = Request::put(format!("/api/v2/favorites/track/{track}")) + .header("authorization", format!("Bearer {owner_token}")) + .header("x-waveflow-operation-id", Uuid::new_v4().to_string()) + .header("x-waveflow-device-id", intruder_device_id) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router + .clone() + .oneshot(foreign_device) + .await + .unwrap() + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + let foreign = router .oneshot( Request::get("/api/v2/sync/changes?after=0") diff --git a/webapp/src/api.ts b/webapp/src/api.ts index 9ddba6e..92052bd 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -165,20 +165,31 @@ function refresh(): Promise { } async function performRefresh(): Promise { + const hadSession = session !== null; const csrf = cookieValue("waveflow-csrf"); - if (!csrf) return false; + if (!csrf) { + handleRefreshFailure(hadSession); + return false; + } const response = await fetch("/api/v2/web/auth/refresh", { method: "POST", headers: { "x-waveflow-csrf": csrf }, }); if (!response.ok) { - session = null; + handleRefreshFailure(hadSession); return false; } session = await parse(response); return true; } +function handleRefreshFailure(hadSession: boolean): void { + session = null; + if (hadSession && window.location.pathname !== "/login") { + window.location.assign("/login"); + } +} + export async function ensureSession(): Promise { return hasSession() || refresh(); } diff --git a/webapp/src/pages.tsx b/webapp/src/pages.tsx index 3b3ab7b..a83991d 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate } from "@tanstack/react-router"; -import { type FormEvent, useEffect, useState } from "react"; +import { type FormEvent, useEffect, useMemo, useState } from "react"; import { type Album, @@ -88,15 +88,21 @@ export function LoginPage() { setBusy(true); setError(null); try { + if (setup) { + try { + await bootstrapAdmin(username, password); + setSetup(false); + } catch { + setError( + "Setup failed. Use a valid username and at least 12 password characters.", + ); + return; + } + } try { - if (setup) await bootstrapAdmin(username, password); await login(username, password); } catch { - setError( - setup - ? "Setup failed. Use a valid username and at least 12 password characters." - : "Wrong username or password.", - ); + setError("Wrong username or password."); return; } const next = safeInternalPath( @@ -135,6 +141,7 @@ export function LoginPage() { value={password} onChange={(event) => setPassword(event.target.value)} autoComplete={setup ? "new-password" : "current-password"} + minLength={setup ? 12 : undefined} required /> @@ -195,41 +202,43 @@ export function SongTable({ songs }: { songs: Song[] }) { } return ( - - - {songs.map((song, position) => { - const starred = stars[song.id] ?? song.starred_at !== null; - const active = player.current?.id === song.id; - return ( - - - - - - - - ); - })} - -
{song.track ?? position + 1} - - {song.artist}{formatDuration(song.duration_ms)} - -
+
+ + + {songs.map((song, position) => { + const starred = stars[song.id] ?? song.starred_at !== null; + const active = player.current?.id === song.id; + return ( + + + + + + + + ); + })} + +
{song.track ?? position + 1} + + {song.artist}{formatDuration(song.duration_ms)} + +
+
); } @@ -237,7 +246,12 @@ export function FavoritesPage() { const { value, error } = useAsync(async () => { const favorites = await listFavorites(); const tracks = favorites.filter((item) => item.entity_type === "track"); - return Promise.all(tracks.map((item) => getTrack(item.entity_id))); + const resolved = await Promise.allSettled( + tracks.map((item) => getTrack(item.entity_id)), + ); + return resolved.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); }, []); if (!value) return ; return ( @@ -273,6 +287,7 @@ export function PlaylistsPage() { async function addQueue(playlist: Playlist) { if (!player.queue.length) return; + setMutationError(null); try { await appendToPlaylist( playlist.id, @@ -359,6 +374,14 @@ export function PlaylistsPage() { export function QueuePage() { const player = usePlayer(); + const queueKeys = useMemo(() => { + const occurrences = new Map(); + return player.queue.map((song) => { + const occurrence = occurrences.get(song.id) ?? 0; + occurrences.set(song.id, occurrence + 1); + return `${song.id}-${occurrence}`; + }); + }, [player.queue]); return (
{player.queue.map((song, position) => (
  • +
      {libraries.map((library) => ( @@ -657,7 +694,9 @@ export function AdminPage() { autoComplete="new-password" required /> - +
  • @@ -709,14 +748,6 @@ function EmptyState({ message }: { message: string }) { ); } -function queueOccurrenceKey(songs: Song[], position: number): string { - const id = songs[position]?.id ?? "missing"; - const occurrence = songs - .slice(0, position) - .filter((song) => song.id === id).length; - return `${id}-${occurrence}`; -} - export function AlbumPage({ albumId }: { albumId: string }) { const { value, error } = useAsync( () => getAlbum(albumId), diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index b8fa27c..8dcc7cb 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -23,8 +23,6 @@ type PlayerState = { index: number; current: Song | null; playing: boolean; - position: number; - duration: number; play: (queue: Song[], index: number) => void; remove: (index: number) => void; clear: () => void; @@ -34,7 +32,13 @@ type PlayerState = { seek: (seconds: number) => void; }; +type PlayerProgress = { + position: number; + duration: number; +}; + const PlayerContext = createContext(null); +const PlayerProgressContext = createContext(null); export function usePlayer(): PlayerState { const player = useContext(PlayerContext); @@ -42,6 +46,12 @@ export function usePlayer(): PlayerState { return player; } +function usePlayerProgress(): PlayerProgress { + const progress = useContext(PlayerProgressContext); + if (!progress) throw new Error("usePlayerProgress requires PlayerProvider"); + return progress; +} + export function PlayerProvider({ children }: { children: ReactNode }) { const audio = useRef(null); const [queue, setQueue] = useState([]); @@ -60,6 +70,9 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const positionRef = useRef(0); const hydrated = useRef(false); const resumePosition = useRef(0); + const resumeTrack = useRef(null); + const autoplay = useRef(false); + const saveChain = useRef>(Promise.resolve()); const current = queue[index] ?? null; queueLength.current = queue.length; @@ -67,6 +80,16 @@ export function PlayerProvider({ children }: { children: ReactNode }) { indexRef.current = index; positionRef.current = position; + const persistQueue = useCallback( + (songs: Song[], selected: string | null, positionMs: number) => { + const snapshot = [...songs]; + saveChain.current = saveChain.current + .then(() => saveQueue(snapshot, selected, positionMs)) + .catch(() => undefined); + }, + [], + ); + useEffect(() => { let cancelled = false; void getQueue() @@ -78,6 +101,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { : 0; setIndex(Math.max(savedIndex, 0)); resumePosition.current = Math.max(saved.position_ms, 0) / 1000; + resumeTrack.current = saved.current; }) // A transient queue failure must not become an unhandled browser error; // playback can still start a new queue and retry on its first mutation. @@ -97,21 +121,24 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const onDuration = () => setDuration(element.duration || 0); // Stop on the last track rather than stepping past it: an out-of-range // index empties `current` and the player bar vanishes mid-listen. - const onEnd = () => - setIndex((value) => - Math.min(value + 1, Math.max(queueLength.current - 1, 0)), - ); + const onEnd = () => { + setIndex((value) => { + const next = Math.min(value + 1, Math.max(queueLength.current - 1, 0)); + autoplay.current = next !== value; + return next; + }); + }; const onPlay = () => setPlaying(true); const onPause = () => { setPlaying(false); if (!hydrated.current) return; const songs = queueRef.current; const selected = songs[indexRef.current] ?? null; - void saveQueue( + persistQueue( songs, selected?.id ?? null, Math.round(positionRef.current * 1000), - ).catch(() => undefined); + ); }; element.addEventListener("timeupdate", onTime); element.addEventListener("loadedmetadata", onDuration); @@ -125,7 +152,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { element.removeEventListener("play", onPlay); element.removeEventListener("pause", onPause); }; - }, []); + }, [persistQueue]); // Loading a track needs a round-trip for its ticket, so guard against a // stale response overwriting a newer selection. @@ -134,15 +161,19 @@ export function PlayerProvider({ children }: { children: ReactNode }) { if (!element || !current) return; let cancelled = false; submitted.current = null; + const shouldAutoplay = autoplay.current; + autoplay.current = false; void (async () => { try { const url = await streamUrl(current.id); if (cancelled) return; element.src = url; - if (resumePosition.current > 0) { + if (resumeTrack.current === current.id && resumePosition.current > 0) { element.currentTime = resumePosition.current; resumePosition.current = 0; + resumeTrack.current = null; } + if (!shouldAutoplay) return; await element.play(); void scrobble(current.id, false).catch(() => undefined); } catch { @@ -164,6 +195,19 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, [position, duration, current]); const play = useCallback((next: Song[], at: number) => { + const sameSelection = next === queueRef.current && at === indexRef.current; + if (sameSelection) { + const element = audio.current; + const selected = next[at]; + if (element && selected) { + void element + .play() + .then(() => scrobble(selected.id, false)) + .catch(() => undefined); + } + return; + } + autoplay.current = true; setQueue(next); setIndex(at); }, []); @@ -171,30 +215,48 @@ export function PlayerProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!hydrated.current) return; const timeout = window.setTimeout(() => { - void saveQueue( + persistQueue( queue, current?.id ?? null, Math.round(positionRef.current * 1000), - ).catch(() => undefined); + ); }, 400); return () => window.clearTimeout(timeout); - }, [queue, current]); + }, [queue, current, persistQueue]); const toggle = useCallback(() => { const element = audio.current; if (!element || !current) return; - if (element.paused) void element.play(); - else element.pause(); + if (element.paused) { + void element + .play() + .then(() => scrobble(current.id, false)) + .catch(() => undefined); + } else element.pause(); }, [current]); + const next = useCallback(() => { + setIndex((value) => { + const next = Math.min(value + 1, Math.max(queueLength.current - 1, 0)); + autoplay.current = next !== value; + return next; + }); + }, []); + + const previous = useCallback(() => { + setIndex((value) => { + const previous = Math.max(value - 1, 0); + autoplay.current = previous !== value; + return previous; + }); + }, []); + const value = useMemo( () => ({ queue, index, current, playing, - position, - duration, play, remove: (at: number) => { setQueue((songs) => songs.filter((_, position) => position !== at)); @@ -206,27 +268,50 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, clear: () => { audio.current?.pause(); + audio.current?.removeAttribute("src"); + audio.current?.load(); + autoplay.current = false; setQueue([]); setIndex(0); + persistQueue([], null, 0); }, toggle, - next: () => - setIndex((value) => Math.min(value + 1, Math.max(queue.length - 1, 0))), - previous: () => setIndex((value) => Math.max(value - 1, 0)), + next, + previous, seek: (seconds: number) => { if (audio.current) audio.current.currentTime = seconds; }, }), - [queue, index, current, playing, position, duration, play, toggle], + [ + queue, + index, + current, + playing, + play, + toggle, + next, + previous, + persistQueue, + ], + ); + + const progress = useMemo( + () => ({ position, duration }), + [position, duration], ); return ( - {children} + + + {children} + + ); } export function PlayerBar() { const player = usePlayer(); + const progress = usePlayerProgress(); const [scrubbing, setScrubbing] = useState(null); if (!player.current) return null; const commit = (value: number) => { @@ -259,20 +344,20 @@ export function PlayerBar() {
    - {formatDuration((scrubbing ?? player.position) * 1000)} + {formatDuration((scrubbing ?? progress.position) * 1000)} setScrubbing(Number(event.target.value))} onMouseUp={(event) => commit(Number(event.currentTarget.value))} onTouchEnd={(event) => commit(Number(event.currentTarget.value))} onKeyUp={(event) => commit(Number(event.currentTarget.value))} aria-label="Seek" /> - {formatDuration(player.duration * 1000)} + {formatDuration(progress.duration * 1000)}
    ); diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 91afb75..6dbddef 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -241,13 +241,11 @@ main { .songs { width: 100%; border-collapse: collapse; - display: block; - overflow-x: auto; } -.songs tbody { - display: table; +.songs-scroll { width: 100%; + overflow-x: auto; } .songs td { @@ -500,7 +498,7 @@ main { } main { - padding: 1rem; + padding: 1rem 1rem 9rem; } .page-header, From 3b6d3604feb5677ecd833f5bdfcba3e8bcf89f50 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 15:09:58 +0200 Subject: [PATCH 07/17] fix(review): address follow-up m4 findings Signed-off-by: InstaZDLL --- src/http.rs | 46 +++++++++++++++++++--- src/services.rs | 81 ++++++++++++++++++++++++++++++--------- src/sync.rs | 21 +++++++--- tests/v2_foundations.rs | 85 +++++++++++++++++++++++++++++++++-------- webapp/src/api.test.ts | 60 ++++++++++++++++++++++++++++- webapp/src/api.ts | 19 +++++---- webapp/src/player.tsx | 18 +++++---- 7 files changed, 267 insertions(+), 63 deletions(-) diff --git a/src/http.rs b/src/http.rs index a9b7d5f..0bef13e 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1516,7 +1516,7 @@ pub async fn sync_changes( .changes(user.id, after, limit) .await .map(Json) - .map_err(db_error) + .map_err(sync_error) } #[utoipa::path( @@ -1626,10 +1626,20 @@ async fn serve_sync_socket(socket: WebSocket, state: AppState, user_id: Uuid, af return; } } + let mut heartbeat = tokio::time::interval(Duration::from_secs(30)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat.tick().await; + let mut awaiting_pong = false; loop { tokio::select! { incoming = receiver.next() => match incoming { Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + Some(Ok(Message::Pong(_))) => awaiting_pong = false, + Some(Ok(Message::Ping(payload))) => { + if sender.send(Message::Pong(payload)).await.is_err() { + break; + } + } Some(Ok(_)) => {} }, notice = notices.recv() => match sync_notice_action(&state.sync, user_id, notice).await { @@ -1640,6 +1650,12 @@ async fn serve_sync_socket(socket: WebSocket, state: AppState, user_id: Uuid, af } Ok(SyncNoticeAction::Continue) => {} Ok(SyncNoticeAction::Close) | Err(_) => break, + }, + _ = heartbeat.tick() => { + if awaiting_pong || sender.send(Message::Ping(Vec::new().into())).await.is_err() { + break; + } + awaiting_pong = true; } } } @@ -1770,10 +1786,13 @@ fn expired_cookie(name: &str, http_only: bool, secure: bool) -> String { } fn secure_cookies(state: &AppState) -> bool { - state - .public_url - .as_deref() - .is_some_and(|url| url.starts_with("https://")) + public_url_is_https(state.public_url.as_deref()) +} + +fn public_url_is_https(public_url: Option<&str>) -> bool { + public_url + .and_then(|url| url::Url::parse(url).ok()) + .is_some_and(|url| url.scheme() == "https") } fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { @@ -1899,6 +1918,13 @@ fn db_error(error: sqlx::Error) -> ApiError { ApiError::Unavailable } +fn sync_error(error: crate::sync::SyncError) -> ApiError { + match error { + crate::sync::SyncError::Invalid => ApiError::Validation, + crate::sync::SyncError::Database(error) => db_error(error), + } +} + /// Maps a domain failure onto the HTTP surface. `Forbidden` deliberately answers /// 404 like `NotFound`: telling a caller that a resource exists but belongs to /// someone else would leak another tenant's catalogue, which is the same @@ -1919,7 +1945,15 @@ fn service_error(error: crate::services::ServiceError) -> ApiError { #[cfg(test)] mod tests { - use super::{sync_notice_action, SyncNoticeAction}; + use super::{public_url_is_https, sync_notice_action, SyncNoticeAction}; + + #[test] + fn secure_cookie_detection_uses_the_parsed_url_scheme() { + assert!(public_url_is_https(Some("HTTPS://waveflow.test/"))); + assert!(!public_url_is_https(Some("http://waveflow.test"))); + assert!(!public_url_is_https(Some("not a URL"))); + assert!(!public_url_is_https(None)); + } #[tokio::test] async fn lagged_sync_socket_recovers_from_the_durable_cursor() { diff --git a/src/services.rs b/src/services.rs index 0bccbc4..9c694e0 100644 --- a/src/services.rs +++ b/src/services.rs @@ -299,6 +299,15 @@ pub enum ServiceError { Security(#[from] security::SecurityError), } +impl From for ServiceError { + fn from(error: crate::sync::SyncError) -> Self { + match error { + crate::sync::SyncError::Invalid => Self::Invalid, + crate::sync::SyncError::Database(error) => Self::Database(error), + } + } +} + impl DomainServices { pub fn new(db: Database, secret_box: Arc, sync: SyncService) -> Self { Self { @@ -798,11 +807,34 @@ impl DomainServices { } pub async fn playlist(&self, user_id: Uuid, id: Uuid) -> Result { - self.playlists(user_id) - .await? - .into_iter() - .find(|playlist| playlist.id == id) - .ok_or(ServiceError::NotFound) + let mut connection = self.db.pool().acquire().await?; + self.playlist_on(&mut connection, user_id, id).await + } + + async fn playlist_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + id: Uuid, + ) -> Result { + let row = sqlx::query( + "SELECT id, name, comment, public, created_at, updated_at FROM playlist \ + WHERE id=? AND owner_user_id=?", + ) + .bind(id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(&mut *connection) + .await? + .ok_or(ServiceError::NotFound)?; + Ok(PlaylistItem { + id, + name: row.try_get("name")?, + comment: row.try_get("comment")?, + public: row.try_get::("public")? != 0, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + songs: self.playlist_songs_on(connection, user_id, id).await?, + }) } async fn playlist_songs_on( @@ -1555,22 +1587,35 @@ impl DomainServices { ) -> Result, ServiceError> { let rows = sqlx::query("SELECT id, token_nonce, token_ciphertext, description, expires_at, created_at, visit_count FROM share WHERE owner_user_id=? ORDER BY created_at DESC") .bind(user_id.to_string()).fetch_all(&mut *connection).await?; - let mut shares = Vec::new(); + let track_rows = sqlx::query( + "SELECT st.share_id, st.track_id FROM share_track st \ + JOIN share s ON s.id=st.share_id WHERE s.owner_user_id=? \ + ORDER BY st.share_id, st.position", + ) + .bind(user_id.to_string()) + .fetch_all(&mut *connection) + .await?; + let mut track_owners = Vec::with_capacity(track_rows.len()); + let mut track_ids = Vec::with_capacity(track_rows.len()); + for track_row in track_rows { + track_owners.push(parse_uuid(track_row.try_get("share_id")?)?); + track_ids.push(parse_uuid(track_row.try_get("track_id")?)?); + } + let songs = self + .songs_by_ids_on(connection, user_id, &track_ids) + .await?; + let mut songs_by_share = HashMap::>::new(); + for (share_id, song) in track_owners.into_iter().zip(songs) { + songs_by_share.entry(share_id).or_default().push(song); + } + + let mut shares = Vec::with_capacity(rows.len()); for row in rows { let id = parse_uuid(row.try_get("id")?)?; let nonce: Vec = row.try_get("token_nonce")?; let ciphertext: Vec = row.try_get("token_ciphertext")?; let token = String::from_utf8(self.secret_box.decrypt(&nonce, &ciphertext)?) .map_err(|_| ServiceError::Invalid)?; - let track_ids = sqlx::query_scalar::<_, String>( - "SELECT track_id FROM share_track WHERE share_id=? ORDER BY position", - ) - .bind(id.to_string()) - .fetch_all(&mut *connection) - .await? - .into_iter() - .map(parse_uuid) - .collect::, _>>()?; shares.push(ShareItem { id, owner_id: user_id, @@ -1579,9 +1624,7 @@ impl DomainServices { expires_at: row.try_get("expires_at")?, created_at: row.try_get("created_at")?, visit_count: row.try_get("visit_count")?, - songs: self - .songs_by_ids_on(connection, user_id, &track_ids) - .await?, + songs: songs_by_share.remove(&id).unwrap_or_default(), }); } Ok(shares) @@ -1949,7 +1992,7 @@ impl DomainServices { folder_ids: Option<&[Uuid]>, ) -> Result { self.require_admin(actor_id).await?; - validate_username(username)?; + validate_name(username)?; if password.is_empty() { return Err(ServiceError::Invalid); } diff --git a/src/sync.rs b/src/sync.rs index 07551ec..bf9fda3 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -15,6 +15,14 @@ use crate::{authentication::now_ms, database::Database}; pub const DEFAULT_SYNC_LIMIT: i64 = 100; pub const MAX_SYNC_LIMIT: i64 = 500; +#[derive(Debug, thiserror::Error)] +pub enum SyncError { + #[error("invalid synchronization input")] + Invalid, + #[error(transparent)] + Database(#[from] sqlx::Error), +} + #[derive(Debug, Clone, Copy)] pub struct MutationContext { pub operation_id: Uuid, @@ -91,7 +99,7 @@ impl SyncService { connection: &mut SqliteConnection, user_id: Uuid, context: MutationContext, - ) -> Result { + ) -> Result { if let Some(device_id) = context.origin_device_id { let owned = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM device \ @@ -108,9 +116,7 @@ impl SyncService { %device_id, "sync mutation rejected for an invalid origin device" ); - return Err(sqlx::Error::Protocol( - "sync origin device does not belong to the user".into(), - )); + return Err(SyncError::Invalid); } } let inserted = sqlx::query( @@ -144,7 +150,7 @@ impl SyncService { operation_id = %context.operation_id, "sync operation exists but has no completed event" ); - return Err(sqlx::Error::Protocol("sync operation is incomplete".into())); + return Err(sqlx::Error::Protocol("sync operation is incomplete".into()).into()); }; Ok(OperationClaim::Replayed(MutationReceipt { operation_id: context.operation_id, @@ -225,7 +231,10 @@ impl SyncService { user_id: Uuid, after: i64, limit: i64, - ) -> Result { + ) -> Result { + if !(1..=MAX_SYNC_LIMIT).contains(&limit) { + return Err(SyncError::Invalid); + } let rows = sqlx::query( "SELECT cursor, event_id, operation_id, origin_device_id, entity_type, entity_id, \ action, payload_json, changed_at \ diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index 16777ca..bcd6b6e 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -13,7 +13,10 @@ use waveflow_server::{ authentication::now_ms, catalog::{ApplyOutcome, CatalogTrackInput, LibraryRecord}, database::{AccountRole, LibraryRole, LibraryVisibility}, - security, Config, + security, + services::ServiceError, + sync::{MutationContext, SyncError, MAX_SYNC_LIMIT}, + Config, }; async fn test_app() -> (TempDir, Config, waveflow_server::AppState) { @@ -1930,26 +1933,25 @@ async fn subsonic_xml_json_auth_catalog_and_user_data_are_compatible() { assert!(default_folders.contains(&library.to_string())); assert!(default_folders.contains(&secondary_library.to_string())); - let invalid_username = router - .clone() - .oneshot( - Request::get(format!( - "/rest/createUser.view?apiKey={api_key}&v=1.16.1&c=golden&f=json&username=%C3%A9lodie&password=invalid-user-secret&email=invalid@example.invalid" - )) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(invalid_username.status(), StatusCode::BAD_REQUEST); - let invalid_username = json_body(invalid_username).await; - assert_eq!(invalid_username["subsonic-response"]["status"], "failed"); + let unicode_username = subsonic_json( + &router, + "createUser", + api_key, + "&username=%C3%A9lodie&password=unicode-user-secret&email=unicode@example.invalid", + ) + .await; + assert_eq!(unicode_username["subsonic-response"]["status"], "ok"); assert!(state .db .account_by_username("élodie") .await .unwrap() - .is_none()); + .is_some()); + assert_eq!( + subsonic_json(&router, "deleteUser", api_key, "&username=%C3%A9lodie").await + ["subsonic-response"]["status"], + "ok" + ); assert_eq!( subsonic_json(&router, "deleteUser", api_key, "&username=sub-default").await @@ -3195,6 +3197,36 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { assert_eq!(queue["current"], first); assert_eq!(queue["songs"].as_array().unwrap().len(), 2); + // Multiple shares retain their independent track ordering when the + // aggregate loader batches all share rows. + for track_ids in [vec![second.clone(), first.clone()], vec![first.clone()]] { + let share = send( + "POST", + "/api/v2/shares".into(), + owner_token.clone(), + Some(serde_json::json!({ "track_ids": track_ids })), + ) + .await; + assert_eq!(share.status(), StatusCode::CREATED); + } + let shares = send("GET", "/api/v2/shares".into(), owner_token.clone(), None).await; + let shares = json_body(shares).await; + let song_orders = shares + .as_array() + .unwrap() + .iter() + .map(|share| { + share["track_ids"] + .as_array() + .unwrap() + .iter() + .map(|track_id| track_id.as_str().unwrap().to_owned()) + .collect::>() + }) + .collect::>(); + assert!(song_orders.contains(&vec![second.to_string(), first.to_string()])); + assert!(song_orders.contains(&vec![first.to_string()])); + // A foreign tenant can neither read nor mutate any of it. let foreign_playlists = send( "GET", @@ -3326,6 +3358,27 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { let intruder_token = intruder_login["access_token"].as_str().unwrap().to_owned(); let intruder_device_id = intruder_login["device_id"].as_str().unwrap().to_owned(); + for invalid_limit in [0, MAX_SYNC_LIMIT + 1, i64::MAX] { + assert!(matches!( + state.sync.changes(owner, 0, invalid_limit).await, + Err(SyncError::Invalid) + )); + } + let direct_foreign_device = state + .services + .set_star_with_context( + owner, + "track", + track, + true, + MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: Some(Uuid::parse_str(&intruder_device_id).unwrap()), + }, + ) + .await; + assert!(matches!(direct_foreign_device, Err(ServiceError::Invalid))); + let mutate = |method: &'static str, uri: String, operation_id: Uuid, body: Option| { let router = router.clone(); diff --git a/webapp/src/api.test.ts b/webapp/src/api.test.ts index b431bb4..99ccd53 100644 --- a/webapp/src/api.test.ts +++ b/webapp/src/api.test.ts @@ -1,6 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { isAllowedRedirect, safeInternalPath } from "./api"; +import { + hasSession, + isAllowedRedirect, + listLibraries, + login, + safeInternalPath, +} from "./api"; + +afterEach(() => { + vi.unstubAllGlobals(); + // biome-ignore lint/suspicious/noDocumentCookie: jsdom has no Cookie Store API. + document.cookie = "waveflow-csrf=; Max-Age=0; Path=/"; +}); /** * These two guards are the client half of the OAuth redirect policy. Both were @@ -54,3 +66,47 @@ describe("safeInternalPath", () => { expect(safeInternalPath("")).toBeNull(); }); }); + +describe("session refresh failures", () => { + const webSession = { + access_token: "test-access", + user: { id: "user-id", username: "listener", role: "user" }, + device_id: "device-id", + }; + + async function establishSession(refreshResult: () => Promise) { + window.history.replaceState(null, "", "/login"); + // biome-ignore lint/suspicious/noDocumentCookie: jsdom has no Cookie Store API. + document.cookie = "waveflow-csrf=test-csrf; Path=/"; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify(webSession), { status: 200 }), + ) + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockImplementationOnce(refreshResult), + ); + await login("listener", "password"); + expect(hasSession()).toBe(true); + } + + it("clears an established session when refresh loses the network", async () => { + await establishSession(() => + Promise.reject(new TypeError("network unavailable")), + ); + + await expect(listLibraries()).rejects.toMatchObject({ status: 401 }); + expect(hasSession()).toBe(false); + }); + + it("clears an established session when refresh JSON is malformed", async () => { + await establishSession(() => + Promise.resolve(new Response("not-json", { status: 200 })), + ); + + await expect(listLibraries()).rejects.toMatchObject({ status: 401 }); + expect(hasSession()).toBe(false); + }); +}); diff --git a/webapp/src/api.ts b/webapp/src/api.ts index 92052bd..a87c2a4 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -171,16 +171,21 @@ async function performRefresh(): Promise { handleRefreshFailure(hadSession); return false; } - const response = await fetch("/api/v2/web/auth/refresh", { - method: "POST", - headers: { "x-waveflow-csrf": csrf }, - }); - if (!response.ok) { + try { + const response = await fetch("/api/v2/web/auth/refresh", { + method: "POST", + headers: { "x-waveflow-csrf": csrf }, + }); + if (!response.ok) { + handleRefreshFailure(hadSession); + return false; + } + session = await parse(response); + return true; + } catch { handleRefreshFailure(hadSession); return false; } - session = await parse(response); - return true; } function handleRefreshFailure(hadSession: boolean): void { diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index 8dcc7cb..273e468 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -68,7 +68,8 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const queueRef = useRef([]); const indexRef = useRef(0); const positionRef = useRef(0); - const hydrated = useRef(false); + const [hydrated, setHydrated] = useState(false); + const localMutation = useRef(false); const resumePosition = useRef(0); const resumeTrack = useRef(null); const autoplay = useRef(false); @@ -94,7 +95,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { let cancelled = false; void getQueue() .then((saved) => { - if (cancelled || !saved) return; + if (cancelled || localMutation.current || !saved) return; setQueue(saved.songs); const savedIndex = saved.current ? saved.songs.findIndex((song) => song.id === saved.current) @@ -107,7 +108,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // playback can still start a new queue and retry on its first mutation. .catch(() => undefined) .finally(() => { - if (!cancelled) hydrated.current = true; + if (!cancelled) setHydrated(true); }); return () => { cancelled = true; @@ -131,7 +132,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const onPlay = () => setPlaying(true); const onPause = () => { setPlaying(false); - if (!hydrated.current) return; + if (!hydrated) return; const songs = queueRef.current; const selected = songs[indexRef.current] ?? null; persistQueue( @@ -152,7 +153,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { element.removeEventListener("play", onPlay); element.removeEventListener("pause", onPause); }; - }, [persistQueue]); + }, [hydrated, persistQueue]); // Loading a track needs a round-trip for its ticket, so guard against a // stale response overwriting a newer selection. @@ -195,6 +196,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, [position, duration, current]); const play = useCallback((next: Song[], at: number) => { + localMutation.current = true; const sameSelection = next === queueRef.current && at === indexRef.current; if (sameSelection) { const element = audio.current; @@ -213,7 +215,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { - if (!hydrated.current) return; + if (!hydrated) return; const timeout = window.setTimeout(() => { persistQueue( queue, @@ -222,7 +224,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { ); }, 400); return () => window.clearTimeout(timeout); - }, [queue, current, persistQueue]); + }, [queue, current, hydrated, persistQueue]); const toggle = useCallback(() => { const element = audio.current; @@ -259,6 +261,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { playing, play, remove: (at: number) => { + localMutation.current = true; setQueue((songs) => songs.filter((_, position) => position !== at)); setIndex((value) => value > at @@ -267,6 +270,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { ); }, clear: () => { + localMutation.current = true; audio.current?.pause(); audio.current?.removeAttribute("src"); audio.current?.load(); From 9fb7b2dfc6434d6e3486a3513a55ee8c36ad7334 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 15:23:44 +0200 Subject: [PATCH 08/17] fix(sync-v2): bind operation ids to mutation intent Signed-off-by: InstaZDLL --- docs/rfcs/RFC-003-waveflow-sync-v2.md | 3 + ...20260809140000_sync_intent_fingerprint.sql | 4 + src/http.rs | 1 + src/services.rs | 140 ++++++++++++++---- src/sync.rs | 98 +++++++++++- tests/v2_foundations.rs | 46 ++++++ webapp/src/player.tsx | 17 ++- 7 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 migrations-v2/20260809140000_sync_intent_fingerprint.sql diff --git a/docs/rfcs/RFC-003-waveflow-sync-v2.md b/docs/rfcs/RFC-003-waveflow-sync-v2.md index 07a2dcb..b2e89ac 100644 --- a/docs/rfcs/RFC-003-waveflow-sync-v2.md +++ b/docs/rfcs/RFC-003-waveflow-sync-v2.md @@ -78,6 +78,9 @@ SQLite transaction behind the process-wide writer gate. Repeating the same operation ID is recognized as already applied and never creates a second domain row or journal event. Resource endpoints return the current representation when it still exists; the journal receipt remains the durable proof of application. +The reservation stores a canonical fingerprint of the action, target resource +and normalized payload. Reusing an operation ID with a different fingerprint is +rejected as a conflict instead of being reported as a successful replay. `PUT /api/v2/sync/ack` with `{ "device_id": "", "cursor": 42 }` records a monotonic per-device acknowledgement. A cursor below the stored ACK diff --git a/migrations-v2/20260809140000_sync_intent_fingerprint.sql b/migrations-v2/20260809140000_sync_intent_fingerprint.sql new file mode 100644 index 0000000..1bc41fc --- /dev/null +++ b/migrations-v2/20260809140000_sync_intent_fingerprint.sql @@ -0,0 +1,4 @@ +-- An operation UUID is idempotent only for the exact normalized mutation +-- intent that first claimed it. Existing rows remain NULL and are rejected on +-- replay because their original intent cannot be proven. +ALTER TABLE sync_operation ADD COLUMN intent_hash BLOB; diff --git a/src/http.rs b/src/http.rs index 0bef13e..9ce0e81 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1921,6 +1921,7 @@ fn db_error(error: sqlx::Error) -> ApiError { fn sync_error(error: crate::sync::SyncError) -> ApiError { match error { crate::sync::SyncError::Invalid => ApiError::Validation, + crate::sync::SyncError::Conflict => ApiError::Validation, crate::sync::SyncError::Database(error) => db_error(error), } } diff --git a/src/services.rs b/src/services.rs index 9c694e0..e4e3f0a 100644 --- a/src/services.rs +++ b/src/services.rs @@ -11,7 +11,7 @@ use crate::{ authentication::now_ms, database::{AccountRecord, AccountRole, Database}, security::{self, EncryptedSecret, SecretBox}, - sync::{MutationContext, MutationReceipt, OperationClaim, SyncService}, + sync::{MutationContext, MutationIntent, MutationReceipt, OperationClaim, SyncService}, }; /// Tenant-filtered projections shared by the Subsonic facade and the native @@ -303,6 +303,7 @@ impl From for ServiceError { fn from(error: crate::sync::SyncError) -> Self { match error { crate::sync::SyncError::Invalid => Self::Invalid, + crate::sync::SyncError::Conflict => Self::Conflict, crate::sync::SyncError::Database(error) => Self::Database(error), } } @@ -881,10 +882,17 @@ impl DomainServices { ) -> Result { validate_name(name)?; self.songs_by_ids(user_id, track_ids).await?; + let intent = MutationIntent::new( + "create", + "playlist", + &serde_json::json!({ "name": name.trim(), "track_ids": track_ids }), + ); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "playlist")?; @@ -962,10 +970,21 @@ impl DomainServices { validate_name(name)?; } self.songs_by_ids(user_id, add).await?; - let mut ids = current.songs.iter().map(|song| song.id).collect::>(); let mut removes = remove_indexes.to_vec(); removes.sort_unstable_by(|a, b| b.cmp(a)); removes.dedup(); + let intent = MutationIntent::new( + "update", + &format!("playlist:{id}"), + &serde_json::json!({ + "name": name.map(str::trim), + "comment": comment, + "public": public, + "add": add, + "remove_indexes": &removes, + }), + ); + let mut ids = current.songs.iter().map(|song| song.id).collect::>(); for index in removes { if index >= ids.len() { return Err(ServiceError::Invalid); @@ -975,8 +994,10 @@ impl DomainServices { ids.extend_from_slice(add); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "playlist")?; @@ -1037,10 +1058,14 @@ impl DomainServices { id: Uuid, context: MutationContext, ) -> Result<(), ServiceError> { + let intent = + MutationIntent::new("delete", &format!("playlist:{id}"), &serde_json::json!({})); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "playlist")?; @@ -1102,10 +1127,17 @@ impl DomainServices { ) -> Result<(), ServiceError> { self.authorize_entity(user_id, entity_type, entity_id) .await?; + let intent = MutationIntent::new( + if starred { "star" } else { "unstar" }, + &format!("{entity_type}:{entity_id}"), + &serde_json::json!({ "starred": starred }), + ); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "favorite")?; @@ -1267,10 +1299,17 @@ impl DomainServices { } self.authorize_entity(user_id, entity_type, entity_id) .await?; + let intent = MutationIntent::new( + "set-rating", + &format!("{entity_type}:{entity_id}"), + &serde_json::json!({ "rating": rating }), + ); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "rating")?; @@ -1343,10 +1382,21 @@ impl DomainServices { if now < 0 || now > current_time.saturating_add(MAX_FUTURE_SKEW_MS) { return Err(ServiceError::Invalid); } + let intent = MutationIntent::new( + if submission { + "scrobble" + } else { + "now-playing" + }, + &format!("track:{track_id}"), + &serde_json::json!({ "submission": submission, "time": time }), + ); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "scrobble")?; @@ -1491,10 +1541,22 @@ impl DomainServices { if let Some(current) = current { self.songs_by_ids(user_id, &[current]).await?; } + let intent = MutationIntent::new( + "save", + &format!("queue:{user_id}"), + &serde_json::json!({ + "track_ids": ids, + "current": current, + "position_ms": position_ms, + "client": client, + }), + ); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "queue")?; @@ -1659,15 +1721,21 @@ impl DomainServices { return Err(ServiceError::Invalid); } self.songs_by_ids(user_id, ids).await?; - let token = security::generate_token("wfs_"); - let token_hash = security::token_hash(&token); - let encrypted = self.secret_box.encrypt(token.as_bytes())?; - let id = Uuid::new_v4(); - let now = now_ms(); + let intent = MutationIntent::new( + "create", + "share", + &serde_json::json!({ + "track_ids": ids, + "description": description, + "expires_at": expires_at, + }), + ); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "share")?; @@ -1680,6 +1748,11 @@ impl DomainServices { .find(|share| share.id == id) .ok_or(ServiceError::NotFound); } + let token = security::generate_token("wfs_"); + let token_hash = security::token_hash(&token); + let encrypted = self.secret_box.encrypt(token.as_bytes())?; + let id = Uuid::new_v4(); + let now = now_ms(); sqlx::query("INSERT INTO share (id, owner_user_id, token_hash, token_nonce, token_ciphertext, description, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") .bind(id.to_string()).bind(user_id.to_string()).bind(token_hash.as_slice()).bind(encrypted.nonce.as_slice()).bind(encrypted.ciphertext).bind(description).bind(expires_at).bind(now).bind(now).execute(&mut *tx).await?; for (position, track) in ids.iter().enumerate() { @@ -1783,10 +1856,20 @@ impl DomainServices { expires_at: Option, context: MutationContext, ) -> Result { + let intent = MutationIntent::new( + "update", + &format!("share:{id}"), + &serde_json::json!({ + "description": description, + "expires_at": expires_at, + }), + ); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "share")?; @@ -1844,10 +1927,13 @@ impl DomainServices { id: Uuid, context: MutationContext, ) -> Result<(), ServiceError> { + let intent = MutationIntent::new("delete", &format!("share:{id}"), &serde_json::json!({})); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = - self.sync.claim_operation(&mut tx, user_id, context).await? + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&mut tx, user_id, context, intent) + .await? { tx.rollback().await?; validate_replay_type(&receipt, "share")?; diff --git a/src/sync.rs b/src/sync.rs index bf9fda3..30a503d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -19,10 +19,33 @@ pub const MAX_SYNC_LIMIT: i64 = 500; pub enum SyncError { #[error("invalid synchronization input")] Invalid, + #[error("operation id was already used for another intent")] + Conflict, #[error(transparent)] Database(#[from] sqlx::Error), } +#[derive(Debug, Clone, Copy)] +pub struct MutationIntent([u8; 32]); + +impl MutationIntent { + pub fn new(action: &str, target: &str, payload: &Value) -> Self { + let payload = canonical_json(payload); + let payload = serde_json::to_vec(&payload).expect("JSON values always serialize"); + let mut hasher = blake3::Hasher::new(); + hasher.update(b"waveflow-sync-intent-v1"); + for component in [action.as_bytes(), target.as_bytes(), payload.as_slice()] { + hasher.update(&(component.len() as u64).to_le_bytes()); + hasher.update(component); + } + Self(*hasher.finalize().as_bytes()) + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + #[derive(Debug, Clone, Copy)] pub struct MutationContext { pub operation_id: Uuid, @@ -99,6 +122,7 @@ impl SyncService { connection: &mut SqliteConnection, user_id: Uuid, context: MutationContext, + intent: MutationIntent, ) -> Result { if let Some(device_id) = context.origin_device_id { let owned = sqlx::query_scalar::<_, bool>( @@ -121,12 +145,13 @@ impl SyncService { } let inserted = sqlx::query( "INSERT INTO sync_operation \ - (user_id, operation_id, origin_device_id, created_at) VALUES (?, ?, ?, ?) \ + (user_id, operation_id, origin_device_id, intent_hash, created_at) VALUES (?, ?, ?, ?, ?) \ ON CONFLICT (user_id, operation_id) DO NOTHING", ) .bind(user_id.to_string()) .bind(context.operation_id.to_string()) .bind(context.origin_device_id.map(|id| id.to_string())) + .bind(intent.as_bytes()) .bind(now_ms()) .execute(&mut *connection) .await? @@ -136,7 +161,7 @@ impl SyncService { } let row = sqlx::query( - "SELECT so.result_entity_id, so.event_cursor, se.entity_type \ + "SELECT so.result_entity_id, so.event_cursor, so.intent_hash, se.entity_type \ FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor \ WHERE so.user_id=? AND so.operation_id=? AND so.applied_at IS NOT NULL", ) @@ -152,6 +177,10 @@ impl SyncService { ); return Err(sqlx::Error::Protocol("sync operation is incomplete".into()).into()); }; + let stored_intent: Option> = row.try_get("intent_hash")?; + if stored_intent.as_deref() != Some(intent.as_bytes()) { + return Err(SyncError::Conflict); + } Ok(OperationClaim::Replayed(MutationReceipt { operation_id: context.operation_id, result_entity_id: row @@ -232,7 +261,7 @@ impl SyncService { after: i64, limit: i64, ) -> Result { - if !(1..=MAX_SYNC_LIMIT).contains(&limit) { + if after < 0 || !(1..=MAX_SYNC_LIMIT).contains(&limit) { return Err(SyncError::Invalid); } let rows = sqlx::query( @@ -310,6 +339,22 @@ impl SyncService { } } +fn canonical_json(value: &Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + Value::Object(values) => { + let mut keys = values.keys().collect::>(); + keys.sort_unstable(); + Value::Object( + keys.into_iter() + .map(|key| (key.clone(), canonical_json(&values[key]))) + .collect(), + ) + } + value => value.clone(), + } +} + fn change_from_row(row: sqlx::sqlite::SqliteRow) -> Result { let payload: String = row.try_get("payload_json")?; Ok(SyncChange { @@ -332,3 +377,50 @@ fn change_from_row(row: sqlx::sqlite::SqliteRow) -> Result Result { Uuid::parse_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) } + +#[cfg(test)] +mod tests { + use super::MutationIntent; + + #[test] + fn mutation_intents_are_canonical_and_scope_action_target_and_payload() { + let first = MutationIntent::new( + "update", + "playlist:one", + &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), + ); + let reordered = MutationIntent::new( + "update", + "playlist:one", + &serde_json::json!({ "tracks": ["a", "b"], "name": "Road" }), + ); + assert_eq!(first.as_bytes(), reordered.as_bytes()); + assert_ne!( + first.as_bytes(), + MutationIntent::new( + "delete", + "playlist:one", + &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), + ) + .as_bytes() + ); + assert_ne!( + first.as_bytes(), + MutationIntent::new( + "update", + "playlist:two", + &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), + ) + .as_bytes() + ); + assert_ne!( + first.as_bytes(), + MutationIntent::new( + "update", + "playlist:one", + &serde_json::json!({ "name": "Night", "tracks": ["a", "b"] }), + ) + .as_bytes() + ); + } +} diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index bcd6b6e..9d5afdb 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -1952,6 +1952,12 @@ async fn subsonic_xml_json_auth_catalog_and_user_data_are_compatible() { ["subsonic-response"]["status"], "ok" ); + assert!(state + .db + .account_by_username("élodie") + .await + .unwrap() + .is_none()); assert_eq!( subsonic_json(&router, "deleteUser", api_key, "&username=sub-default").await @@ -3364,6 +3370,10 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { Err(SyncError::Invalid) )); } + assert!(matches!( + state.sync.changes(owner, -1, 1).await, + Err(SyncError::Invalid) + )); let direct_foreign_device = state .services .set_star_with_context( @@ -3431,6 +3441,21 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { .await; assert_eq!(mismatched_replay.status(), StatusCode::UNPROCESSABLE_ENTITY); + let inverted_favorite = mutate( + "DELETE", + format!("/api/v2/favorites/track/{track}"), + favorite_operation, + None, + ) + .await; + assert_eq!(inverted_favorite.status(), StatusCode::UNPROCESSABLE_ENTITY); + let star_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_star WHERE user_id=?") + .bind(owner.to_string()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(star_count, 1); + let scrobble_operation = Uuid::new_v4(); for _ in 0..2 { let response = mutate( @@ -3463,6 +3488,27 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { playlist_ids.push(json_body(response).await["id"].as_str().unwrap().to_owned()); } assert_eq!(playlist_ids[0], playlist_ids[1]); + let different_playlist = mutate( + "POST", + "/api/v2/playlists".into(), + create_operation, + Some(serde_json::json!({ + "name": "Different playlist", + "track_ids": [track] + })), + ) + .await; + assert_eq!( + different_playlist.status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + let playlist_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM playlist WHERE owner_user_id=?") + .bind(owner.to_string()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(playlist_count, 1); let share_operation = Uuid::new_v4(); let mut share_ids = Vec::new(); diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index 273e468..fa5932c 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -159,6 +159,13 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // stale response overwriting a newer selection. useEffect(() => { const element = audio.current; + const resumeSeconds = + current && resumeTrack.current === current.id + ? resumePosition.current + : 0; + positionRef.current = resumeSeconds; + setPosition(resumeSeconds); + setDuration(0); if (!element || !current) return; let cancelled = false; submitted.current = null; @@ -169,8 +176,10 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const url = await streamUrl(current.id); if (cancelled) return; element.src = url; - if (resumeTrack.current === current.id && resumePosition.current > 0) { - element.currentTime = resumePosition.current; + if (resumeSeconds > 0) { + element.currentTime = resumeSeconds; + } + if (resumeTrack.current === current.id) { resumePosition.current = 0; resumeTrack.current = null; } @@ -262,6 +271,10 @@ export function PlayerProvider({ children }: { children: ReactNode }) { play, remove: (at: number) => { localMutation.current = true; + if (at === indexRef.current) { + autoplay.current = false; + audio.current?.pause(); + } setQueue((songs) => songs.filter((_, position) => position !== at)); setIndex((value) => value > at From c641d29838c7a0d5d79dfd2ef7afd0b04957be82 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 15:39:34 +0200 Subject: [PATCH 09/17] fix(sync-v2): claim operations before validation Signed-off-by: InstaZDLL --- src/database.rs | 81 +++++++++++++++++++- src/services.rs | 86 ++++++++++----------- src/sync.rs | 98 ++++++++++-------------- tests/v2_foundations.rs | 161 ++++++++++++++++++++++++++++++++++++++++ webapp/src/player.tsx | 22 +++++- 5 files changed, 344 insertions(+), 104 deletions(-) diff --git a/src/database.rs b/src/database.rs index 06e03cd..4bfad99 100644 --- a/src/database.rs +++ b/src/database.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use sqlx::{ migrate::Migrator, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, - Row, SqlitePool, + Row, SqliteConnection, SqlitePool, }; use tokio::sync::{Mutex, OwnedMutexGuard}; use utoipa::ToSchema; @@ -168,6 +168,22 @@ pub struct AuthorizationRecord { pub device_name: String, } +#[derive(Debug)] +pub(crate) struct SyncOperationReplayRecord { + pub result_entity_id: Option, + pub event_cursor: i64, + pub intent_hash: Option>, + pub entity_type: String, +} + +#[derive(Debug)] +pub(crate) enum SyncOperationReservation { + InvalidOriginDevice, + New, + Incomplete, + Replayed(SyncOperationReplayRecord), +} + impl Database { pub async fn setup_required(&self) -> Result { let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM account") @@ -334,6 +350,69 @@ impl Database { Arc::clone(&self.writer).lock_owned().await } + #[allow(clippy::too_many_arguments)] + pub(crate) async fn reserve_sync_operation( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + operation_id: Uuid, + origin_device_id: Option, + intent_hash: &[u8], + created_at: i64, + ) -> Result { + if let Some(device_id) = origin_device_id { + let owned = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM device \ + WHERE id=? AND user_id=? AND revoked_at IS NULL)", + ) + .bind(device_id.to_string()) + .bind(user_id.to_string()) + .fetch_one(&mut *connection) + .await?; + if !owned { + return Ok(SyncOperationReservation::InvalidOriginDevice); + } + } + + let inserted = sqlx::query( + "INSERT INTO sync_operation \ + (user_id, operation_id, origin_device_id, intent_hash, created_at) VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT (user_id, operation_id) DO NOTHING", + ) + .bind(user_id.to_string()) + .bind(operation_id.to_string()) + .bind(origin_device_id.map(|id| id.to_string())) + .bind(intent_hash) + .bind(created_at) + .execute(&mut *connection) + .await? + .rows_affected(); + if inserted == 1 { + return Ok(SyncOperationReservation::New); + } + + let row = sqlx::query( + "SELECT so.result_entity_id, so.event_cursor, so.intent_hash, se.entity_type \ + FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor \ + WHERE so.user_id=? AND so.operation_id=? AND so.applied_at IS NOT NULL", + ) + .bind(user_id.to_string()) + .bind(operation_id.to_string()) + .fetch_optional(&mut *connection) + .await?; + let Some(row) = row else { + return Ok(SyncOperationReservation::Incomplete); + }; + Ok(SyncOperationReservation::Replayed( + SyncOperationReplayRecord { + result_entity_id: row.try_get("result_entity_id")?, + event_cursor: row.try_get("event_cursor")?, + intent_hash: row.try_get("intent_hash")?, + entity_type: row.try_get("entity_type")?, + }, + )) + } + pub async fn create_account( &self, username: &str, diff --git a/src/services.rs b/src/services.rs index e4e3f0a..aa0d266 100644 --- a/src/services.rs +++ b/src/services.rs @@ -880,8 +880,6 @@ impl DomainServices { track_ids: &[Uuid], context: MutationContext, ) -> Result { - validate_name(name)?; - self.songs_by_ids(user_id, track_ids).await?; let intent = MutationIntent::new( "create", "playlist", @@ -900,6 +898,8 @@ impl DomainServices { drop(_writer); return self.playlist(user_id, id).await; } + validate_name(name)?; + self.songs_by_ids_on(&mut tx, user_id, track_ids).await?; let id = Uuid::new_v4(); let now = now_ms(); sqlx::query("INSERT INTO playlist (id, owner_user_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") @@ -965,11 +965,6 @@ impl DomainServices { remove_indexes: &[usize], context: MutationContext, ) -> Result { - let current = self.playlist(user_id, id).await?; - if let Some(name) = name { - validate_name(name)?; - } - self.songs_by_ids(user_id, add).await?; let mut removes = remove_indexes.to_vec(); removes.sort_unstable_by(|a, b| b.cmp(a)); removes.dedup(); @@ -984,14 +979,6 @@ impl DomainServices { "remove_indexes": &removes, }), ); - let mut ids = current.songs.iter().map(|song| song.id).collect::>(); - for index in removes { - if index >= ids.len() { - return Err(ServiceError::Invalid); - } - ids.remove(index); - } - ids.extend_from_slice(add); let _writer = self.db.writer_guard().await; let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self @@ -1004,6 +991,19 @@ impl DomainServices { drop(_writer); return self.playlist(user_id, id).await; } + let current = self.playlist_on(&mut tx, user_id, id).await?; + if let Some(name) = name { + validate_name(name)?; + } + self.songs_by_ids_on(&mut tx, user_id, add).await?; + let mut ids = current.songs.iter().map(|song| song.id).collect::>(); + for index in removes { + if index >= ids.len() { + return Err(ServiceError::Invalid); + } + ids.remove(index); + } + ids.extend_from_slice(add); let changed_at = now_ms(); sqlx::query( "UPDATE playlist SET name=COALESCE(?, name), comment=COALESCE(?, comment), \ @@ -1125,8 +1125,6 @@ impl DomainServices { starred: bool, context: MutationContext, ) -> Result<(), ServiceError> { - self.authorize_entity(user_id, entity_type, entity_id) - .await?; let intent = MutationIntent::new( if starred { "star" } else { "unstar" }, &format!("{entity_type}:{entity_id}"), @@ -1143,6 +1141,8 @@ impl DomainServices { validate_replay_type(&receipt, "favorite")?; return Ok(()); } + self.authorize_entity_on(&mut tx, user_id, entity_type, entity_id) + .await?; if starred { sqlx::query("INSERT INTO user_star (user_id, entity_type, entity_id, starred_at) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET starred_at=excluded.starred_at") .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(now_ms()) @@ -1294,11 +1294,6 @@ impl DomainServices { rating: i64, context: MutationContext, ) -> Result<(), ServiceError> { - if !(0..=5).contains(&rating) { - return Err(ServiceError::Invalid); - } - self.authorize_entity(user_id, entity_type, entity_id) - .await?; let intent = MutationIntent::new( "set-rating", &format!("{entity_type}:{entity_id}"), @@ -1315,6 +1310,11 @@ impl DomainServices { validate_replay_type(&receipt, "rating")?; return Ok(()); } + if !(0..=5).contains(&rating) { + return Err(ServiceError::Invalid); + } + self.authorize_entity_on(&mut tx, user_id, entity_type, entity_id) + .await?; if rating == 0 { sqlx::query( "DELETE FROM user_rating WHERE user_id=? AND entity_type=? AND entity_id=?", @@ -1375,13 +1375,6 @@ impl DomainServices { time: Option, context: MutationContext, ) -> Result<(), ServiceError> { - self.authorize_entity(user_id, "track", track_id).await?; - let current_time = now_ms(); - let now = time.unwrap_or(current_time); - const MAX_FUTURE_SKEW_MS: i64 = 5 * 60 * 1_000; - if now < 0 || now > current_time.saturating_add(MAX_FUTURE_SKEW_MS) { - return Err(ServiceError::Invalid); - } let intent = MutationIntent::new( if submission { "scrobble" @@ -1402,6 +1395,14 @@ impl DomainServices { validate_replay_type(&receipt, "scrobble")?; return Ok(()); } + self.authorize_entity_on(&mut tx, user_id, "track", track_id) + .await?; + let current_time = now_ms(); + let now = time.unwrap_or(current_time); + const MAX_FUTURE_SKEW_MS: i64 = 5 * 60 * 1_000; + if now < 0 || now > current_time.saturating_add(MAX_FUTURE_SKEW_MS) { + return Err(ServiceError::Invalid); + } sqlx::query( "INSERT INTO play_event (user_id, track_id, submission, played_at) VALUES (?, ?, ?, ?)", ) @@ -1534,13 +1535,6 @@ impl DomainServices { client: Option<&str>, context: MutationContext, ) -> Result<(), ServiceError> { - if position_ms < 0 { - return Err(ServiceError::Invalid); - } - self.songs_by_ids(user_id, ids).await?; - if let Some(current) = current { - self.songs_by_ids(user_id, &[current]).await?; - } let intent = MutationIntent::new( "save", &format!("queue:{user_id}"), @@ -1562,6 +1556,13 @@ impl DomainServices { validate_replay_type(&receipt, "queue")?; return Ok(()); } + if position_ms < 0 { + return Err(ServiceError::Invalid); + } + self.songs_by_ids_on(&mut tx, user_id, ids).await?; + if let Some(current) = current { + self.songs_by_ids_on(&mut tx, user_id, &[current]).await?; + } sqlx::query("INSERT INTO play_queue (user_id, current_track_id, position_ms, changed_by, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET current_track_id=excluded.current_track_id, position_ms=excluded.position_ms, changed_by=excluded.changed_by, updated_at=excluded.updated_at") .bind(user_id.to_string()).bind(current.map(|id| id.to_string())).bind(position_ms).bind(client).bind(now_ms()).execute(&mut *tx).await?; sqlx::query("DELETE FROM play_queue_track WHERE user_id=?") @@ -1717,10 +1718,6 @@ impl DomainServices { expires_at: Option, context: MutationContext, ) -> Result { - if ids.is_empty() { - return Err(ServiceError::Invalid); - } - self.songs_by_ids(user_id, ids).await?; let intent = MutationIntent::new( "create", "share", @@ -1748,6 +1745,10 @@ impl DomainServices { .find(|share| share.id == id) .ok_or(ServiceError::NotFound); } + if ids.is_empty() { + return Err(ServiceError::Invalid); + } + self.songs_by_ids_on(&mut tx, user_id, ids).await?; let token = security::generate_token("wfs_"); let token_hash = security::token_hash(&token); let encrypted = self.secret_box.encrypt(token.as_bytes())?; @@ -2330,8 +2331,9 @@ impl DomainServices { Ok(unique) } - async fn authorize_entity( + async fn authorize_entity_on( &self, + connection: &mut SqliteConnection, user_id: Uuid, kind: &str, id: Uuid, @@ -2345,7 +2347,7 @@ impl DomainServices { let exists = sqlx::query_scalar::<_, i64>(query) .bind(id.to_string()) .bind(user_id.to_string()) - .fetch_optional(self.db.pool()) + .fetch_optional(&mut *connection) .await?; if exists.is_some() { Ok(()) diff --git a/src/sync.rs b/src/sync.rs index 30a503d..b91ccda 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -10,7 +10,10 @@ use tokio::sync::broadcast; use utoipa::ToSchema; use uuid::Uuid; -use crate::{authentication::now_ms, database::Database}; +use crate::{ + authentication::now_ms, + database::{Database, SyncOperationReservation}, +}; pub const DEFAULT_SYNC_LIMIT: i64 = 100; pub const MAX_SYNC_LIMIT: i64 = 500; @@ -124,73 +127,52 @@ impl SyncService { context: MutationContext, intent: MutationIntent, ) -> Result { - if let Some(device_id) = context.origin_device_id { - let owned = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM device \ - WHERE id=? AND user_id=? AND revoked_at IS NULL)", + let reservation = self + .db + .reserve_sync_operation( + connection, + user_id, + context.operation_id, + context.origin_device_id, + intent.as_bytes(), + now_ms(), ) - .bind(device_id.to_string()) - .bind(user_id.to_string()) - .fetch_one(&mut *connection) .await?; - if !owned { + match reservation { + SyncOperationReservation::InvalidOriginDevice => { + let device_id = context + .origin_device_id + .expect("invalid reservation includes an origin device"); tracing::warn!( %user_id, operation_id = %context.operation_id, %device_id, "sync mutation rejected for an invalid origin device" ); - return Err(SyncError::Invalid); + Err(SyncError::Invalid) + } + SyncOperationReservation::New => Ok(OperationClaim::New), + SyncOperationReservation::Incomplete => { + tracing::error!( + %user_id, + operation_id = %context.operation_id, + "sync operation exists but has no completed event" + ); + Err(sqlx::Error::Protocol("sync operation is incomplete".into()).into()) + } + SyncOperationReservation::Replayed(record) => { + if record.intent_hash.as_deref() != Some(intent.as_bytes()) { + return Err(SyncError::Conflict); + } + Ok(OperationClaim::Replayed(MutationReceipt { + operation_id: context.operation_id, + result_entity_id: record.result_entity_id.map(parse_uuid).transpose()?, + entity_type: record.entity_type, + cursor: record.event_cursor, + replayed: true, + })) } } - let inserted = sqlx::query( - "INSERT INTO sync_operation \ - (user_id, operation_id, origin_device_id, intent_hash, created_at) VALUES (?, ?, ?, ?, ?) \ - ON CONFLICT (user_id, operation_id) DO NOTHING", - ) - .bind(user_id.to_string()) - .bind(context.operation_id.to_string()) - .bind(context.origin_device_id.map(|id| id.to_string())) - .bind(intent.as_bytes()) - .bind(now_ms()) - .execute(&mut *connection) - .await? - .rows_affected(); - if inserted == 1 { - return Ok(OperationClaim::New); - } - - let row = sqlx::query( - "SELECT so.result_entity_id, so.event_cursor, so.intent_hash, se.entity_type \ - FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor \ - WHERE so.user_id=? AND so.operation_id=? AND so.applied_at IS NOT NULL", - ) - .bind(user_id.to_string()) - .bind(context.operation_id.to_string()) - .fetch_optional(&mut *connection) - .await?; - let Some(row) = row else { - tracing::error!( - %user_id, - operation_id = %context.operation_id, - "sync operation exists but has no completed event" - ); - return Err(sqlx::Error::Protocol("sync operation is incomplete".into()).into()); - }; - let stored_intent: Option> = row.try_get("intent_hash")?; - if stored_intent.as_deref() != Some(intent.as_bytes()) { - return Err(SyncError::Conflict); - } - Ok(OperationClaim::Replayed(MutationReceipt { - operation_id: context.operation_id, - result_entity_id: row - .try_get::, _>("result_entity_id")? - .map(parse_uuid) - .transpose()?, - entity_type: row.try_get("entity_type")?, - cursor: row.try_get("event_cursor")?, - replayed: true, - })) } #[allow(clippy::too_many_arguments)] diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index 9d5afdb..fee44e9 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -3689,6 +3689,167 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { .is_empty()); } +#[tokio::test] +async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { + let (_temp, config, state) = test_app().await; + let hash = security::hash_password(&Uuid::new_v4().to_string()).unwrap(); + let owner = state + .db + .create_account("claim-owner", &hash, AccountRole::Admin, now_ms()) + .await + .unwrap(); + let listener = state + .db + .create_account("claim-listener", &hash, AccountRole::User, now_ms()) + .await + .unwrap(); + let music = config.data_dir.join("claim-music"); + std::fs::create_dir_all(&music).unwrap(); + let library = state + .db + .create_library( + owner, + "Claim library", + &std::fs::canonicalize(&music).unwrap(), + LibraryVisibility::Private, + now_ms(), + ) + .await + .unwrap(); + let scan = state + .db + .create_scan_job(library, Some(owner), "manual") + .await + .unwrap(); + state.db.start_scan_job(scan, 1).await.unwrap(); + state + .db + .apply_catalog_track( + library, + scan, + &browse_input( + 0, + "Claimed Song", + "Claimed Album", + "Claimed Artist", + Some(1), + Some(1), + ), + None, + false, + ) + .await + .unwrap(); + state.db.finish_scan_job(scan, 0).await.unwrap(); + let track = state.db.list_tracks_for_user(owner, library).await.unwrap()[0].id; + + let playlist_context = MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: None, + }; + let playlist = state + .services + .create_playlist_with_context(owner, "Claimed playlist", &[track], playlist_context) + .await + .unwrap(); + state + .services + .delete_playlist(owner, playlist.id) + .await + .unwrap(); + assert!(matches!( + state + .services + .update_playlist_with_context( + owner, + playlist.id, + Some("Changed after deletion"), + None, + None, + &[], + &[], + playlist_context, + ) + .await, + Err(ServiceError::Conflict) + )); + + state + .db + .add_library_member(owner, library, listener, LibraryRole::Listener, now_ms()) + .await + .unwrap(); + let inaccessible_context = MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: None, + }; + state + .services + .set_star_with_context(listener, "track", track, true, inaccessible_context) + .await + .unwrap(); + assert!(state + .db + .remove_library_member(owner, library, listener, now_ms()) + .await + .unwrap()); + state + .services + .set_star_with_context(listener, "track", track, true, inaccessible_context) + .await + .unwrap(); + assert!(matches!( + state + .services + .set_rating_with_context(listener, "track", track, 5, inaccessible_context) + .await, + Err(ServiceError::Conflict) + )); + + let invalid_replay_context = MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: None, + }; + state + .services + .set_rating_with_context(owner, "track", track, 5, invalid_replay_context) + .await + .unwrap(); + assert!(matches!( + state + .services + .set_rating_with_context(owner, "track", track, 6, invalid_replay_context) + .await, + Err(ServiceError::Conflict) + )); + + let rolled_back_context = MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: None, + }; + assert!(matches!( + state + .services + .set_rating_with_context(owner, "track", track, 6, rolled_back_context) + .await, + Err(ServiceError::Invalid) + )); + let reservation_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sync_operation WHERE user_id=? AND operation_id=?", + ) + .bind(owner.to_string()) + .bind(rolled_back_context.operation_id.to_string()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(reservation_count, 0); + state + .services + .set_rating_with_context(owner, "track", track, 4, rolled_back_context) + .await + .unwrap(); +} + #[tokio::test] async fn embedded_web_client_serves_shell_without_shadowing_the_api() { let (_temp, config, state) = test_app().await; diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index fa5932c..3e405ac 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -73,6 +73,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const resumePosition = useRef(0); const resumeTrack = useRef(null); const autoplay = useRef(false); + const suppressPausePersistence = useRef(false); const saveChain = useRef>(Promise.resolve()); const current = queue[index] ?? null; @@ -123,6 +124,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // Stop on the last track rather than stepping past it: an out-of-range // index empties `current` and the player bar vanishes mid-listen. const onEnd = () => { + localMutation.current = true; setIndex((value) => { const next = Math.min(value + 1, Math.max(queueLength.current - 1, 0)); autoplay.current = next !== value; @@ -132,6 +134,10 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const onPlay = () => setPlaying(true); const onPause = () => { setPlaying(false); + if (suppressPausePersistence.current) { + suppressPausePersistence.current = false; + return; + } if (!hydrated) return; const songs = queueRef.current; const selected = songs[indexRef.current] ?? null; @@ -166,7 +172,12 @@ export function PlayerProvider({ children }: { children: ReactNode }) { positionRef.current = resumeSeconds; setPosition(resumeSeconds); setDuration(0); - if (!element || !current) return; + if (!element) return; + if (!element.paused) suppressPausePersistence.current = true; + element.pause(); + element.removeAttribute("src"); + element.load(); + if (!current) return; let cancelled = false; submitted.current = null; const shouldAutoplay = autoplay.current; @@ -224,7 +235,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { - if (!hydrated) return; + if (!hydrated || !localMutation.current) return; const timeout = window.setTimeout(() => { persistQueue( queue, @@ -247,6 +258,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, [current]); const next = useCallback(() => { + localMutation.current = true; setIndex((value) => { const next = Math.min(value + 1, Math.max(queueLength.current - 1, 0)); autoplay.current = next !== value; @@ -255,6 +267,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, []); const previous = useCallback(() => { + localMutation.current = true; setIndex((value) => { const previous = Math.max(value - 1, 0); autoplay.current = previous !== value; @@ -296,7 +309,10 @@ export function PlayerProvider({ children }: { children: ReactNode }) { next, previous, seek: (seconds: number) => { - if (audio.current) audio.current.currentTime = seconds; + if (audio.current) { + localMutation.current = true; + audio.current.currentTime = seconds; + } }, }), [ From 5b5d53e8044f9a3be8662648bb7cdf555de9a43f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 15:52:57 +0200 Subject: [PATCH 10/17] fix(sync-v2): harden operation and queue writes Signed-off-by: InstaZDLL --- src/database.rs | 3 ++- src/services.rs | 35 +++++++++++++++++++++-------------- src/sync.rs | 4 +++- tests/v2_foundations.rs | 33 +++++++++++++++++++++++++++++++-- webapp/src/player.tsx | 15 +++++++++++++-- 5 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/database.rs b/src/database.rs index 4bfad99..5863330 100644 --- a/src/database.rs +++ b/src/database.rs @@ -353,6 +353,7 @@ impl Database { #[allow(clippy::too_many_arguments)] pub(crate) async fn reserve_sync_operation( &self, + _writer_guard: &OwnedMutexGuard<()>, connection: &mut SqliteConnection, user_id: Uuid, operation_id: Uuid, @@ -393,7 +394,7 @@ impl Database { let row = sqlx::query( "SELECT so.result_entity_id, so.event_cursor, so.intent_hash, se.entity_type \ - FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor \ + FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor AND se.user_id=so.user_id \ WHERE so.user_id=? AND so.operation_id=? AND so.applied_at IS NOT NULL", ) .bind(user_id.to_string()) diff --git a/src/services.rs b/src/services.rs index aa0d266..2dd21ef 100644 --- a/src/services.rs +++ b/src/services.rs @@ -131,6 +131,9 @@ pub struct CatalogSnapshot { /// 500-item cap so both surfaces expose the same paging ceiling. pub const MAX_BROWSE_LIMIT: i64 = 500; const DEFAULT_BROWSE_LIMIT: i64 = 100; +/// Fits a UUID-only queue request below the server's 16 KiB body limit while +/// also bounding the work performed under the global SQLite writer gate. +pub const MAX_QUEUE_TRACKS: usize = 400; /// Offset/limit pair validated once, at the HTTP boundary, so the SQL layer can /// bind it without re-checking bounds. @@ -889,7 +892,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -983,7 +986,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -1064,7 +1067,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -1134,7 +1137,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -1303,7 +1306,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -1388,7 +1391,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -1549,13 +1552,16 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; validate_replay_type(&receipt, "queue")?; return Ok(()); } + if ids.len() > MAX_QUEUE_TRACKS { + return Err(ServiceError::Invalid); + } if position_ms < 0 { return Err(ServiceError::Invalid); } @@ -1569,13 +1575,14 @@ impl DomainServices { .bind(user_id.to_string()) .execute(&mut *tx) .await?; - for (position, id) in ids.iter().enumerate() { + if !ids.is_empty() { + let ids_json = serde_json::to_string(ids).map_err(|_| ServiceError::Invalid)?; sqlx::query( - "INSERT INTO play_queue_track (user_id, track_id, position) VALUES (?, ?, ?)", + "INSERT INTO play_queue_track (user_id, track_id, position) \ + SELECT ?, value, CAST(key AS INTEGER) FROM json_each(?)", ) .bind(user_id.to_string()) - .bind(id.to_string()) - .bind(position as i64) + .bind(ids_json) .execute(&mut *tx) .await?; } @@ -1731,7 +1738,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -1869,7 +1876,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; @@ -1933,7 +1940,7 @@ impl DomainServices { let mut tx = self.db.pool().begin().await?; if let OperationClaim::Replayed(receipt) = self .sync - .claim_operation(&mut tx, user_id, context, intent) + .claim_operation(&_writer, &mut tx, user_id, context, intent) .await? { tx.rollback().await?; diff --git a/src/sync.rs b/src/sync.rs index b91ccda..8293f9b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -6,7 +6,7 @@ use serde::Serialize; use serde_json::Value; use sqlx::{Row, SqliteConnection}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, OwnedMutexGuard}; use utoipa::ToSchema; use uuid::Uuid; @@ -122,6 +122,7 @@ impl SyncService { pub(crate) async fn claim_operation( &self, + writer_guard: &OwnedMutexGuard<()>, connection: &mut SqliteConnection, user_id: Uuid, context: MutationContext, @@ -130,6 +131,7 @@ impl SyncService { let reservation = self .db .reserve_sync_operation( + writer_guard, connection, user_id, context.operation_id, diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index fee44e9..da662e2 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -14,7 +14,7 @@ use waveflow_server::{ catalog::{ApplyOutcome, CatalogTrackInput, LibraryRecord}, database::{AccountRole, LibraryRole, LibraryVisibility}, security, - services::ServiceError, + services::{ServiceError, MAX_QUEUE_TRACKS}, sync::{MutationContext, SyncError, MAX_SYNC_LIMIT}, Config, }; @@ -3757,6 +3757,10 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { .delete_playlist(owner, playlist.id) .await .unwrap(); + let missing_playlist_context = MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: None, + }; assert!(matches!( state .services @@ -3768,6 +3772,22 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { None, &[], &[], + missing_playlist_context, + ) + .await, + Err(ServiceError::NotFound) + )); + assert!(matches!( + state + .services + .update_playlist_with_context( + owner, + playlist.id, + Some("Divergent replay after deletion"), + None, + None, + &[], + &[], playlist_context, ) .await, @@ -3818,7 +3838,7 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { assert!(matches!( state .services - .set_rating_with_context(owner, "track", track, 6, invalid_replay_context) + .set_rating_with_context(owner, "track", track, 4, invalid_replay_context) .await, Err(ServiceError::Conflict) )); @@ -3848,6 +3868,15 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { .set_rating_with_context(owner, "track", track, 4, rolled_back_context) .await .unwrap(); + + let oversized_queue = vec![track; MAX_QUEUE_TRACKS + 1]; + assert!(matches!( + state + .services + .save_queue(owner, &oversized_queue, Some(track), 0, Some("limit-test")) + .await, + Err(ServiceError::Invalid) + )); } #[tokio::test] diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index 3e405ac..6533b03 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -309,9 +309,20 @@ export function PlayerProvider({ children }: { children: ReactNode }) { next, previous, seek: (seconds: number) => { - if (audio.current) { + const element = audio.current; + if (element) { localMutation.current = true; - audio.current.currentTime = seconds; + element.currentTime = seconds; + positionRef.current = seconds; + const songs = queueRef.current; + const selected = songs[indexRef.current] ?? null; + // Queue the seek itself: it must not depend on a later pause event, + // which may never arrive before the page is closed. + persistQueue( + songs, + selected?.id ?? null, + Math.round(positionRef.current * 1000), + ); } }, }), From 552ea92b4ecd77f66c0f6fc82b7a1863c8baee50 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 16:37:01 +0200 Subject: [PATCH 11/17] fix(user-data): preserve duplicate queue tracks Signed-off-by: InstaZDLL --- .../20260809150000_queue_duplicate_tracks.sql | 12 +++ src/services.rs | 46 ++++++++-- tests/v2_foundations.rs | 92 +++++++++++++++++++ webapp/src/player.tsx | 26 ++++-- 4 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 migrations-v2/20260809150000_queue_duplicate_tracks.sql diff --git a/migrations-v2/20260809150000_queue_duplicate_tracks.sql b/migrations-v2/20260809150000_queue_duplicate_tracks.sql new file mode 100644 index 0000000..3d07935 --- /dev/null +++ b/migrations-v2/20260809150000_queue_duplicate_tracks.sql @@ -0,0 +1,12 @@ +CREATE TABLE play_queue_track_by_position ( + user_id TEXT NOT NULL REFERENCES play_queue(user_id) ON DELETE CASCADE, + track_id TEXT NOT NULL REFERENCES track(id) ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position >= 0), + PRIMARY KEY (user_id, position) +) STRICT; + +INSERT INTO play_queue_track_by_position (user_id, track_id, position) +SELECT user_id, track_id, position FROM play_queue_track; + +DROP TABLE play_queue_track; +ALTER TABLE play_queue_track_by_position RENAME TO play_queue_track; diff --git a/src/services.rs b/src/services.rs index 2dd21ef..3e50647 100644 --- a/src/services.rs +++ b/src/services.rs @@ -730,6 +730,22 @@ impl DomainServices { connection: &mut SqliteConnection, user_id: Uuid, ids: &[Uuid], + ) -> Result, ServiceError> { + let songs = self + .songs_by_ids_lenient_on(connection, user_id, ids) + .await?; + if songs.len() == ids.len() { + Ok(songs) + } else { + Err(ServiceError::NotFound) + } + } + + async fn songs_by_ids_lenient_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ids: &[Uuid], ) -> Result, ServiceError> { if ids.is_empty() { return Ok(Vec::new()); @@ -750,9 +766,10 @@ impl DomainServices { .into_iter() .map(|song| (song.id, song)) .collect::>(); - ids.iter() - .map(|id| available.get(id).cloned().ok_or(ServiceError::NotFound)) - .collect() + Ok(ids + .iter() + .filter_map(|id| available.get(id).cloned()) + .collect()) } pub async fn artwork_for_user( @@ -858,7 +875,8 @@ impl DomainServices { .into_iter() .map(parse_uuid) .collect::, _>>()?; - self.songs_by_ids_on(connection, user_id, &ids).await + self.songs_by_ids_lenient_on(connection, user_id, &ids) + .await } pub async fn create_playlist( @@ -1641,7 +1659,9 @@ impl DomainServices { position_ms: row.try_get("position_ms")?, changed_by: row.try_get("changed_by")?, updated_at: row.try_get("updated_at")?, - songs: self.songs_by_ids_on(connection, user_id, &ids).await?, + songs: self + .songs_by_ids_lenient_on(connection, user_id, &ids) + .await?, })) } @@ -1672,11 +1692,19 @@ impl DomainServices { track_ids.push(parse_uuid(track_row.try_get("track_id")?)?); } let songs = self - .songs_by_ids_on(connection, user_id, &track_ids) - .await?; + .songs_by_ids_lenient_on(connection, user_id, &track_ids) + .await? + .into_iter() + .map(|song| (song.id, song)) + .collect::>(); let mut songs_by_share = HashMap::>::new(); - for (share_id, song) in track_owners.into_iter().zip(songs) { - songs_by_share.entry(share_id).or_default().push(song); + for (share_id, track_id) in track_owners.into_iter().zip(track_ids) { + if let Some(song) = songs.get(&track_id) { + songs_by_share + .entry(share_id) + .or_default() + .push(song.clone()); + } } let mut shares = Vec::with_capacity(rows.len()); diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index da662e2..f5edcf1 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -3825,6 +3825,17 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { .await, Err(ServiceError::Conflict) )); + let fresh_inaccessible_context = MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: None, + }; + assert!(matches!( + state + .services + .set_rating_with_context(listener, "track", track, 5, fresh_inaccessible_context) + .await, + Err(ServiceError::NotFound) + )); let invalid_replay_context = MutationContext { operation_id: Uuid::new_v4(), @@ -3877,6 +3888,87 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { .await, Err(ServiceError::Invalid) )); + + state + .services + .save_queue( + owner, + &[track, track], + Some(track), + 0, + Some("duplicate-test"), + ) + .await + .unwrap(); + let duplicate_queue = state.services.queue(owner).await.unwrap().unwrap(); + assert_eq!( + duplicate_queue + .songs + .iter() + .map(|song| song.id) + .collect::>(), + vec![track, track] + ); + let positions = sqlx::query_scalar::<_, i64>( + "SELECT position FROM play_queue_track WHERE user_id=? ORDER BY position", + ) + .bind(owner.to_string()) + .fetch_all(state.db.pool()) + .await + .unwrap(); + assert_eq!(positions, vec![0, 1]); + + let aggregate_playlist = state + .services + .create_playlist(owner, "Unavailable aggregate", &[track]) + .await + .unwrap(); + let aggregate_share = state + .services + .create_share(owner, &[track], Some("Unavailable aggregate"), None) + .await + .unwrap(); + let empty_scan = state + .db + .create_scan_job(library, Some(owner), "manual") + .await + .unwrap(); + state.db.start_scan_job(empty_scan, 1).await.unwrap(); + assert_eq!( + state + .db + .mark_unseen_unavailable(library, empty_scan) + .await + .unwrap(), + 1 + ); + state.db.finish_scan_job(empty_scan, 1).await.unwrap(); + assert!(state + .services + .playlist(owner, aggregate_playlist.id) + .await + .unwrap() + .songs + .is_empty()); + assert!(state + .services + .queue(owner) + .await + .unwrap() + .unwrap() + .songs + .is_empty()); + assert!(state + .services + .shares(owner) + .await + .unwrap() + .into_iter() + .find(|share| share.id == aggregate_share.id) + .unwrap() + .songs + .is_empty()); + state.services.sync_snapshot(owner, 100).await.unwrap(); } #[tokio::test] diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index 6533b03..73d438d 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -73,7 +73,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const resumePosition = useRef(0); const resumeTrack = useRef(null); const autoplay = useRef(false); - const suppressPausePersistence = useRef(false); + const suppressedPauseEvents = useRef(0); const saveChain = useRef>(Promise.resolve()); const current = queue[index] ?? null; @@ -131,11 +131,14 @@ export function PlayerProvider({ children }: { children: ReactNode }) { return next; }); }; - const onPlay = () => setPlaying(true); + const onPlay = () => { + suppressedPauseEvents.current = 0; + setPlaying(true); + }; const onPause = () => { setPlaying(false); - if (suppressPausePersistence.current) { - suppressPausePersistence.current = false; + if (suppressedPauseEvents.current > 0) { + suppressedPauseEvents.current -= 1; return; } if (!hydrated) return; @@ -173,7 +176,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { setPosition(resumeSeconds); setDuration(0); if (!element) return; - if (!element.paused) suppressPausePersistence.current = true; + suppressedPauseEvents.current = 2; element.pause(); element.removeAttribute("src"); element.load(); @@ -297,9 +300,16 @@ export function PlayerProvider({ children }: { children: ReactNode }) { }, clear: () => { localMutation.current = true; - audio.current?.pause(); - audio.current?.removeAttribute("src"); - audio.current?.load(); + const element = audio.current; + queueRef.current = []; + indexRef.current = 0; + positionRef.current = 0; + if (element) { + suppressedPauseEvents.current = 2; + element.pause(); + element.removeAttribute("src"); + element.load(); + } autoplay.current = false; setQueue([]); setIndex(0); From 4d69380e8ff1e930fbdb98009eb4ee9af8f5db2a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 16:47:15 +0200 Subject: [PATCH 12/17] fix(playlists): retain unavailable tracks on update Signed-off-by: InstaZDLL --- src/services.rs | 22 +++++++++++++++++----- tests/v2_foundations.rs | 25 ++++++++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/services.rs b/src/services.rs index 3e50647..8828a01 100644 --- a/src/services.rs +++ b/src/services.rs @@ -864,7 +864,20 @@ impl DomainServices { user_id: Uuid, playlist_id: Uuid, ) -> Result, ServiceError> { - let ids = sqlx::query_scalar::<_, String>( + let ids = self + .playlist_track_ids_on(connection, user_id, playlist_id) + .await?; + self.songs_by_ids_lenient_on(connection, user_id, &ids) + .await + } + + async fn playlist_track_ids_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + playlist_id: Uuid, + ) -> Result, ServiceError> { + sqlx::query_scalar::<_, String>( "SELECT pt.track_id FROM playlist_track pt JOIN playlist p ON p.id=pt.playlist_id \ WHERE p.id=? AND p.owner_user_id=? ORDER BY pt.position", ) @@ -874,9 +887,8 @@ impl DomainServices { .await? .into_iter() .map(parse_uuid) - .collect::, _>>()?; - self.songs_by_ids_lenient_on(connection, user_id, &ids) - .await + .collect::, _>>() + .map_err(Into::into) } pub async fn create_playlist( @@ -1017,7 +1029,7 @@ impl DomainServices { validate_name(name)?; } self.songs_by_ids_on(&mut tx, user_id, add).await?; - let mut ids = current.songs.iter().map(|song| song.id).collect::>(); + let mut ids = self.playlist_track_ids_on(&mut tx, user_id, id).await?; for index in removes { if index >= ids.len() { return Err(ServiceError::Invalid); diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index f5edcf1..baebefc 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -3943,13 +3943,28 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { 1 ); state.db.finish_scan_job(empty_scan, 1).await.unwrap(); - assert!(state + let updated_playlist = state .services - .playlist(owner, aggregate_playlist.id) + .update_playlist( + owner, + aggregate_playlist.id, + Some("Unavailable aggregate renamed"), + None, + None, + &[], + &[], + ) .await - .unwrap() - .songs - .is_empty()); + .unwrap(); + assert_eq!(updated_playlist.name, "Unavailable aggregate renamed"); + assert!(updated_playlist.songs.is_empty()); + let persisted_playlist_tracks: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM playlist_track WHERE playlist_id=?") + .bind(aggregate_playlist.id.to_string()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(persisted_playlist_tracks, 1); assert!(state .services .queue(owner) From 765a64e0daa946a8014e468c10dc1ca425ff960c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 17:01:14 +0200 Subject: [PATCH 13/17] fix(user-data): harden share and history handling Signed-off-by: InstaZDLL --- README.md | 2 +- docs/rfcs/RFC-002-waveflow-server-v2.md | 2 +- docs/rfcs/RFC-003-waveflow-sync-v2.md | 2 +- .../20260809160000_share_hash_only.sql | 2 + src/http.rs | 15 +++--- src/services.rs | 54 ++++++++++--------- src/subsonic.rs | 7 ++- tests/v2_foundations.rs | 34 +++++++++--- webapp/src/api.ts | 2 +- webapp/src/pages.tsx | 21 ++++++-- 10 files changed, 93 insertions(+), 48 deletions(-) create mode 100644 migrations-v2/20260809160000_share_hash_only.sql diff --git a/README.md b/README.md index 578678a..1ac4ff4 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The server listens on `127.0.0.1:4533` by default and exposes: For browser-hosted clients such as Feishin, list every trusted origin explicitly, for example `WAVEFLOW_ALLOWED_ORIGINS=http://127.0.0.1:9180,https://music.example.com`. Wildcards are rejected so credential-bearing Subsonic requests cannot be opened to arbitrary sites. -Set `WAVEFLOW_PUBLIC_URL=https://music.example.com` behind the reverse proxy so `createShare` returns absolute, externally usable URLs. When it is omitted, share URLs remain relative to the server origin. +Set `WAVEFLOW_PUBLIC_URL=https://music.example.com` behind the reverse proxy so `createShare` returns an absolute, externally usable URL once at creation. Only the token hash is persisted, so later share reads and sync snapshots omit the URL. When the setting is absent, the creation response uses a URL relative to the server origin. Create or restore a coherent database/key bundle: diff --git a/docs/rfcs/RFC-002-waveflow-server-v2.md b/docs/rfcs/RFC-002-waveflow-server-v2.md index 6be420a..6ce523e 100644 --- a/docs/rfcs/RFC-002-waveflow-server-v2.md +++ b/docs/rfcs/RFC-002-waveflow-server-v2.md @@ -62,7 +62,7 @@ Mutation methods whose Subsonic result is empty (`updatePlaylist`, `deletePlayli Cross-origin access is disabled unless the operator supplies an exact comma-separated allow-list through `WAVEFLOW_ALLOWED_ORIGINS`. Allowed origins may use GET, form POST and OPTIONS and may read the byte-range response headers needed for web playback; wildcard origins are not accepted. -Original downloads and streams use repository authorization and the M2 path guard. They forward valid byte ranges to originals and completed cache entries, including 206/416 response semantics; live transcodes still require temporal `timeOffset` seeking. Requested MP3/Opus transcodes use the same FFmpeg/cache service as `/api/v2`. Without an explicit output format, `maxBitRate` is a ceiling: WaveFlow serves the original when its known bitrate is at or below the ceiling and otherwise transcodes to MP3; unknown source bitrate is conservatively transcoded. `getCoverArt` accepts an authorized track, album, artist or content hash. Public share URLs contain a high-entropy token; its lookup hash and encrypted recoverable form are stored separately so `getShares` can reproduce the URL without storing the token in plaintext. `WAVEFLOW_PUBLIC_URL` supplies the external HTTP(S) origin; otherwise relative URLs are returned. The public metadata response supplies token-scoped per-track stream URLs with the same Range/transcode service, and a share cannot stream a track outside its persisted membership. Share tokens are redacted from request trace paths. +Original downloads and streams use repository authorization and the M2 path guard. They forward valid byte ranges to originals and completed cache entries, including 206/416 response semantics; live transcodes still require temporal `timeOffset` seeking. Requested MP3/Opus transcodes use the same FFmpeg/cache service as `/api/v2`. Without an explicit output format, `maxBitRate` is a ceiling: WaveFlow serves the original when its known bitrate is at or below the ceiling and otherwise transcodes to MP3; unknown source bitrate is conservatively transcoded. `getCoverArt` accepts an authorized track, album, artist or content hash. Public share URLs contain a high-entropy bearer token; only its lookup hash is persisted. The URL is returned once by the successful creation response and is omitted from later share reads, updates and synchronization snapshots. `WAVEFLOW_PUBLIC_URL` supplies the external HTTP(S) origin; otherwise the creation response uses a relative URL. The public metadata response supplies token-scoped per-track stream URLs with the same Range/transcode service, and a share cannot stream a track outside its persisted membership. Share tokens are redacted from request trace paths. ### Reconciliation diff --git a/docs/rfcs/RFC-003-waveflow-sync-v2.md b/docs/rfcs/RFC-003-waveflow-sync-v2.md index b2e89ac..ca05d21 100644 --- a/docs/rfcs/RFC-003-waveflow-sync-v2.md +++ b/docs/rfcs/RFC-003-waveflow-sync-v2.md @@ -65,7 +65,7 @@ Each change has `cursor`, `event_id`, `operation_id`, optional | `rating` | `upsert`, `delete` | `entity_type`, `entity_id`, `rating` (0 means clear) | | `scrobble` | `upsert`, `append` | `track_id`, `submission`, `played_at` | | `queue` | `upsert` | ordered `track_ids`, current track, `position_ms`, client | -| `share` | `upsert`, `delete` | id and the changed share fields | +| `share` | `upsert`, `delete` | id and the changed non-secret share fields; bearer token and URL are never synchronized | Unknown entity types, actions and payload fields must be ignored and retained only if a client needs to relay diagnostic data. A client that cannot apply a diff --git a/migrations-v2/20260809160000_share_hash_only.sql b/migrations-v2/20260809160000_share_hash_only.sql new file mode 100644 index 0000000..fb63436 --- /dev/null +++ b/migrations-v2/20260809160000_share_hash_only.sql @@ -0,0 +1,2 @@ +ALTER TABLE share DROP COLUMN token_nonce; +ALTER TABLE share DROP COLUMN token_ciphertext; diff --git a/src/http.rs b/src/http.rs index 9ce0e81..067d27f 100644 --- a/src/http.rs +++ b/src/http.rs @@ -155,7 +155,8 @@ pub struct UpdateShareRequest { #[derive(Debug, Serialize, ToSchema)] pub struct ShareResponse { pub id: Uuid, - pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, pub description: Option, pub expires_at: Option, pub created_at: i64, @@ -1473,11 +1474,13 @@ pub async fn delete_share( } fn share_response(state: &AppState, share: crate::services::ShareItem) -> ShareResponse { - let path = format!("/share/{}", share.url_token); - let url = state - .public_url - .as_ref() - .map_or_else(|| path.clone(), |base| format!("{base}{path}")); + let url = share.url_token.map(|token| { + let path = format!("/share/{token}"); + state + .public_url + .as_ref() + .map_or_else(|| path.clone(), |base| format!("{base}{path}")) + }); ShareResponse { id: share.id, url, diff --git a/src/services.rs b/src/services.rs index 8828a01..39c32c9 100644 --- a/src/services.rs +++ b/src/services.rs @@ -131,6 +131,7 @@ pub struct CatalogSnapshot { /// 500-item cap so both surfaces expose the same paging ceiling. pub const MAX_BROWSE_LIMIT: i64 = 500; const DEFAULT_BROWSE_LIMIT: i64 = 100; +pub const MAX_HISTORY_LIMIT: i64 = 500; /// Fits a UUID-only queue request below the server's 16 KiB body limit while /// also bounding the work performed under the global SQLite writer gate. pub const MAX_QUEUE_TRACKS: usize = 400; @@ -241,7 +242,9 @@ pub struct HistoryItem { pub struct ShareItem { pub id: Uuid, pub owner_id: Uuid, - pub url_token: String, + /// Present only in the result of a newly-created share. Persistent reads + /// deliberately cannot recover the bearer token from its lookup hash. + pub url_token: Option, pub description: Option, pub expires_at: Option, pub created_at: i64, @@ -1517,6 +1520,9 @@ impl DomainServices { user_id: Uuid, limit: i64, ) -> Result, ServiceError> { + if !(0..=MAX_HISTORY_LIMIT).contains(&limit) { + return Err(ServiceError::Invalid); + } sqlx::query( "SELECT p.track_id, p.submission, p.played_at FROM play_event p \ JOIN track t ON t.id=p.track_id JOIN library_member m ON m.library_id=t.library_id \ @@ -1687,7 +1693,7 @@ impl DomainServices { connection: &mut SqliteConnection, user_id: Uuid, ) -> Result, ServiceError> { - let rows = sqlx::query("SELECT id, token_nonce, token_ciphertext, description, expires_at, created_at, visit_count FROM share WHERE owner_user_id=? ORDER BY created_at DESC") + let rows = sqlx::query("SELECT id, description, expires_at, created_at, visit_count FROM share WHERE owner_user_id=? ORDER BY created_at DESC") .bind(user_id.to_string()).fetch_all(&mut *connection).await?; let track_rows = sqlx::query( "SELECT st.share_id, st.track_id FROM share_track st \ @@ -1722,14 +1728,10 @@ impl DomainServices { let mut shares = Vec::with_capacity(rows.len()); for row in rows { let id = parse_uuid(row.try_get("id")?)?; - let nonce: Vec = row.try_get("token_nonce")?; - let ciphertext: Vec = row.try_get("token_ciphertext")?; - let token = String::from_utf8(self.secret_box.decrypt(&nonce, &ciphertext)?) - .map_err(|_| ServiceError::Invalid)?; shares.push(ShareItem { id, owner_id: user_id, - url_token: token, + url_token: None, description: row.try_get("description")?, expires_at: row.try_get("expires_at")?, created_at: row.try_get("created_at")?, @@ -1795,14 +1797,13 @@ impl DomainServices { if ids.is_empty() { return Err(ServiceError::Invalid); } - self.songs_by_ids_on(&mut tx, user_id, ids).await?; + let songs = self.songs_by_ids_on(&mut tx, user_id, ids).await?; let token = security::generate_token("wfs_"); let token_hash = security::token_hash(&token); - let encrypted = self.secret_box.encrypt(token.as_bytes())?; let id = Uuid::new_v4(); let now = now_ms(); - sqlx::query("INSERT INTO share (id, owner_user_id, token_hash, token_nonce, token_ciphertext, description, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") - .bind(id.to_string()).bind(user_id.to_string()).bind(token_hash.as_slice()).bind(encrypted.nonce.as_slice()).bind(encrypted.ciphertext).bind(description).bind(expires_at).bind(now).bind(now).execute(&mut *tx).await?; + sqlx::query("INSERT INTO share (id, owner_user_id, token_hash, description, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(id.to_string()).bind(user_id.to_string()).bind(token_hash.as_slice()).bind(description).bind(expires_at).bind(now).bind(now).execute(&mut *tx).await?; for (position, track) in ids.iter().enumerate() { sqlx::query("INSERT INTO share_track (share_id, track_id, position) VALUES (?, ?, ?)") .bind(id.to_string()) @@ -1832,16 +1833,21 @@ impl DomainServices { tx.commit().await?; drop(_writer); self.sync.publish(user_id, receipt); - self.shares(user_id) - .await? - .into_iter() - .find(|share| share.id == id) - .ok_or(ServiceError::NotFound) + Ok(ShareItem { + id, + owner_id: user_id, + url_token: Some(token), + description: description.map(str::to_owned), + expires_at, + created_at: now, + visit_count: 0, + songs, + }) } pub async fn public_share(&self, token: &str) -> Result { let hash = security::token_hash(token); - let row = sqlx::query("SELECT id, owner_user_id, token_nonce, token_ciphertext, description, expires_at, created_at, visit_count FROM share WHERE token_hash=? AND (expires_at IS NULL OR expires_at>?)") + let row = sqlx::query("SELECT id, owner_user_id, description, expires_at, created_at, visit_count FROM share WHERE token_hash=? AND (expires_at IS NULL OR expires_at>?)") .bind(hash.as_slice()).bind(now_ms()).fetch_optional(self.db.pool()).await?.ok_or(ServiceError::NotFound)?; let id = parse_uuid(row.try_get("id")?)?; let owner = parse_uuid(row.try_get("owner_user_id")?)?; @@ -1854,13 +1860,6 @@ impl DomainServices { .into_iter() .map(parse_uuid) .collect::, _>>()?; - let nonce: Vec = row.try_get("token_nonce")?; - let ciphertext: Vec = row.try_get("token_ciphertext")?; - let stored = String::from_utf8(self.secret_box.decrypt(&nonce, &ciphertext)?) - .map_err(|_| ServiceError::Invalid)?; - if !security::constant_time_bytes_eq(stored.as_bytes(), token.as_bytes()) { - return Err(ServiceError::NotFound); - } let _writer = self.db.writer_guard().await; sqlx::query("UPDATE share SET visit_count=visit_count+1, last_visited_at=? WHERE id=?") .bind(now_ms()) @@ -1870,7 +1869,7 @@ impl DomainServices { Ok(ShareItem { id, owner_id: owner, - url_token: stored, + url_token: None, description: row.try_get("description")?, expires_at: row.try_get("expires_at")?, created_at: row.try_get("created_at")?, @@ -2131,7 +2130,10 @@ impl DomainServices { return Err(ServiceError::Invalid); } let placeholder = security::generate_token("web-disabled-"); - let password_hash = security::hash_password(&placeholder)?; + let password_hash = + tokio::task::spawn_blocking(move || security::hash_password(&placeholder)) + .await + .map_err(|_| ServiceError::Unavailable)??; let encrypted = self.secret_box.encrypt(password.as_bytes())?; let api_key = security::generate_token("wfsk_"); let api_key_hash = security::token_hash(&api_key); diff --git a/src/subsonic.rs b/src/subsonic.rs index 80b4527..614d777 100644 --- a/src/subsonic.rs +++ b/src/subsonic.rs @@ -1509,10 +1509,13 @@ fn user_node(user: &crate::services::UserItem) -> Node { } fn share_node(share: &crate::services::ShareItem, public_url: Option<&str>) -> Node { - let path = format!("/share/{}", share.url_token); + let url = share.url_token.as_ref().map(|token| { + let path = format!("/share/{token}"); + external_url(public_url, &path) + }); Node::new("share") .attr("id", share.id.to_string()) - .attr("url", external_url(public_url, &path)) + .maybe_attr("url", url) .maybe_attr("description", share.description.clone()) .maybe_attr("expires", share.expires_at.map(iso_time)) .attr("username", "") diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index baebefc..c5dc0d8 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -14,7 +14,7 @@ use waveflow_server::{ catalog::{ApplyOutcome, CatalogTrackInput, LibraryRecord}, database::{AccountRole, LibraryRole, LibraryVisibility}, security, - services::{ServiceError, MAX_QUEUE_TRACKS}, + services::{ServiceError, MAX_HISTORY_LIMIT, MAX_QUEUE_TRACKS}, sync::{MutationContext, SyncError, MAX_SYNC_LIMIT}, Config, }; @@ -1887,10 +1887,11 @@ async fn subsonic_xml_json_auth_catalog_and_user_data_are_compatible() { .await .unwrap(); assert_eq!(foreign_public_stream.status(), StatusCode::NOT_FOUND); - assert_eq!( - subsonic_json(&router, "getShares", api_key, "").await["subsonic-response"]["status"], - "ok" - ); + let listed_shares = subsonic_json(&router, "getShares", api_key, "").await; + assert_eq!(listed_shares["subsonic-response"]["status"], "ok"); + assert!(listed_shares["subsonic-response"]["shares"]["share"][0] + .get("url") + .is_none()); assert_eq!( subsonic_json( &router, @@ -3011,7 +3012,7 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { } state.db.finish_scan_job(scan_id, 0).await.unwrap(); - let router = waveflow_server::app(&config, state); + let router = waveflow_server::app(&config, state.clone()); let owner_token = login_token(&router, "data-owner", password).await; let intruder_token = login_token(&router, "data-intruder", password).await; @@ -3182,6 +3183,12 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { ) .await; assert_eq!(scrobbled.status(), StatusCode::NO_CONTENT); + for invalid_limit in [-1, MAX_HISTORY_LIMIT + 1] { + assert!(matches!( + state.services.history(owner, invalid_limit).await, + Err(ServiceError::Invalid) + )); + } // The queue survives a write/read round-trip. let saved = send( @@ -3214,9 +3221,15 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { ) .await; assert_eq!(share.status(), StatusCode::CREATED); + assert!(json_body(share).await["url"].as_str().is_some()); } let shares = send("GET", "/api/v2/shares".into(), owner_token.clone(), None).await; let shares = json_body(shares).await; + assert!(shares + .as_array() + .unwrap() + .iter() + .all(|share| share.get("url").is_none())); let song_orders = shares .as_array() .unwrap() @@ -3232,6 +3245,14 @@ async fn native_user_data_endpoints_round_trip_and_isolate_tenants() { .collect::>(); assert!(song_orders.contains(&vec![second.to_string(), first.to_string()])); assert!(song_orders.contains(&vec![first.to_string()])); + let share_columns = + sqlx::query_scalar::<_, String>("SELECT name FROM pragma_table_info('share')") + .fetch_all(state.db.pool()) + .await + .unwrap(); + assert!(share_columns.contains(&"token_hash".to_owned())); + assert!(!share_columns.contains(&"token_nonce".to_owned())); + assert!(!share_columns.contains(&"token_ciphertext".to_owned())); // A foreign tenant can neither read nor mutate any of it. let foreign_playlists = send( @@ -3622,6 +3643,7 @@ async fn sync_journal_is_idempotent_cursor_based_and_tenant_isolated() { assert_eq!(snapshot["history"].as_array().unwrap().len(), 1); assert_eq!(snapshot["playlists"].as_array().unwrap().len(), 1); assert_eq!(snapshot["shares"].as_array().unwrap().len(), 1); + assert!(snapshot["shares"][0].get("url").is_none()); let cursor = snapshot["cursor"].as_i64().unwrap(); let ack = router diff --git a/webapp/src/api.ts b/webapp/src/api.ts index a87c2a4..beb52b9 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -96,7 +96,7 @@ export type Queue = { export type Share = { id: string; - url: string; + url?: string; description: string | null; expires_at: number | null; created_at: number; diff --git a/webapp/src/pages.tsx b/webapp/src/pages.tsx index a83991d..8caa5e8 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -31,6 +31,7 @@ import { login, type Playlist, type SearchResult, + type Share, type Song, safeInternalPath, search, @@ -432,6 +433,7 @@ export function QueuePage() { export function SharesPage() { const player = usePlayer(); const [description, setDescription] = useState(""); + const [createdShare, setCreatedShare] = useState(null); const [revision, setRevision] = useState(0); const [mutationError, setMutationError] = useState(null); const { value, error } = useAsync(listShares, [revision]); @@ -441,10 +443,11 @@ export function SharesPage() { event.preventDefault(); setMutationError(null); try { - await createShare( + const share = await createShare( player.queue.map((song) => song.id), description, ); + setCreatedShare(share); setDescription(""); setRevision((value) => value + 1); } catch { @@ -484,15 +487,25 @@ export function SharesPage() {

    Add tracks to the queue before creating a link.

    ) : null} {mutationError ?

    {mutationError}

    : null} + {createdShare?.url ? ( +

    + This link is shown only once:{" "} + + {createdShare.url} + +

    + ) : null} {value.length ? (
      {value.map((share) => (
    • {share.description ?? "Music share"} - - {share.url} - + {share.url ? ( + + {share.url} + + ) : null}
      {share.track_ids.length} tracks