Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ WAVEFLOW_JWT_AUDIENCE=waveflow-server
# WAVEFLOW_MUSIC_ROOT=/var/lib/waveflow/music
# WAVEFLOW_STREAM_SECRET=replace-me-with-openssl-rand-base64-32-output

# Artwork storage (Phase 1.h.1) — opt-in, same shape as streaming.
# Unset disables `/api/v1/artwork/*` cleanly with 503.
#
# WAVEFLOW_ARTWORK_LOCAL_DIR: filesystem root the LocalFileSystem
# backend writes uploaded artwork into, keyed `artwork/<blake3_hex>`.
# Created on the fly at boot if missing. Phase 1.h.2 will add an S3
# backend behind the same opt-in flag.
# WAVEFLOW_ARTWORK_LOCAL_DIR=/var/lib/waveflow/artwork

# Logging.
# RUST_LOG syntax: `info,waveflow_server=debug,tower_http=debug`.
# WAVEFLOW_LOG_FORMAT=json switches to JSON output. Anything else =
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ CI runs the full suite only on Linux (service container); the Windows leg is a c
- **Tenancy is enforced at the storage layer.** Server handlers under `/api/v1/*` extract `UserId` from the `middleware::authenticate` middleware and call `*_for_user` methods only — never the single-tenant trait surface. `PostgresProfileRepository` deliberately does NOT implement `ProfileRepository`, so the compiler stops a careless `list_all()` from leaking another tenant's rows. Apply the same pattern when adding library / track / playlist repositories.
- **Auth: JWT-only (Phase 1.d.2).** `middleware::authenticate` requires a Bearer JWT signed by the upstream Better Auth issuer. The middleware verifies the token via [`AppState::jwt_verifier`], then `db::users::find_or_provision_by_external_id(state.db, &sub, now_ms)` lazy-onboards the user on first request — a valid signature is the authoritative onboarding signal, so no separate `POST /api/v1/users` exists. Boot requires the full `WAVEFLOW_JWT_*` triple (`_JWKS_URL` / `_ISSUER` / `_AUDIENCE`); the legacy `X-User-Id` dev shim retired alongside `WAVEFLOW_DEV_AUTH`.
- **Streaming (Phase 1.e).** Two endpoints: `POST /api/v1/profiles/{p}/libraries/{l}/tracks/{t}/stream-url` (JWT-authed) verifies tenant ownership and signs a short-lived (≤ 60 s) URL via [`stream_token::mint`]; `GET /api/v1/stream/{token}` is mounted OUTSIDE the JWT layer because browsers can't attach a Bearer to `<audio src>` — the HMAC in the token IS the auth. The stream handler canonicalises `<music_root>/<file_path>` and refuses anything resolving outside `WAVEFLOW_MUSIC_ROOT` (path-traversal guard via `std::fs::canonicalize` + prefix check). Range requests handled in-process: `Accept-Ranges: bytes`, 206 + `Content-Range` on partial, 416 on unsatisfiable. Both endpoints answer 503 when streaming is disabled at boot (`WAVEFLOW_MUSIC_ROOT` + `WAVEFLOW_STREAM_SECRET` unset). Until 1.f's sync ships, files must be placed manually under the music root.
- **Artwork cache (Phase 1.h.1).** Shared dedup-by-content store keyed on the BLAKE3 hex digest of the bytes. Two endpoints: `POST /api/v1/artwork` (JWT-authed, raw image bytes, `Content-Type` ∈ {`image/jpeg`, `image/png`, `image/webp`}, 4 MiB cap) hashes server-side and returns `{ hash, byte_size, mime, url }` — idempotent, a re-upload of the same bytes skips the storage write; `GET /api/v1/artwork/{hash}` is public (the 64-hex hash IS the credential, same model as `share` tokens) and serves the bytes with the recorded Content-Type plus `Cache-Control: immutable` + ETag. Storage lives in [`src/storage.rs`](src/storage.rs) over Apache's `object_store` trait — LocalFileSystem backend in 1.h.1; S3 (`object_store` `aws` feature) follows in 1.h.2 with no caller change. Metadata persists in `metadata_artwork` (`hash` PK, `mime`, `byte_size`, `created_at`); SQL helpers in [`db::artwork`](src/db.rs). Both endpoints answer 503 when storage is disabled at boot (`WAVEFLOW_ARTWORK_LOCAL_DIR` unset). Image resize (3 sizes) + async pipeline land in 1.h.3 + 1.i.1.
- **Sync (Phase 1.f).** Append-only `sync_op` log keyed on `BIGSERIAL id`; per-`(user, device)` UNIQUEs on `operation_id` (idempotency) and `lamport_ts` (monotonicity). Three REST routes + one WebSocket under `/api/v1/sync/*`: `POST /ops` push (idempotent replay via `ON CONFLICT operation_id DO NOTHING`, 409 + `stored_max` on lamport regression), `GET /ops?since=N` pull (410 + `compacted_up_to` when `0 < since < watermark`; `since=0` is the bootstrap path and always skips the guard so fresh devices can converge from an empty cursor — read-only, never advances the cursor), `POST /ack` (the **only** path that writes `device_sync_cursor`, buffered in memory + flushed every 5 s), `GET /ws?device_id=…` WebSocket fan-out (one broadcast channel, per-frame `user_id` filter so cross-tenant ops never leak). Live state in `sync::SyncHub`: tokio `broadcast::Sender` + `DashMap<(user_id, device_id), AckEntry>` + daily compaction task that flushes ACKs first then collapses superseded ops in the same Postgres transaction as the watermark UPSERT. Watermark monotonic by UPSERT `WHERE` clause. Stale devices (`last_seen_at < now - 90 d`) skipped from the compaction MIN. Tests drive `SyncHub::for_tests` (no background tasks) and call `flush_acks` / `compact_once` directly for determinism.
- **Don't leak DB errors to unauthenticated probes.** `/ready` logs the sqlx error via `tracing::warn!` but returns a fixed sentinel body (`{status, db}`) so a load balancer never sees the connection-URL host or credentials. Apply the same discipline to any other unauthenticated endpoint.
- **Migrations are immutable once merged.** They're embedded at compile time via `sqlx::migrate!("./migrations")` (`db::MIGRATOR`); the `_sqlx_migrations` table stores each file's checksum, so editing an applied migration makes the server refuse to start. Schema changes = a new dated migration file (`YYYYMMDDHHMMSS_name.sql`). Boot applies pending migrations *before* opening the listener, which is what makes `/ready` trustworthy.
Expand Down
98 changes: 98 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,27 @@ rand = "0.8"
# `ReaderStream` and returns it as an axum response body.
tokio-util = { version = "0.7", features = ["io"] }

