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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +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 + 1.h.2).** 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 enforced by the handler + a matching `DefaultBodyLimit::max(MAX_UPLOAD_BYTES + 1024)` so the handler is the authoritative gatekeeper rather than axum's default 2 MiB) hashes server-side and returns `{ hash, byte_size, mime, url }` — idempotent, a re-upload of the same bytes skips the storage write and the response echoes the stored row's `mime` + `byte_size` (not the request's) so a future MIME canonicalisation never silently breaks the documented contract; `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: public, max-age=31536000, immutable` + ETag = `"<hash>"`. Storage lives in [`src/storage.rs`](src/storage.rs) over Apache's `object_store` trait — `ArtworkBackend::{Local, S3}` enum picks the backend at boot via [`ArtworkBackend::from_env`](src/storage.rs); `Local` writes under `WAVEFLOW_ARTWORK_LOCAL_DIR` (created on the fly), `S3` uses `AmazonS3Builder` + an optional `WAVEFLOW_ARTWORK_S3_ENDPOINT` override to reach MinIO / Cloudflare R2 / Backblaze B2 (`with_allow_http(true)` flips on for the endpoint-override branch so an `http://minio:9000` deploy doesn't get refused; AWS itself ignores the flag). The two backends are mutually exclusive — setting both `WAVEFLOW_ARTWORK_LOCAL_DIR` + `WAVEFLOW_ARTWORK_S3_BUCKET` fails boot loudly. An optional `WAVEFLOW_ARTWORK_S3_PREFIX` wraps the inner store in `object_store::prefix::PrefixStore` so the shared `artwork/<hash>` key shape stays a single source of truth. Metadata persists in `metadata_artwork` (`hash` PK, `mime`, `byte_size`, `created_at`) via `INSERT … ON CONFLICT (hash) DO NOTHING` for race-safe concurrent uploads; SQL helpers in [`db::artwork`](src/db.rs). Both endpoints answer 503 when storage is disabled at boot (no backend env set). Image resize (3 sizes) + async pipeline land in 1.h.3 + 1.i.1.
- **Artwork cache (Phase 1.h.1 + 1.h.2 + 1.h.3).** Shared dedup-by-content store keyed on the BLAKE3 hex digest of the bytes. Three endpoints: `POST /api/v1/artwork` (JWT-authed, raw image bytes, `Content-Type` ∈ {`image/jpeg`, `image/png`, `image/webp`}, 4 MiB cap enforced by the handler + a matching `DefaultBodyLimit::max(MAX_UPLOAD_BYTES + 1024)` so the handler is the authoritative gatekeeper rather than axum's default 2 MiB) hashes server-side, runs the byte stream through [`artwork_pipeline::generate_variants`](src/artwork_pipeline.rs) (decode → fit-into-box resize → JPEG q85 encode per bucket; long-edge clamp prevents upscaling for sources already below the target) to produce `thumb` (≤ 128px) + `preview` (≤ 480px), and returns `{ hash, byte_size, mime, url, variants[] }` — idempotent, a re-upload of the same bytes skips both the storage write AND the pipeline run and echoes the stored row's `mime` + `byte_size` + the cached variant set; `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, `Cache-Control: public, max-age=31536000, immutable` + ETag = `"<hash>"` — the handler falls back to `metadata_artwork_variant` so a client that cached just the variant hash can hit the same route directly; `GET /api/v1/artwork/{hash}/{variant}` (variant ∈ {thumb, preview}) serves the resized JPEG with the same cache posture. Pipeline runs synchronously on the upload thread in 1.h.3 (apalis async lands in 1.i.1). Storage lives in [`src/storage.rs`](src/storage.rs) over Apache's `object_store` trait — `ArtworkBackend::{Local, S3}` enum picks the backend at boot via [`ArtworkBackend::from_env`](src/storage.rs); `Local` writes under `WAVEFLOW_ARTWORK_LOCAL_DIR` (created on the fly), `S3` uses `AmazonS3Builder` + an optional `WAVEFLOW_ARTWORK_S3_ENDPOINT` override to reach MinIO / Cloudflare R2 / Backblaze B2 (`with_allow_http(true)` flips on for the endpoint-override branch so an `http://minio:9000` deploy doesn't get refused; AWS itself ignores the flag). The two backends are mutually exclusive — setting both `WAVEFLOW_ARTWORK_LOCAL_DIR` + `WAVEFLOW_ARTWORK_S3_BUCKET` fails boot loudly. An optional `WAVEFLOW_ARTWORK_S3_PREFIX` wraps the inner store in `object_store::prefix::PrefixStore` so the shared `artwork/<hash>` key shape stays a single source of truth. Metadata persists in `metadata_artwork` (`hash` PK, `mime`, `byte_size`, `created_at`) + `metadata_artwork_variant` (`(parent_hash, variant)` PK, `hash`, `mime`, `byte_size`, `width`, `height`; `ON DELETE CASCADE` so a future GC of the parent reclaims the variants in the same tx), both written via `INSERT … ON CONFLICT DO NOTHING` for race-safe concurrent uploads; SQL helpers in [`db::artwork`](src/db.rs). All routes answer 503 when storage is disabled at boot (no backend env set). Async pipeline + on-demand re-generation land in 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
1 change: 1 addition & 0 deletions Cargo.lock

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

12 changes: 12 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ bytes = "1"
# a shared file across desktop ↔ server caches.
blake3 = "1"

# Image decode + resize for the artwork pipeline (Phase 1.h.3).
# `image` is the Rust ecosystem's de facto image library — pure
# Rust, no native deps. We disable default features (which pull in
# every supported codec, including HEIC + AVIF + TIFF + farbfeld +
# QOI + PNM that we don't accept on the wire anyway) and re-enable
# only the three formats `api/artwork.rs` actually allows: JPEG
# (encode + decode), PNG (decode), WebP (decode). Variants are
# re-encoded as JPEG q85, which gives ~60% smaller files than PNG
# at visually identical quality for cover art (always opaque) — so
# we don't need the WebP encoder feature.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }

[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
71 changes: 71 additions & 0 deletions migrations/20260607000000_metadata_artwork_variant.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
-- =============================================================================
-- Phase 1.h.3 — resized variants of the shared artwork cache.
--
-- Every upload that lands in `metadata_artwork` triggers two
-- synchronous re-encodes:
-- - `thumb` (≤ 128px on the long edge) for sidebars + list views
-- - `preview` (≤ 480px) for popovers + mini-cards
--
-- The original ("full") is kept in `metadata_artwork` byte-perfect
-- so a future high-DPI consumer can fall back to it. Variants are
-- always JPEG q85: covers are opaque, and JPEG buys roughly 60% file
-- size over PNG / WebP-lossless for visually identical output.
--
-- Parent → variant is FK-tracked with `ON DELETE CASCADE` so a
-- future garbage-collection sweep on `metadata_artwork` reclaims
-- the variants in the same transaction. The `variant` column is
-- string-typed so adding a new size later (`hero`, `large`, …)
-- doesn't need an ALTER TYPE.
-- =============================================================================

CREATE TABLE metadata_artwork_variant (
-- Hash of the ORIGINAL bytes the user uploaded. Maps 1:1 to
-- `metadata_artwork.hash`.
parent_hash TEXT NOT NULL REFERENCES metadata_artwork(hash) ON DELETE CASCADE,

-- Size bucket. Currently `thumb` (≤ 128px) or `preview` (≤ 480px).
-- Add new values to the CHECK list when a new size ships.
variant TEXT NOT NULL CHECK (variant IN ('thumb', 'preview')),

-- BLAKE3 of the re-encoded variant bytes. Same shape as the
-- parent — 64 lowercase hex chars — because the variant is
-- stored in object_store under the same `artwork/<hash>` key
-- shape (the GET handler treats the variant hash as a
-- first-class object reference; clients that already cache the
-- variant hash hit the bare `GET /api/v1/artwork/{hash}` route
-- without paying the parent-lookup detour).
hash TEXT NOT NULL CHECK (
char_length(hash) = 64
AND hash ~ '^[0-9a-f]{64}$'
),

-- Always 'image/jpeg' today; mirrors the future-proofing on
-- the parent table (a future WebP encoder would extend the
-- CHECK list without a schema break).
mime TEXT NOT NULL CHECK (mime IN ('image/jpeg')),

-- Cached so the GET handler can vend Content-Length without a
-- backend HEAD.
byte_size BIGINT NOT NULL CHECK (byte_size > 0),

-- Output dimensions (after the aspect-preserving resize). Useful
-- for the client to pre-size the layout slot without measuring
-- the bytes — and for picking the right variant for a target
-- pixel density.
width INTEGER NOT NULL CHECK (width > 0),
height INTEGER NOT NULL CHECK (height > 0),

created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

-- (parent, variant) is the natural row identity. A second
-- thumb for the same parent would imply two different re-encodes
-- of identical input bytes, which can't happen with a
-- deterministic resize pipeline.
PRIMARY KEY (parent_hash, variant)
);

-- The handler resolves a `GET /api/v1/artwork/{hash}` against the
-- variant table too, so a client that cached the variant hash can
-- fetch directly. The PK doesn't cover the `hash` column, hence the
-- secondary index.
CREATE INDEX idx_metadata_artwork_variant_hash ON metadata_artwork_variant(hash);
Loading
Loading