diff --git a/Cargo.toml b/Cargo.toml index 89ea97b..0d8505b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,12 @@ sha2 = "0.10" # 0.22 for the JWKS test harness. base64 = "0.22" +# Opaque public-share token generation (Phase 1.g.1). Same crate +# the JWT test harness uses under dev-deps; one runtime entry +# suffices. `Alphanumeric.sample_string` is the URL-safe random +# generator the share helper calls. +rand = "0.8" + # Stream files through axum without buffering the whole body. The # range handler in `src/api/stream.rs` wraps a `tokio::fs::File` in # `ReaderStream` and returns it as an axum response body. diff --git a/migrations/20260603000000_playlist_share_token.sql b/migrations/20260603000000_playlist_share_token.sql new file mode 100644 index 0000000..80026d6 --- /dev/null +++ b/migrations/20260603000000_playlist_share_token.sql @@ -0,0 +1,30 @@ +-- Public share tokens for playlists. Phase 1.g.1 of the WaveFlow +-- roadmap. The desktop's "Share" modal calls the mint endpoint to +-- generate an opaque, unguessable token; the resulting URL +-- (`/p/{token}` on waveflow-web) opens an anonymous, read-only +-- preview of the playlist — no account required to view, no +-- streaming inside the preview (Phase 1.g.0 keeps the surface +-- minimal until server-side `playlist_track` materialisation +-- arrives in a follow-up). +-- +-- Design choices, mirroring the same defaults the desktop will +-- inherit when the column lands there too: +-- +-- - `TEXT` rather than `UUID` because the desktop mints via +-- `rand::distributions::Alphanumeric` (URL-safe 32-char string), +-- not a UUID — matches the stream-token convention and keeps the +-- public URL short. Validation against any specific format lives +-- in the application layer. +-- - `UNIQUE` index is partial (`WHERE share_token IS NOT NULL`) so +-- the vast majority of playlists (private) don't pay the index +-- bloat. Postgres supports partial UNIQUE indexes natively; a +-- plain UNIQUE column would reject the second NULL row on most +-- databases that key NULLs. +-- - Revoke = `UPDATE playlist SET share_token = NULL WHERE id = ?`. +-- Idempotent and instantaneously closes the public URL. + +ALTER TABLE playlist ADD COLUMN share_token TEXT; + +CREATE UNIQUE INDEX idx_playlist_share_token + ON playlist (share_token) + WHERE share_token IS NOT NULL; diff --git a/src/api/mod.rs b/src/api/mod.rs index 63a5124..dadb74f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -34,6 +34,7 @@ mod libraries; mod playlists; mod profiles; mod ready; +mod share; mod stream; mod sync; mod tracks; @@ -55,6 +56,13 @@ pub fn router(state: AppState) -> OpenApiRouter { let tracks_router = tracks::router(state.clone()).layer(auth_layer.clone()); let playlists_router = playlists::router(state.clone()).layer(auth_layer.clone()); let sync_router = sync::router(state.clone()).layer(auth_layer.clone()); + // Mint + revoke stay JWT-authed (verify tenant ownership before + // mutating the share_token column). Same auth-vs-public split as + // the streaming surface. + let share_mint_router = share::auth_router(state.clone()).layer(auth_layer.clone()); + // Public read of a shared playlist by opaque token — no JWT + // gate, the token IS the auth. + let share_public_router = share::public_router(state.clone()); // Mint stays JWT-authed (verifies tenant ownership before signing). let stream_mint_router = stream::auth_router(state.clone()).layer(auth_layer); // The stream endpoint itself is HMAC-authed by the token in the @@ -71,6 +79,8 @@ pub fn router(state: AppState) -> OpenApiRouter { .merge(tracks_router) .merge(playlists_router) .merge(sync_router) + .merge(share_mint_router) + .merge(share_public_router) .merge(stream_mint_router) .merge(stream_public_router) } diff --git a/src/api/share.rs b/src/api/share.rs new file mode 100644 index 0000000..c5b213a --- /dev/null +++ b/src/api/share.rs @@ -0,0 +1,210 @@ +//! `/api/v1/share/*` — public share-link surface per Phase 1.g.1. +//! +//! Three routes split across the same auth-vs-public boundary the +//! streaming module uses: +//! +//! - **Mint** (`POST /api/v1/profiles/{profile_id}/playlists/{playlist_id}/share`): +//! JWT-authed. Verifies tenant ownership of the playlist, then +//! atomically generates (or returns the existing) opaque token via +//! `db::share::mint_or_get_token`. Idempotent — a second call for +//! the same playlist returns the same token rather than rotating +//! it. +//! - **Revoke** (`DELETE /api/v1/profiles/{profile_id}/playlists/{playlist_id}/share`): +//! JWT-authed. Sets `share_token = NULL`, instantly closing any +//! public URL pointing at this playlist. +//! - **Public read** (`GET /api/v1/share/playlists/{token}`): NOT +//! behind the JWT middleware — the token IS the auth. A miss (no +//! row matches the token) returns 404 with no body so an attacker +//! can't distinguish "revoked" from "never minted". +//! +//! Wire format intentionally minimal: name + description + cover + +//! brand tokens + timestamps. Track list is NOT returned today +//! because the server doesn't materialise `playlist_track` yet +//! (the desktop is still the source of truth for the join). When +//! Phase 1.g.2 brings server-side materialisation, this DTO grows a +//! `tracks: Vec` field without a wire-break. + +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Serialize; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::{middleware::UserId, AppState}; + +/// Body of the mint response. `token` is the opaque ID the web +/// client uses for the public route (`/p/` on +/// waveflow-web). The server does NOT return the full URL because +/// it doesn't know the web origin — clients combine this token +/// with their persisted `app_setting['app.waveflow_web_url']` to +/// build the shareable link. +#[derive(Debug, Serialize, ToSchema)] +pub struct MintResponse { + /// Opaque URL-safe token (32 alphanumeric chars). Stable for + /// the lifetime of the link — a second mint call returns the + /// same value rather than rotating. + pub token: String, +} + +/// Public preview of a shared playlist. Mirrors the desktop's +/// `Playlist` DTO minus the `profile_id` (the share owner isn't +/// exposed to anonymous viewers) and minus the smart-playlist +/// machinery (smart playlists can't be shared — they materialise +/// per-device). +#[derive(Debug, Serialize, ToSchema)] +pub struct PublicPlaylistResponse { + pub id: i64, + pub name: String, + pub description: Option, + pub color_id: String, + pub icon_id: String, + /// BLAKE3 hash of the cover image in the shared metadata cache. + /// `None` until the artwork pipeline ships on the server. + pub cover_hash: Option, + pub created_at: i64, + pub updated_at: i64, + /// Track list. Always empty today — server-side + /// `playlist_track` materialisation is Phase 1.g.2. The field + /// is present so a future server release can populate it + /// without breaking the wire shape. + pub tracks: Vec, +} + +/// Placeholder shape for the track list — kept minimal so the +/// initial wire contract doesn't pre-commit to a richer DTO that +/// would need re-negotiating once the join lands server-side. +#[derive(Debug, Serialize, ToSchema)] +pub struct PublicTrack { + pub title: String, + pub artist: Option, + pub duration_ms: i64, +} + +pub fn auth_router(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(mint_share_token)) + .routes(routes!(revoke_share_token)) + .with_state(state) +} + +pub fn public_router(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(get_public_playlist)) + .with_state(state) +} + +#[utoipa::path( + post, + path = "/api/v1/profiles/{profile_id}/playlists/{playlist_id}/share", + tag = "share", + params( + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), + ("profile_id" = i64, Path, description = "Owning profile id"), + ("playlist_id" = i64, Path, description = "Playlist to publish"), + ), + responses( + (status = 200, description = "Token minted or echoed back if one already existed", body = MintResponse), + (status = 401, description = "Missing or invalid bearer token"), + (status = 404, description = "Playlist not found in the requested profile or not owned by the caller"), + (status = 500, description = "Database or internal failure"), + ), +)] +async fn mint_share_token( + State(state): State, + Extension(UserId(user_id)): Extension, + Path((profile_id, playlist_id)): Path<(i64, i64)>, +) -> impl IntoResponse { + match crate::db::share::mint_or_get_token(&state.db, user_id, profile_id, playlist_id).await { + Ok(Some(token)) => (StatusCode::OK, Json(MintResponse { token })).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, "playlist not found").into_response(), + Err(err) => { + tracing::error!(error = %err, user_id, profile_id, playlist_id, "share mint failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "mint failed").into_response() + } + } +} + +#[utoipa::path( + delete, + path = "/api/v1/profiles/{profile_id}/playlists/{playlist_id}/share", + tag = "share", + params( + ("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"), + ("profile_id" = i64, Path, description = "Owning profile id"), + ("playlist_id" = i64, Path, description = "Playlist whose link should be closed"), + ), + responses( + (status = 204, description = "Token cleared (idempotent — also returned if the link was already private)"), + (status = 401, description = "Missing or invalid bearer token"), + (status = 404, description = "Playlist not found in the requested profile or not owned by the caller"), + (status = 500, description = "Database or internal failure"), + ), +)] +async fn revoke_share_token( + State(state): State, + Extension(UserId(user_id)): Extension, + Path((profile_id, playlist_id)): Path<(i64, i64)>, +) -> impl IntoResponse { + match crate::db::share::revoke_token(&state.db, user_id, profile_id, playlist_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => (StatusCode::NOT_FOUND, "playlist not found").into_response(), + Err(err) => { + tracing::error!(error = %err, user_id, profile_id, playlist_id, "share revoke failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "revoke failed").into_response() + } + } +} + +#[utoipa::path( + get, + path = "/api/v1/share/playlists/{token}", + tag = "share", + params( + ("token" = String, Path, description = "Opaque share token minted by POST /share"), + ), + responses( + (status = 200, description = "Public preview of the shared playlist", body = PublicPlaylistResponse), + (status = 404, description = "Token unknown (never minted or revoked)"), + (status = 500, description = "Database or internal failure"), + ), +)] +async fn get_public_playlist( + State(state): State, + Path(token): Path, +) -> impl IntoResponse { + match crate::db::share::fetch_public_by_token(&state.db, &token).await { + Ok(Some(( + id, + name, + description, + color_id, + icon_id, + cover_hash, + created_at, + updated_at, + ))) => ( + StatusCode::OK, + Json(PublicPlaylistResponse { + id, + name, + description, + color_id, + icon_id, + cover_hash, + created_at, + updated_at, + tracks: Vec::new(), + }), + ) + .into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(err) => { + tracing::error!(error = %err, "share public lookup failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed").into_response() + } + } +} diff --git a/src/db.rs b/src/db.rs index 26d31d1..7b96dd0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -266,3 +266,120 @@ pub mod users { .await } } + +/// SQL helpers for the public-share surface (Phase 1.g.1). All four +/// helpers key on `(user_id, profile_id, playlist_id)` so a request +/// targeting a playlist the caller doesn't own short-circuits at the +/// storage layer rather than the handler — same defence pattern as +/// the rest of the API. +pub mod share { + use sqlx::PgPool; + + use rand::distributions::{Alphanumeric, DistString}; + + /// URL-safe character length of the opaque share token. 32 + /// alphanumerics ≈ 190 bits of entropy, well above the 128-bit + /// threshold the OWASP cheat sheet recommends for "opaque + /// session-equivalent" tokens. Short enough to fit in a Bitly- + /// style social card without wrapping. + pub const TOKEN_LEN: usize = 32; + + /// Mint a fresh share token (or return the existing one if the + /// playlist already has one) for a playlist the caller owns. The + /// tenant chain (`user_id → profile_id → playlist`) is verified + /// inline; a foreign-owned playlist surfaces as `Ok(None)`. + /// + /// Idempotent: a second call for the same playlist returns the + /// existing token rather than rotating it. Rotation requires an + /// explicit revoke + re-mint. + pub async fn mint_or_get_token( + pool: &PgPool, + user_id: i64, + profile_id: i64, + playlist_id: i64, + ) -> Result, sqlx::Error> { + let candidate = Alphanumeric.sample_string(&mut rand::thread_rng(), TOKEN_LEN); + // `COALESCE(share_token, $candidate)` — atomic and race-free. + // If the row already had a token (mint called twice, or two + // concurrent mints racing past our generation), the COALESCE + // keeps the existing value and `RETURNING` echoes it back. + // If `share_token IS NULL`, the candidate is planted. Either + // way we never write twice and never need a re-SELECT. + // + // Ownership chain (`user_id → profile_id → playlist`) checked + // inline. A foreign-owned playlist makes the WHERE match no + // rows, `fetch_optional` returns `None`, and the handler maps + // it to 404 — same no-existence-leak shape as the other + // modules. + sqlx::query_scalar::<_, String>( + "UPDATE playlist + SET share_token = COALESCE(share_token, $1) + WHERE id = $2 AND profile_id = $3 + AND profile_id IN (SELECT id FROM profile WHERE user_id = $4) + RETURNING share_token", + ) + .bind(&candidate) + .bind(playlist_id) + .bind(profile_id) + .bind(user_id) + .fetch_optional(pool) + .await + } + + /// Drop the share token for a playlist the caller owns. Returns + /// the rows-affected boolean so the handler can distinguish "no + /// playlist" (404) from "already private" (204 no-op). + pub async fn revoke_token( + pool: &PgPool, + user_id: i64, + profile_id: i64, + playlist_id: i64, + ) -> Result { + let res = sqlx::query( + "UPDATE playlist + SET share_token = NULL + WHERE id = $1 AND profile_id = $2 + AND profile_id IN (SELECT id FROM profile WHERE user_id = $3)", + ) + .bind(playlist_id) + .bind(profile_id) + .bind(user_id) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Public lookup — fetch the playlist row by token without any + /// auth check. Returns the column tuple the public handler + /// projects into its response DTO. A token that was minted then + /// revoked surfaces as `None` (no row matches) — same shape as a + /// token that never existed, so an attacker can't distinguish + /// "revoked" from "never minted". + #[allow(clippy::type_complexity)] + pub async fn fetch_public_by_token( + pool: &PgPool, + token: &str, + ) -> Result< + Option<( + i64, + String, + Option, + String, + String, + Option, + i64, + i64, + )>, + sqlx::Error, + > { + sqlx::query_as( + "SELECT p.id, p.name, p.description, p.color_id, p.icon_id, + p.cover_hash, p.created_at, p.updated_at + FROM playlist p + WHERE p.share_token = $1", + ) + .bind(token) + .fetch_optional(pool) + .await + } +} diff --git a/tests/share.rs b/tests/share.rs new file mode 100644 index 0000000..75e01fb --- /dev/null +++ b/tests/share.rs @@ -0,0 +1,286 @@ +//! End-to-end tests for `/api/v1/share/*` and the per-playlist +//! mint / revoke endpoints. Phase 1.g.1 of the WaveFlow roadmap. +//! +//! Coverage matrix mirrors `tests/playlists.rs` for the tenant- +//! isolation battery (foreign profile 404, proxy attack, 401 gate) +//! plus share-specific properties: +//! +//! - Mint is idempotent — a second call returns the same token. +//! - Revoke + re-mint produces a NEW token (the partial UNIQUE +//! index allows reuse of the previous value). +//! - Public GET returns 404 on unknown / revoked tokens, with no +//! way for an attacker to distinguish the two. +//! - The public payload omits `profile_id` and other tenant- +//! identifying fields. + +mod support; + +use reqwest::StatusCode; +use serde_json::{json, Value}; +use sqlx::PgPool; +use support::{spawn_authenticated, spawn_two_authenticated}; + +async fn mint_profile(base: &str, token: &str, name: &str) -> i64 { + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles")) + .bearer_auth(token) + .json(&json!({ "name": name, "color_id": "emerald" })) + .send() + .await + .expect("profile create failed") + .error_for_status() + .expect("non-2xx on profile create") + .json() + .await + .expect("profile create body"); + created["id"].as_i64().expect("profile id missing") +} + +async fn mint_playlist(base: &str, token: &str, profile_id: i64, name: &str) -> i64 { + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .bearer_auth(token) + .json(&json!({ "name": name })) + .send() + .await + .expect("playlist create failed") + .error_for_status() + .expect("non-2xx on playlist create") + .json() + .await + .expect("playlist create body"); + created["id"].as_i64().expect("playlist id missing") +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn mint_returns_token_and_public_get_resolves(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share").await; + let pid = mint_profile(&h.base, &h.token, "p").await; + let plid = mint_playlist(&h.base, &h.token, pid, "Soirée").await; + + let resp: Value = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let token = resp["token"].as_str().expect("mint token").to_string(); + assert_eq!(token.len(), 32, "token must be 32 chars (Phase 1.g spec)"); + + // Public GET — no JWT, just the token. + let public: Value = reqwest::Client::new() + .get(format!("{}/api/v1/share/playlists/{}", h.base, token)) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(public["id"].as_i64(), Some(plid)); + assert_eq!(public["name"].as_str(), Some("Soirée")); + assert!(public["tracks"].is_array()); + // Tenant-identifying fields must NOT be on the public payload. + assert!( + public.get("profile_id").is_none(), + "public payload must not leak profile_id, got {public}" + ); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn mint_is_idempotent(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share").await; + let pid = mint_profile(&h.base, &h.token, "p").await; + let plid = mint_playlist(&h.base, &h.token, pid, "p").await; + + let first: Value = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let second: Value = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + first["token"].as_str(), + second["token"].as_str(), + "two mints in a row must return the same token" + ); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn revoke_closes_the_public_url(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share").await; + let pid = mint_profile(&h.base, &h.token, "p").await; + let plid = mint_playlist(&h.base, &h.token, pid, "p").await; + + let token = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["token"] + .as_str() + .unwrap() + .to_string(); + + let revoke = reqwest::Client::new() + .delete(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap(); + assert_eq!(revoke.status(), StatusCode::NO_CONTENT); + + let public = reqwest::Client::new() + .get(format!("{}/api/v1/share/playlists/{}", h.base, token)) + .send() + .await + .unwrap(); + assert_eq!( + public.status(), + StatusCode::NOT_FOUND, + "revoked token must surface as 404 with no body, same shape as never-minted" + ); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn revoke_then_remint_returns_a_fresh_token(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share").await; + let pid = mint_profile(&h.base, &h.token, "p").await; + let plid = mint_playlist(&h.base, &h.token, pid, "p").await; + + let initial = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["token"] + .as_str() + .unwrap() + .to_string(); + reqwest::Client::new() + .delete(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap(); + let reminted = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + .bearer_auth(&h.token) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["token"] + .as_str() + .unwrap() + .to_string(); + assert_ne!( + initial, reminted, + "revoke + re-mint MUST produce a fresh token — pinning a stale URL would defeat revoke" + ); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn mint_against_foreign_playlist_is_404(pool: PgPool) { + let two = spawn_two_authenticated(pool, "alice", "bob").await; + let pid_a = mint_profile(&two.base, &two.a.token, "alice").await; + let plid_a = mint_playlist(&two.base, &two.a.token, pid_a, "alice's playlist").await; + + let status = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + two.base, pid_a, plid_a + )) + .bearer_auth(&two.b.token) + .send() + .await + .unwrap() + .status(); + assert_eq!( + status, + StatusCode::NOT_FOUND, + "tenant proxy attack must 404, never leak existence" + ); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn public_get_unknown_token_is_404(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share").await; + let resp = reqwest::Client::new() + .get(format!( + "{}/api/v1/share/playlists/{}", + h.base, + "x".repeat(32) + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn mint_requires_auth(pool: PgPool) { + let h = spawn_authenticated(pool, "user-share").await; + let pid = mint_profile(&h.base, &h.token, "p").await; + let plid = mint_playlist(&h.base, &h.token, pid, "p").await; + let status = reqwest::Client::new() + .post(format!( + "{}/api/v1/profiles/{}/playlists/{}/share", + h.base, pid, plid + )) + // No bearer. + .send() + .await + .unwrap() + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); +}