# Storage abstraction for the artwork cache (Phase 1.h). The Apache
# `object_store` crate exposes a single `ObjectStore` trait backed by
# LocalFileSystem (default), S3, Azure Blob, GCS, in-memory. We start
# with the LocalFileSystem backend in 1.h.1 and unlock S3 in 1.h.2 by
# enabling the `aws` feature — same trait, no caller change. Default
# features are inert (just `LocalFileSystem` + `InMemory`), so no
# extra deps land in the binary until S3 is opted into.
object_store = "0.12"

# Owned byte buffers for the artwork upload path. axum's `Bytes`
# extractor reads the request body into one shot; `object_store`'s
# `PutPayload` accepts `Bytes` directly. Already transitive via axum
# + reqwest; expliciting it here makes the surface area visible to
# anyone reading the manifest.
bytes = "1"

# BLAKE3 hashing of uploaded artwork bytes. Same crate the desktop
# uses to fingerprint cover/picture downloads, so a shared hash means
# a shared file across desktop ↔ server caches.
blake3 = "1"

[dev-dependencies]
# Activate `use_pem` for the JWT tests only. The harness in
# `tests/jwks_harness.rs` calls `EncodingKey::from_rsa_pem(…)` to
Expand Down
48 changes: 48 additions & 0 deletions migrations/20260606000000_metadata_artwork.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- =============================================================================
-- Phase 1.h.1 — shared artwork cache (Postgres mirror of the desktop's
-- on-disk metadata_artwork directory). Identical images uploaded from
-- different tenants dedupe to a single row keyed by the BLAKE3 hash of
-- the bytes, so a million users sharing the same album cover only
-- store one copy.
--
-- This table tracks the MIME type + byte size + first-seen timestamp.
-- The bytes themselves live in object_store (LocalFileSystem in 1.h.1,
-- S3 in 1.h.2) at the key `artwork/<hash>`. Splitting metadata from
-- payload lets the existence check (a GET / HEAD against the table)
-- stay a single Postgres round-trip instead of round-tripping S3.
--
-- No foreign keys: the desktop references this hash from playlist,
-- album, artist tables across two separate databases (app.db +
-- per-profile data.db). We mirror that "soft" linkage server-side by
-- letting playlist.cover_hash / album.cover_hash / artist.picture_hash
-- carry the hash without a referential constraint.
-- =============================================================================

CREATE TABLE metadata_artwork (
-- BLAKE3 of the raw bytes, hex-encoded. 64 characters; we enforce
-- the length + alphabet so a malformed value can't slip through
-- a hand-crafted POST.
hash TEXT PRIMARY KEY CHECK (
char_length(hash) = 64
AND hash ~ '^[0-9a-f]{64}$'
),

-- Content-Type the client originally uploaded with. We accept
-- image/jpeg, image/png, image/webp at the application layer;
-- mirrored as a CHECK so a bypass of the app layer can't pollute
-- the table with arbitrary strings.
mime TEXT NOT NULL CHECK (mime IN ('image/jpeg', 'image/png', 'image/webp')),

-- Original byte count. Cached so a HEAD response or a listing can
-- vend Content-Length without round-tripping object_store.
byte_size BIGINT NOT NULL CHECK (byte_size > 0),

-- First-seen timestamp. Not "updated_at" — rows are immutable
-- (the BLAKE3 hash IS the row identity). Useful for cron-driven
-- garbage collection of artwork no entity references anymore.
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- No additional indexes: the primary key already covers the only
-- lookup pattern (GET /api/v1/artwork/{hash}) and the table is
-- write-once / read-many / append-only.
Loading
Loading