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
25 changes: 21 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,31 @@ 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.
# Artwork storage (Phase 1.h.1 + 1.h.2) — opt-in, same shape as
# streaming. Unset disables `/api/v1/artwork/*` cleanly with 503.
#
# Pick ONE backend. The LOCAL_DIR + S3_BUCKET pair is mutually
# exclusive — setting both fails boot loudly so an operator never
# silently points at the wrong cache.
#
# --- LocalFileSystem backend ---
# 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.
# Created on the fly at boot if missing.
# WAVEFLOW_ARTWORK_LOCAL_DIR=/var/lib/waveflow/artwork
#
# --- S3-compatible backend ---
# The same builder reaches AWS S3, MinIO, Cloudflare R2 and Backblaze
# B2 — non-AWS providers just need the WAVEFLOW_ARTWORK_S3_ENDPOINT
# override. Static credentials only in 1.h.2; IAM role / IMDS auth is
# a follow-up.
#
# WAVEFLOW_ARTWORK_S3_BUCKET=wf-artwork
# WAVEFLOW_ARTWORK_S3_ACCESS_KEY_ID=AKIA...
# WAVEFLOW_ARTWORK_S3_SECRET_ACCESS_KEY=...
# WAVEFLOW_ARTWORK_S3_REGION=us-east-1 # default
# WAVEFLOW_ARTWORK_S3_ENDPOINT=http://minio:9000 # MinIO / R2 / B2 only
# WAVEFLOW_ARTWORK_S3_PREFIX=dev/ # optional, share a bucket cleanly

# Logging.
# RUST_LOG syntax: `info,waveflow_server=debug,tower_http=debug`.
Expand Down
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).** 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.
- **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.
- **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
141 changes: 139 additions & 2 deletions Cargo.lock

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

14 changes: 8 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,14 @@ 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"
# LocalFileSystem (default), S3, Azure Blob, GCS, in-memory. 1.h.1
# shipped the LocalFileSystem backend; 1.h.2 unlocks S3 via the
# `aws` feature — same trait, same `put` / `get` / `head`, so the
# caller side never branches on backend. AWS, MinIO, Cloudflare R2,
# Backblaze B2 are all S3-API-compatible and reachable through the
# same builder (an `WAVEFLOW_ARTWORK_S3_ENDPOINT` override is the
# only knob a non-AWS provider needs).
object_store = { version = "0.12", features = ["aws"] }

# Owned byte buffers for the artwork upload path. axum's `Bytes`
# extractor reads the request body into one shot; `object_store`'s
Expand Down
20 changes: 14 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,26 @@ async fn main() -> anyhow::Result<()> {
}
};

// Artwork storage — Some when WAVEFLOW_ARTWORK_LOCAL_DIR is set,
// Artwork storage — Some when the artwork backend is configured
// (`WAVEFLOW_ARTWORK_LOCAL_DIR` for the LocalFileSystem path or
// `WAVEFLOW_ARTWORK_S3_BUCKET` + creds for the S3 family).
// None disables both `/api/v1/artwork/*` routes. The local
// backend creates the root directory on the fly so a fresh
// container only needs the env set, not a pre-existing dir.
// container only needs the env set, not a pre-existing dir; the
// S3 backend defers credential validation to first use, so a
// bad key / bucket surfaces as 500 on the first call rather
// than blocking boot.
let artwork_storage = match config.artwork.as_ref() {
Some(cfg) => {
let storage = ArtworkStorage::local(&cfg.local_dir)?;
info!(local_dir = %cfg.local_dir.display(), "artwork storage enabled (local backend)");
Some(backend) => {
let storage = ArtworkStorage::from_backend(backend)?;
info!(backend = ?backend, "artwork storage enabled");
Some(storage)
}
None => {
info!("artwork storage disabled (WAVEFLOW_ARTWORK_LOCAL_DIR unset)");
info!(
"artwork storage disabled (set WAVEFLOW_ARTWORK_LOCAL_DIR or \
WAVEFLOW_ARTWORK_S3_BUCKET to enable)"
);
None
}
};
Expand Down
Loading
Loading