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" diff --git a/Cargo.lock b/Cargo.lock index 07998cb..bc11d9f 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" @@ -3412,6 +3510,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "tower", "tower-http 0.7.0", @@ -3616,6 +3715,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..d0d8292 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" @@ -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 e0d6ce7..f16de86 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,15 @@ 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; +- `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. @@ -47,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 at creation. An authenticated idempotent retry of that creation returns the same URL, but later share reads and sync snapshots omit it because only its hash is persisted. 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/M4-handoff.md b/docs/M4-handoff.md index 13f082f..4acaf6d 100644 --- a/docs/M4-handoff.md +++ b/docs/M4-handoff.md @@ -39,17 +39,28 @@ 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 /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é 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é @@ -104,8 +115,12 @@ 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, + 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. @@ -127,13 +142,12 @@ 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. -- 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. +- `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 + 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..3e85312 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. @@ -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 derived with keyed BLAKE3 from the instance key and immutable share UUID; only its lookup hash is persisted. The URL is returned by the successful creation response and authenticated idempotent replays of that operation, while later share reads, updates and synchronization snapshots omit it. `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 new file mode 100644 index 0000000..ca05d21 --- /dev/null +++ b/docs/rfcs/RFC-003-waveflow-sync-v2.md @@ -0,0 +1,105 @@ +# 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 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 +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 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 +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/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/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/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/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/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..5863330 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,7 +168,59 @@ 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") + .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() @@ -298,6 +350,70 @@ impl Database { Arc::clone(&self.writer).lock_owned().await } + #[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, + 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 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()) + .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, @@ -633,6 +749,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 2679b24..067d27f 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,19 @@ 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"; +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 { pub status: &'static str, @@ -43,6 +53,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 +139,31 @@ 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + 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,20 +208,128 @@ 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)) + .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)) @@ -199,9 +351,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 +425,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", 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, + 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 +518,93 @@ 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 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) +} + #[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 +629,139 @@ 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 = 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 = tokio::fs::canonicalize(&path) + .await + .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, @@ -422,6 +838,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, @@ -583,9 +1017,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 +1049,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 +1060,7 @@ pub async fn update_playlist( request.public, &request.add, &request.remove_indexes, + context, ) .await .map(Json) @@ -637,9 +1074,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 +1130,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,31 +1147,193 @@ 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(post, path = "/api/v2/scrobbles", tag = "user-data", request_body = ScrobbleRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[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), (status = 422, body = ErrorResponse)))] pub async fn create_scrobble( State(state): State, headers: HeaderMap, 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: 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 = 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) + .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 = 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( + 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 = 403, 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?; + require_admin(&actor)?; + 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 = 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) + .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 = 403, 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?; + require_admin(&actor)?; + 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 = 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) + .await + .map_err(service_error)?; Ok(StatusCode::NO_CONTENT) } @@ -778,23 +1379,329 @@ 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 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, + 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(sync_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?; + let snapshot = state + .services + .sync_snapshot(user.id, crate::sync::MAX_SYNC_LIMIT) + .await + .map_err(service_error)?; + let favorites = snapshot + .favorites + .into_iter() + .map(|(entity_type, entity_id, starred_at)| StarredEntry { + entity_type, + entity_id, + starred_at, + }) + .collect(); + let shares = snapshot + .shares + .into_iter() + .map(|share| share_response(&state, share)) + .collect(); + Ok(Json(SyncSnapshot { + cursor: snapshot.cursor, + playlists: snapshot.playlists, + favorites, + ratings: snapshot.ratings, + queue: snapshot.queue, + history: snapshot.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; + } + } + 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 { + Ok(SyncNoticeAction::Send(cursor)) => { + if send_sync_notice(&mut sender, cursor).await.is_err() { + break; + } + } + 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; + } + } + } +} + +#[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, +) -> 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 +1725,7 @@ impl IntoResponse for ApiError { "unauthorized", "Authentication failed", ), + Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden", "Request rejected"), Self::Validation => ( StatusCode::UNPROCESSABLE_ENTITY, "validation_error", @@ -834,6 +1742,118 @@ 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 = secure_cookies(state); + 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, secure: bool) -> String { + format!( + "{name}=; Path={}; SameSite=Strict; Max-Age=0{}{}", + if http_only { "/api/v2/web/auth" } else { "/" }, + if http_only { "; HttpOnly" } else { "" }, + if secure { "; Secure" } else { "" } + ) +} + +fn secure_cookies(state: &AppState) -> bool { + 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> { + 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)?; + 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); + } + 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) + .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,11 +1871,64 @@ 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 = + 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 + .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 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 } +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), + } +} + /// 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 @@ -865,6 +1938,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"); @@ -872,3 +1946,34 @@ fn service_error(error: crate::services::ServiceError) -> ApiError { } } } + +#[cfg(test)] +mod tests { + 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() { + 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 7ab5240..9edf094 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,15 +66,25 @@ 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, + http::get_track, http::list_albums, http::get_album, http::list_artists, @@ -86,10 +99,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 +127,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 +140,7 @@ pub struct AppState { http::ScanQueuedResponse, catalog::ScanJobRecord, catalog::TrackRecord, + catalog::LibraryAccess, services::AlbumItem, services::ArtistItem, services::SongItem, @@ -115,16 +150,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 +185,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 +215,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 +224,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 +295,18 @@ 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(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/security.rs b/src/security.rs index fb53f9c..3a40433 100644 --- a/src/security.rs +++ b/src/security.rs @@ -37,6 +37,7 @@ pub enum SecurityError { #[derive(Clone)] pub struct SecretBox { cipher: ChaCha20Poly1305, + key: [u8; INSTANCE_KEY_BYTES], } pub struct EncryptedSecret { @@ -63,15 +64,29 @@ impl SecretBox { } pub fn from_key_bytes(key: &[u8]) -> Result { - if key.len() != INSTANCE_KEY_BYTES { - return Err(SecurityError::InvalidInstanceKey); - } + let key: [u8; INSTANCE_KEY_BYTES] = key + .try_into() + .map_err(|_| SecurityError::InvalidInstanceKey)?; Ok(Self { - cipher: ChaCha20Poly1305::new_from_slice(key) + cipher: ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| SecurityError::InvalidInstanceKey)?, + key, }) } + /// Derives a stable, unforgeable bearer token without persisting any + /// reversible token material. The domain label prevents reuse for another + /// keyed purpose from producing the same output. + pub fn derive_share_token(&self, share_id: uuid::Uuid) -> String { + let mut hasher = blake3::Hasher::new_keyed(&self.key); + hasher.update(b"waveflow/share-token/v1\0"); + hasher.update(share_id.as_bytes()); + format!( + "wfs_{}", + URL_SAFE_NO_PAD.encode(hasher.finalize().as_bytes()) + ) + } + pub fn encrypt(&self, plaintext: &[u8]) -> Result { let mut nonce = [0u8; NONCE_BYTES]; OsRng.fill_bytes(&mut nonce); @@ -183,4 +198,24 @@ mod tests { b"subsonic-only-password" ); } + + #[test] + fn share_tokens_are_stable_and_bound_to_the_instance_and_share() { + let secret = SecretBox::from_key_bytes(&[7; 32]).unwrap(); + let other_secret = SecretBox::from_key_bytes(&[8; 32]).unwrap(); + let share = uuid::Uuid::new_v4(); + + assert_eq!( + secret.derive_share_token(share), + secret.derive_share_token(share) + ); + assert_ne!( + secret.derive_share_token(share), + secret.derive_share_token(uuid::Uuid::new_v4()) + ); + assert_ne!( + secret.derive_share_token(share), + other_secret.derive_share_token(share) + ); + } } diff --git a/src/services.rs b/src/services.rs index cf5740a..06f0853 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,6 +11,7 @@ use crate::{ authentication::now_ms, database::{AccountRecord, AccountRole, Database}, security::{self, EncryptedSecret, SecretBox}, + sync::{MutationContext, MutationIntent, MutationReceipt, OperationClaim, SyncService}, }; /// Tenant-filtered projections shared by the Subsonic facade and the native @@ -130,6 +131,12 @@ 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; +/// Applies the same request-size and writer-gate bound to public shares. +pub const MAX_SHARE_TRACKS: usize = MAX_QUEUE_TRACKS; /// Offset/limit pair validated once, at the HTTP boundary, so the SQL layer can /// bind it without re-checking bounds. @@ -218,11 +225,28 @@ 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, 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, @@ -230,7 +254,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 +264,29 @@ 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>, +} + +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, secret_box: Arc, + sync: SyncService, } #[derive(Debug, thiserror::Error)] @@ -256,15 +299,50 @@ pub enum ServiceError { Invalid, #[error("conflict")] Conflict, + #[error("service unavailable")] + Unavailable, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] 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::Conflict => Self::Conflict, + crate::sync::SyncError::Database(error) => Self::Database(error), + } + } +} + 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::Unavailable)??; + self.db + .bootstrap_admin(username, &password_hash, now_ms()) + .await? + .ok_or(ServiceError::Conflict) } pub async fn credential_by_username( @@ -619,19 +697,84 @@ 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> { + let songs = self + .songs_by_ids_lenient_on(connection, user_id, ids) + .await?; + if songs.len() == ids.len() { + Ok(songs) + } else { + Err(ServiceError::NotFound) } - Ok(songs) + } + + 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()); + } + 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::>(); + Ok(ids + .iter() + .filter_map(|id| available.get(id).cloned()) + .collect()) } pub async fn artwork_for_user( @@ -657,12 +800,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 { @@ -674,37 +826,74 @@ 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) } 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( + async fn playlist_songs_on( &self, + connection: &mut SqliteConnection, 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", ) .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 + .collect::, _>>() + .map_err(Into::into) } pub async fn create_playlist( @@ -713,17 +902,68 @@ impl DomainServices { name: &str, track_ids: &[Uuid], ) -> Result { - validate_name(name)?; - self.songs_by_ids(user_id, track_ids).await?; + 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 { + 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(&_writer, &mut tx, user_id, context, intent) + .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; + } + 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 (?, ?, ?, ?, ?)") .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?; + drop(_writer); + self.sync.publish(user_id, receipt); self.playlist(user_id, id).await } @@ -738,15 +978,63 @@ impl DomainServices { add: &[Uuid], remove_indexes: &[usize], ) -> 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 ids = current.songs.iter().map(|song| song.id).collect::>(); + 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 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 _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; + 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 = self.playlist_track_ids_on(&mut tx, user_id, id).await?; for index in removes { if index >= ids.len() { return Err(ServiceError::Invalid); @@ -754,8 +1042,7 @@ impl DomainServices { ids.remove(index); } ids.extend_from_slice(add); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().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 +1050,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 +1059,81 @@ 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?; + drop(_writer); + 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 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(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; + 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(()) } } @@ -799,21 +1145,73 @@ impl DomainServices { entity_id: Uuid, starred: bool, ) -> Result<(), ServiceError> { - self.authorize_entity(user_id, entity_type, entity_id) - .await?; + 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> { + 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(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + 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()) - .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(()) } @@ -860,12 +1258,62 @@ impl DomainServices { &self, 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? + 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 s.entity_type, s.entity_id, s.starred_at FROM user_star s \ + WHERE s.user_id=? AND ( \ + (s.entity_type='track' AND EXISTS (SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) OR \ + (s.entity_type='album' AND EXISTS (SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) OR \ + (s.entity_type='artist' AND EXISTS (SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) \ + ) ORDER BY s.starred_at DESC", + ) + .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 ( \ + (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(&mut *connection) + .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, @@ -873,12 +1321,45 @@ impl DomainServices { 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> { + 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(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "rating")?; + return Ok(()); + } if !(0..=5).contains(&rating) { return Err(ServiceError::Invalid); } - self.authorize_entity(user_id, entity_type, entity_id) + self.authorize_entity_on(&mut tx, user_id, entity_type, entity_id) .await?; - let _writer = self.db.writer_guard().await; if rating == 0 { sqlx::query( "DELETE FROM user_rating WHERE user_id=? AND entity_type=? AND entity_id=?", @@ -886,12 +1367,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(()) } @@ -902,10 +1402,52 @@ impl DomainServices { submission: bool, time: Option, ) -> Result<(), ServiceError> { - self.authorize_entity(user_id, "track", track_id).await?; - let now = time.unwrap_or_else(now_ms); + 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> { + 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(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + 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 (?, ?, ?, ?)", ) @@ -924,7 +1466,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 +1514,46 @@ impl DomainServices { Ok(result) } + pub async fn history( + &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> { + 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 \ + 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(&mut *connection) + .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, @@ -962,38 +1562,110 @@ impl DomainServices { 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> { + 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(&_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); } - self.songs_by_ids(user_id, ids).await?; + self.songs_by_ids_on(&mut tx, user_id, ids).await?; if let Some(current) = current { - self.songs_by_ids(user_id, &[current]).await?; + self.songs_by_ids_on(&mut tx, user_id, &[current]).await?; } - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().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=?") .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?; } + 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(()) } 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); }; @@ -1001,7 +1673,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) @@ -1014,38 +1686,66 @@ 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_lenient_on(connection, user_id, &ids) + .await?, })) } pub async fn shares(&self, 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?; - let mut shares = Vec::new(); - 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(self.db.pool()) + 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, 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 \ + 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_lenient_on(connection, user_id, &track_ids) .await? .into_iter() - .map(parse_uuid) - .collect::, _>>()?; + .map(|song| (song.id, song)) + .collect::>(); + let mut songs_by_share = HashMap::>::new(); + 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()); + for row in rows { + let id = parse_uuid(row.try_get("id")?)?; 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")?, visit_count: row.try_get("visit_count")?, - songs: self.songs_by_ids(user_id, &track_ids).await?, + songs: songs_by_share.remove(&id).unwrap_or_default(), }); } Ok(shares) @@ -1058,19 +1758,63 @@ impl DomainServices { description: Option<&str>, expires_at: Option, ) -> Result { - if ids.is_empty() { + 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 { + 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(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "share")?; + let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; + drop(_writer); + let mut share = self + .shares(user_id) + .await? + .into_iter() + .find(|share| share.id == id) + .ok_or(ServiceError::NotFound)?; + share.url_token = Some(self.secret_box.derive_share_token(id)); + return Ok(share); + } + if ids.is_empty() || ids.len() > MAX_SHARE_TRACKS { 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 songs = self.songs_by_ids_on(&mut tx, user_id, ids).await?; let id = Uuid::new_v4(); + let token = self.secret_box.derive_share_token(id); + let token_hash = security::token_hash(&token); let now = now_ms(); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - 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()) @@ -1079,17 +1823,42 @@ 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.shares(user_id) - .await? - .into_iter() - .find(|share| share.id == id) - .ok_or(ServiceError::NotFound) + drop(_writer); + self.sync.publish(user_id, receipt); + 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")?)?; @@ -1102,23 +1871,30 @@ 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()) { + // The rows above were read outside the writer gate, and acquiring it can + // block behind a scan. Re-check both revocation and expiry at write + // time: no affected row means the share died during that wait, and a + // visitor must not see what an owner deleted or let expire. + let _writer = self.db.writer_guard().await; + let visited_at = now_ms(); + let visited = sqlx::query( + "UPDATE share SET visit_count=visit_count+1, last_visited_at=? \ + WHERE id=? AND (expires_at IS NULL OR expires_at>?)", + ) + .bind(visited_at) + .bind(id.to_string()) + .bind(visited_at) + .execute(self.db.pool()) + .await? + .rows_affected(); + drop(_writer); + if visited == 0 { 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()) - .bind(id.to_string()) - .execute(self.db.pool()) - .await?; 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")?, @@ -1134,12 +1910,77 @@ impl DomainServices { 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 intent = MutationIntent::new( + "update", + &format!("share:{id}"), + &serde_json::json!({ + "description": description, + "expires_at": expires_at, + }), + ); let _writer = self.db.writer_guard().await; - 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(); - if changed == 0 { - return Err(ServiceError::NotFound); + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "share")?; + drop(_writer); + return self + .shares(user_id) + .await? + .into_iter() + .find(|share| share.id == id) + .ok_or(ServiceError::NotFound); } + 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( + &mut tx, + user_id, + context, + "share", + id, + "upsert", + &serde_json::json!({ + "id": id, + "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? .into_iter() @@ -1148,16 +1989,53 @@ 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 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(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "share")?; + 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 +2059,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::Unavailable)??; + 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, @@ -1195,7 +2155,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); @@ -1259,6 +2222,7 @@ impl DomainServices { .execute(&mut *tx) .await?; tx.commit().await?; + drop(_writer); self.users(actor_id) .await? .into_iter() @@ -1270,13 +2234,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 +2249,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::Unavailable)??, + ) + } 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=?", @@ -1329,6 +2317,7 @@ impl DomainServices { } } tx.commit().await?; + drop(_writer); self.users(actor_id) .await? .into_iter() @@ -1418,8 +2407,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, @@ -1433,7 +2423,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(()) @@ -1582,6 +2572,27 @@ 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()) + || !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..614d777 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)?; @@ -1506,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/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..8293f9b --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,410 @@ +//! 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, OwnedMutexGuard}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + authentication::now_ms, + database::{Database, SyncOperationReservation}, +}; + +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("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, + 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)] +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)] +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, + writer_guard: &OwnedMutexGuard<()>, + connection: &mut SqliteConnection, + user_id: Uuid, + context: MutationContext, + intent: MutationIntent, + ) -> Result { + let reservation = self + .db + .reserve_sync_operation( + writer_guard, + connection, + user_id, + context.operation_id, + context.origin_device_id, + intent.as_bytes(), + now_ms(), + ) + .await?; + 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" + ); + 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, + })) + } + } + } + + #[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, + entity_type: entity_type.to_owned(), + 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 { + if after < 0 || !(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 \ + 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 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 { + 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))) +} + +#[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 78eea2a..8a3799d 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -2,16 +2,21 @@ 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::{ authentication::now_ms, catalog::{ApplyOutcome, CatalogTrackInput, LibraryRecord}, database::{AccountRole, LibraryRole, LibraryVisibility}, - security, Config, + security, + services::{ServiceError, MAX_HISTORY_LIMIT, MAX_QUEUE_TRACKS, MAX_SHARE_TRACKS}, + sync::{MutationContext, SyncError, MAX_SYNC_LIMIT}, + Config, }; async fn test_app() -> (TempDir, Config, waveflow_server::AppState) { @@ -185,6 +190,346 @@ 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 = 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()) + .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": &password, + "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")); + assert!(refresh_cookie.contains("Path=/api/v2/web/auth")); + 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 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") + .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); + 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!( + logout_without_refresh + .headers() + .get_all("set-cookie") + .iter() + .count(), + 2 + ); + + let mut foreign = json_request( + "/api/v2/web/auth/login", + serde_json::json!({ + "username": "web-listener", + "password": &password, + "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 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()); + + 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 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!({ + "username": "first-admin", + "password": &admin_password + }), + ); + 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": &admin_password + }), + ); + 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", &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": &listener_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", &listener_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::FORBIDDEN); +} + #[tokio::test] async fn long_lived_native_api_tokens_authenticate_and_honor_revocation() { let (_temp, config, state) = test_app().await; @@ -304,6 +649,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] @@ -1529,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, @@ -1543,6 +1902,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, @@ -1561,6 +1933,33 @@ 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 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_some()); + assert_eq!( + subsonic_json(&router, "deleteUser", api_key, "&username=%C3%A9lodie").await + ["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 ["subsonic-response"]["status"], @@ -2613,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; @@ -2651,6 +3050,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", @@ -2741,6 +3160,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(), @@ -2749,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( @@ -2770,6 +3210,50 @@ 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); + 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() + .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()])); + 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( "GET", @@ -2818,6 +3302,753 @@ 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 = 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()) + .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(); + let password = password.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_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(); + + for invalid_limit in [0, MAX_SYNC_LIMIT + 1, i64::MAX] { + assert!(matches!( + state.sync.changes(owner, 0, invalid_limit).await, + 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( + 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(); + 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 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 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( + "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 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 share_request = || { + mutate( + "POST", + "/api/v2/shares".into(), + share_operation, + Some(serde_json::json!({ + "track_ids": [track], + "description": "Synchronized share" + })), + ) + }; + let lost_response = share_request().await; + assert_eq!(lost_response.status(), StatusCode::CREATED); + drop(lost_response); + + let replayed_share = share_request().await; + assert_eq!(replayed_share.status(), StatusCode::CREATED); + let replayed_share = json_body(replayed_share).await; + let share_id = replayed_share["id"].as_str().unwrap(); + let share_url = replayed_share["url"].as_str().unwrap(); + let public_share = router + .clone() + .oneshot(Request::get(share_url).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(public_share.status(), StatusCode::OK); + + let listed_shares = router + .clone() + .oneshot( + Request::get("/api/v2/shares") + .header("authorization", format!("Bearer {owner_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let listed_shares = json_body(listed_shares).await; + assert_eq!(listed_shares[0]["id"], share_id); + assert!(listed_shares[0].get("url").is_none()); + + 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(); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + let notice = tokio::time::timeout(std::time::Duration::from_secs(1), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + 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() + .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); + assert!(snapshot["shares"][0].get("url").is_none()); + 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 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") + .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 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(); + let missing_playlist_context = MutationContext { + operation_id: Uuid::new_v4(), + origin_device_id: None, + }; + assert!(matches!( + state + .services + .update_playlist_with_context( + owner, + playlist.id, + Some("Changed after deletion"), + None, + 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, + 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(); + // The row survives the replay, but a revoked membership must stop exposing + // it: favourites are filtered by visibility exactly like ratings are. + assert!(state + .services + .starred_ids(listener) + .await + .unwrap() + .iter() + .all(|(_, entity_id, _)| *entity_id != track)); + assert!(matches!( + state + .services + .set_rating_with_context(listener, "track", track, 5, inaccessible_context) + .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(), + 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, 4, 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(); + + 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) + )); + let oversized_share = vec![track; MAX_SHARE_TRACKS + 1]; + assert!(matches!( + state + .services + .create_share(owner, &oversized_share, Some("limit-test"), None) + .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(); + let updated_playlist = state + .services + .update_playlist( + owner, + aggregate_playlist.id, + Some("Unavailable aggregate renamed"), + None, + None, + &[], + &[], + ) + .await + .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) + .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] async fn embedded_web_client_serves_shell_without_shadowing_the_api() { let (_temp, config, state) = test_app().await; 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 8d1ac7c..beb52b9 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -1,16 +1,33 @@ /** * 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 SessionUser = { + id: string; + username: string; + role: "admin" | "user"; +}; + +export type WebSession = { + access_token: string; + user: SessionUser; + device_id: string; +}; + +let session: WebSession | null = null; -export type Tokens = { access_token: string; refresh_token: string }; +// 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; @@ -53,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, @@ -62,20 +131,12 @@ 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 hasSession(): boolean { + return session !== null; } -export function clearTokens() { - localStorage.removeItem(ACCESS_KEY); - localStorage.removeItem(REFRESH_KEY); +export function currentUser(): SessionUser | null { + return session?.user ?? null; } async function parse(response: Response): Promise { @@ -104,19 +165,38 @@ function refresh(): Promise { } async function performRefresh(): Promise { - const tokens = storedTokens(); - if (!tokens) return false; - const response = await fetch("/api/v2/auth/refresh", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ refresh_token: tokens.refresh_token }), - }); - if (!response.ok) { - clearTokens(); + const hadSession = session !== null; + const csrf = cookieValue("waveflow-csrf"); + if (!csrf) { + handleRefreshFailure(hadSession); + return false; + } + 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; } - store(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(); } async function call( @@ -124,9 +204,8 @@ async function call( 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 +218,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,17 +230,49 @@ 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. */ async function collect(path: string): Promise { const pageSize = 500; @@ -181,6 +292,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 e60ad30..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 { logout, storedTokens } 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 ( -
- -
- -
- -
+ +
+ +
+ +
+ +
+
); } @@ -64,8 +77,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. @@ -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 9690dc9..3ee4425 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -1,24 +1,46 @@ 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, 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 Share, type Song, safeInternalPath, search, setFavorite, + setSubsonicCredential, + setUserDisabled, + setupRequired, + startScan, + type User, } from "./api"; import { usePlayer } from "./player"; @@ -56,17 +78,31 @@ 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(); 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 { 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."); return; } @@ -84,7 +120,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}

}
@@ -146,7 +188,7 @@ export function AlbumsPage() { ); } -function SongTable({ songs }: { songs: Song[] }) { +export function SongTable({ songs }: { songs: Song[] }) { const player = usePlayer(); const [stars, setStars] = useState>({}); @@ -161,41 +203,570 @@ 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} +
+ + + {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)} + +
+
+ ); +} + +export function FavoritesPage() { + const { value, error } = useAsync(async () => { + const favorites = await listFavorites(); + const tracks = favorites.filter((item) => item.entity_type === "track"); + 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 ( +
+ + {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; + setMutationError(null); + 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(); + 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.length ? ( + <> +
+ + +
+
    + {player.queue.map((song, position) => ( +
  1. -
{song.artist}{formatDuration(song.duration_ms)} + {song.artist ?? "Unknown artist"} + + {formatDuration(song.duration_ms)} + + + + ))} + + + ) : ( + + )} + + ); +} + +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]); + const createdShareNotice = createdShare?.url ? ( +

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

+ ) : null; + if (!value) { + return ( + <> + {createdShareNotice} + + + ); + } + + async function shareQueue(event: FormEvent) { + event.preventDefault(); + setMutationError(null); + try { + const share = await createShare( + player.queue.map((song) => song.id), + description, + ); + setCreatedShare(share); + setDescription(""); + setRevision((value) => value + 1); + } catch { + setMutationError("The share could not be created."); + } + } + + async function removeShare(id: string) { + setMutationError(null); + try { + await deleteShare(id); + setCreatedShare((current) => (current?.id === id ? null : current)); + 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} + {createdShareNotice} + {value.length ? ( +
    + {value.map((share) => ( +
  • +
    + {share.description ?? "Music share"} + {share.url ? ( + + {share.url} + + ) : null} +
    + {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 [libraryBusy, setLibraryBusy] = useState(false); + const [userBusy, setUserBusy] = useState(false); + const { value, error } = useAsync( + () => Promise.all([listLibraries(), listUsers()]), + [revision], + ); + if (!value) return ; + const [libraries, users] = value; + + async function registerLibrary(event: FormEvent) { + event.preventDefault(); + setLibraryBusy(true); + setAdminError(null); + setNotice(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."); + } finally { + setLibraryBusy(false); + } + } + + async function addUser(event: FormEvent) { + event.preventDefault(); + setUserBusy(true); + setAdminError(null); + setNotice(null); + try { + await createUser(username, password, "user"); + setUsername(""); + setPassword(""); + setRevision((value) => value + 1); + } catch { + setAdminError("The account could not be created."); + } finally { + setUserBusy(false); + } + } + + async function toggleUser(user: User) { + setAdminError(null); + setNotice(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); + setNotice(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}

+
); } @@ -398,7 +969,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..73d438d 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -9,23 +9,36 @@ 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[]; index: number; current: Song | null; playing: boolean; - position: number; - duration: number; play: (queue: Song[], index: number) => void; + remove: (index: number) => void; + clear: () => void; toggle: () => void; next: () => void; previous: () => void; 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); @@ -33,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([]); @@ -46,9 +65,56 @@ 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, setHydrated] = useState(false); + const localMutation = useRef(false); + const resumePosition = useRef(0); + const resumeTrack = useRef(null); + const autoplay = useRef(false); + const suppressedPauseEvents = useRef(0); + const saveChain = useRef>(Promise.resolve()); const current = queue[index] ?? null; queueLength.current = queue.length; + queueRef.current = queue; + 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() + .then((saved) => { + if (cancelled || localMutation.current || !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; + 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. + .catch(() => undefined) + .finally(() => { + if (!cancelled) setHydrated(true); + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { if (!audio.current) audio.current = new Audio(); @@ -57,12 +123,33 @@ 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 = () => { + localMutation.current = true; + setIndex((value) => { + const next = Math.min(value + 1, Math.max(queueLength.current - 1, 0)); + autoplay.current = next !== value; + return next; + }); + }; + const onPlay = () => { + suppressedPauseEvents.current = 0; + setPlaying(true); + }; + const onPause = () => { + setPlaying(false); + if (suppressedPauseEvents.current > 0) { + suppressedPauseEvents.current -= 1; + return; + } + if (!hydrated) return; + const songs = queueRef.current; + const selected = songs[indexRef.current] ?? null; + persistQueue( + songs, + selected?.id ?? null, + Math.round(positionRef.current * 1000), ); - const onPlay = () => setPlaying(true); - const onPause = () => setPlaying(false); + }; element.addEventListener("timeupdate", onTime); element.addEventListener("loadedmetadata", onDuration); element.addEventListener("ended", onEnd); @@ -75,20 +162,42 @@ export function PlayerProvider({ children }: { children: ReactNode }) { element.removeEventListener("play", onPlay); element.removeEventListener("pause", onPause); }; - }, []); + }, [hydrated, persistQueue]); // Loading a track needs a round-trip for its ticket, so guard against a // stale response overwriting a newer selection. useEffect(() => { const element = audio.current; - if (!element || !current) return; + const resumeSeconds = + current && resumeTrack.current === current.id + ? resumePosition.current + : 0; + positionRef.current = resumeSeconds; + setPosition(resumeSeconds); + setDuration(0); + if (!element) return; + suppressedPauseEvents.current = 2; + element.pause(); + element.removeAttribute("src"); + element.load(); + if (!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 (resumeSeconds > 0) { + element.currentTime = resumeSeconds; + } + if (resumeTrack.current === current.id) { + resumePosition.current = 0; + resumeTrack.current = null; + } + if (!shouldAutoplay) return; await element.play(); void scrobble(current.id, false).catch(() => undefined); } catch { @@ -110,44 +219,153 @@ 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; + 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); }, []); + useEffect(() => { + if (!hydrated || !localMutation.current) return; + const timeout = window.setTimeout(() => { + persistQueue( + queue, + current?.id ?? null, + Math.round(positionRef.current * 1000), + ); + }, 400); + return () => window.clearTimeout(timeout); + }, [queue, current, hydrated, 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(() => { + localMutation.current = true; + setIndex((value) => { + const next = Math.min(value + 1, Math.max(queueLength.current - 1, 0)); + autoplay.current = next !== value; + return next; + }); + }, []); + + const previous = useCallback(() => { + localMutation.current = true; + 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) => { + localMutation.current = true; + if (at === indexRef.current) { + autoplay.current = false; + audio.current?.pause(); + } + setQueue((songs) => songs.filter((_, position) => position !== at)); + setIndex((value) => + value > at + ? value - 1 + : Math.min(value, Math.max(queue.length - 2, 0)), + ); + }, + clear: () => { + localMutation.current = true; + 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); + 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; + const element = audio.current; + if (element) { + localMutation.current = true; + 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), + ); + } }, }), - [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) => { @@ -166,34 +384,34 @@ export function PlayerBar() { onClick={player.previous} aria-label="Previous track" > - ⏮ + Previous

- {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 1271ee7..6dbddef 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 { @@ -222,6 +243,11 @@ main { border-collapse: collapse; } +.songs-scroll { + width: 100%; + overflow-x: auto; +} + .songs td { padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--line); @@ -315,3 +341,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 1rem 9rem; + } + + .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; + } +}