From 789bf91eedc70162dcdb6aa0ec3d4b31c9bfbfca Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 09:18:17 -0500 Subject: [PATCH] Complete Phase 4: security hardening, player/playlist/admin UI, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - New internal/netguard package: podcast feed/enclosure fetching now refuses to dial non-public IPs (loopback/RFC1918/link-local/cloud metadata/CGNAT), validated per-connection including across redirects, closing an SSRF gap where a malicious or compromised feed URL could probe the server's internal network. Configurable via VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS for intentional LAN feeds. - Podcast episode downloads capped at 4GB (was unbounded, disk-exhaustion risk) - Cover art decoding now rejects images above a 40-megapixel budget before full decode (decompression-bomb guard) - HTTP server sets ReadHeaderTimeout (slowloris hardening) - Admin folder-scan redirect now validated as same-site instead of trusting the raw Referer header (open-redirect fix) - Legacy Subsonic token/salt auth now uses a constant-time comparison Web UI completion: - Persistent player: shuffle and repeat (off/all/one) controls, wired into queue advance/prev logic and persisted like other player state - Admin user-permissions form: adminRole toggle (with a guard against demoting the last remaining admin) and per-user music-folder access checkboxes, backed by existing schema/enforcement that was previously unreachable from the UI - Playlist M3U8 import/export Docs: added docs/client-compatibility.md (honest tracking template — no client testing has been performed), docs/deployment.md, docs/security.md, docs/backup.md; refreshed README and cross-links; added a footnote in docs/subsonic-compatibility.md distinguishing spec-standard from VibeSonic-original OpenSubsonic extensions. Also added Go fuzz tests for the external-auth CIDR/IP parser and the LRC lyrics parser. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv --- README.md | 121 +++++----- docs/backup.md | 111 ++++++++++ docs/client-compatibility.md | 69 ++++++ docs/configuration.md | 2 + docs/deployment.md | 230 ++++++++++++++++++++ docs/external-auth.md | 10 +- docs/security.md | 133 +++++++++++ docs/subsonic-compatibility.md | 10 + internal/app/app.go | 7 +- internal/config/cidr_fuzz_test.go | 28 +++ internal/config/config.go | 11 + internal/coverart/coverart.go | 16 ++ internal/database/users.go | 8 + internal/lyrics/lrc_fuzz_test.go | 26 +++ internal/netguard/netguard.go | 147 +++++++++++++ internal/netguard/netguard_fuzz_test.go | 53 +++++ internal/netguard/netguard_test.go | 85 ++++++++ internal/podcasts/podcasts.go | 35 ++- internal/podcasts/podcasts_test.go | 5 +- internal/subsonic/auth.go | 3 +- internal/subsonic/podcasts.go | 4 + internal/subsonic/testutil_test.go | 2 +- internal/web/admin_handlers.go | 72 +++++- internal/web/admin_podcasts_handlers.go | 5 + internal/web/playlist_import_export.go | 194 +++++++++++++++++ internal/web/playlist_import_export_test.go | 174 +++++++++++++++ internal/web/router.go | 3 + internal/web/router_test.go | 68 ++++++ web/static/css/app.css | 4 + web/static/js/app.js | 118 +++++++++- web/templates/admin_user_permissions.html | 21 +- web/templates/partials/footer.html | 2 + web/templates/playlist.html | 15 +- web/templates/playlist_import.html | 12 + web/templates/playlist_import_result.html | 9 + web/templates/playlists.html | 5 +- 36 files changed, 1722 insertions(+), 96 deletions(-) create mode 100644 docs/backup.md create mode 100644 docs/client-compatibility.md create mode 100644 docs/deployment.md create mode 100644 docs/security.md create mode 100644 internal/config/cidr_fuzz_test.go create mode 100644 internal/lyrics/lrc_fuzz_test.go create mode 100644 internal/netguard/netguard.go create mode 100644 internal/netguard/netguard_fuzz_test.go create mode 100644 internal/netguard/netguard_test.go create mode 100644 internal/web/playlist_import_export.go create mode 100644 internal/web/playlist_import_export_test.go create mode 100644 web/templates/playlist_import.html create mode 100644 web/templates/playlist_import_result.html diff --git a/README.md b/README.md index 3a63b06..b3cfa0a 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,57 @@ # VibeSonic -VibeSonic is a lightweight, self-hosted music/media server written in Go. -It follows the same philosophy as other small `Vibe*` tools: a single Go -binary, SQLite storage, server-rendered Bootstrap 5 templates, dark mode -by default, minimal JavaScript, and no external services (no Redis, no -message broker, no microservices, nothing Kubernetes-specific). +VibeSonic is a lightweight, self-hosted music/media server written in +Go. Single binary, SQLite storage, server-rendered Bootstrap 5 +templates, dark mode by default, minimal JavaScript, no external +services (no Redis, no message broker, no microservices). -**This is Phase 2 of 4.** Phase 1 built the foundation (config, schema, -auth, scanner, metadata, streaming, cover art, web UI shell). Phase 2 -adds a **complete implementation of the original Subsonic REST API -through version 1.16.1** under `/rest/*` — every endpoint, backed by -real data, including transcoding, HLS, a real mpv-backed jukebox, and a -real podcast subsystem. See +It implements the **original Subsonic REST API through version +1.16.1**, in full, plus a set of **OpenSubsonic extensions**, so it +works with existing Subsonic/OpenSubsonic client apps, alongside a +complete server-rendered web UI for browser use. See [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for -the full endpoint-by-endpoint matrix. OpenSubsonic extensions are not -yet implemented (Phase 3). +the endpoint-by-endpoint matrix and +[`docs/client-compatibility.md`](docs/client-compatibility.md) for +real-client testing status. ## Features -### Phase 1: foundation - -- SQLite storage (WAL mode, foreign keys, versioned migrations) with a - schema already modeling artists, albums, songs, genres, contributors, - playlists, favorites, ratings, scrobbles, playback history, bookmarks, - play queues, shares, podcasts, internet radio, and settings -- Native authentication: Argon2id password hashing, HTTP-only signed - session cookies, CSRF-protected forms, session revocation -- Optional Navidrome-style external authentication via a trusted reverse - proxy header, gated on the real TCP peer address (never on - spoofable `X-Forwarded-For`/`X-Real-IP`/`Forwarded` headers) -- A library scanner supporting multiple music folders, incremental - rescans, new/changed/deleted/renamed file detection, and a bounded - worker pool — never a blind full rebuild -- Real metadata extraction via `ffprobe` (multi-artist, multi-genre, - MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields, and - more) -- Stable, opaque, URL-safe IDs that survive restarts and rescans -- Direct media streaming with HTTP range request support, correct MIME - types, and no full-file buffering -- Cover art discovery (embedded artwork or folder images) with a - resized-image disk cache -- A server-rendered, dark-mode, keyboard-friendly web UI: browsing, - search, playlists, favorites, a persistent bottom player, and an admin - section (users, music folders, scanner status, system settings) +- SQLite storage (WAL mode, foreign keys, versioned migrations) +- Native authentication (Argon2id, HTTP-only session cookies, CSRF) and + optional Navidrome-style external authentication via a trusted + reverse-proxy header, gated on the real TCP peer address — see + [`docs/external-auth.md`](docs/external-auth.md) +- A library scanner: multiple music folders, incremental rescans, + new/changed/deleted/renamed detection, bounded worker pool +- Real metadata extraction via `ffprobe` (multi-artist/genre, + MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields) +- Direct streaming with HTTP range support, plus real ffmpeg-backed + transcoding (mp3/opus/aac) and HLS segmenting, bounded concurrency, + cancelled on client disconnect +- Structured lyrics: plain, line-synced, and (where the source + provides it) word/syllable-level timing, exposed via the + `songLyrics` OpenSubsonic extension and rendered in the web player + with click-to-seek and active-line highlighting +- Sonic similarity: real audio-feature analysis (tempo, loudness, + spectral shape, chroma) computed during scanning, used for + similar-track lookups and path-finding between songs +- Optional MusicBrainz/Cover Art Archive metadata enrichment (off by + default — VibeSonic works fully offline otherwise) +- Podcasts (RSS subscriptions, bounded background downloads, restart + recovery), Internet radio stations, shares (public, no-login-required + links), bookmarks, and multi-device play queues +- A real jukebox backed by `mpv`'s JSON IPC — server-side playback, not + a simulation; cleanly unavailable if `mpv` isn't installed +- API keys, with the full Subsonic per-user permission model + (admin/settings/stream/download/upload/playlist/cover art/comment/ + podcast/share/jukebox/video-conversion roles, plus per-user music + folder access) +- A server-rendered, dark-mode, keyboard-friendly web UI covering + browsing, search, playlists (including M3U import/export), + favorites, a persistent player with shuffle/repeat and the Media + Session API, and a full admin section - `/healthz` and `/readyz` -### Phase 2: Subsonic REST API - -- All 76 endpoints of the original Subsonic API through 1.16.1 — - browsing (filesystem and ID3), search, playlists, favorites/ratings/ - scrobbling, sharing, user management with the full permission model, - bookmarks, play queue, chat, and library scan status -- Real ffmpeg-backed transcoding (mp3/opus/aac) and HLS segmenting for - `stream`/`hls`, bounded concurrency, killed on client disconnect -- A real podcast subsystem: RSS feed refresh, bounded background - downloads, restart recovery, downloaded episodes stream like any other - song -- A real jukebox backed by `mpv` over its JSON IPC socket — server-side - playback, not a simulation; administratively unavailable (with a clear - error) if `mpv` isn't installed -- Both original Subsonic auth modes (token/salt and password, including - `enc:` hex), verified against an AES-256-GCM-encrypted credential kept - separate from the Argon2id hash used for native login — see - [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md#authentication) -- `/rest/*` authentication is entirely independent of the browser - session/CSRF/external-auth flow - ## Quick start ``` @@ -84,6 +70,11 @@ cover art extraction, and transcoding/HLS to work. `mpv` should be on available — without it, `jukeboxControl` returns a clear "unavailable" error rather than pretending to work. +Point any Subsonic/OpenSubsonic client at `http://:8080` (base +URL only, no `/rest` suffix — clients construct that themselves) with a +VibeSonic username/password, or an API key generated from account +settings. + ### Docker ``` @@ -92,10 +83,14 @@ docker compose up -d See [`docker-compose.yml`](docker-compose.yml) — mount your music directory read-only at `/music` and set `VIBESONIC_SESSION_SECRET`. +Docker is convenient but never required; see +[`docs/deployment.md`](docs/deployment.md) for running natively. ### systemd -See [`systemd/vibesonic.service`](systemd/vibesonic.service). +See [`systemd/vibesonic.service`](systemd/vibesonic.service) and +[`docs/deployment.md`](docs/deployment.md) for install steps and +reverse-proxy examples (Caddy/nginx/Traefik). ## Configuration @@ -108,7 +103,11 @@ full list and defaults. There is no separate config file. - [`docs/architecture.md`](docs/architecture.md) — how the pieces fit together - [`docs/configuration.md`](docs/configuration.md) — every environment variable - [`docs/external-auth.md`](docs/external-auth.md) — trusted reverse-proxy authentication -- [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) — protocol endpoint status +- [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) — protocol endpoint/extension status +- [`docs/client-compatibility.md`](docs/client-compatibility.md) — real-client testing tracker +- [`docs/deployment.md`](docs/deployment.md) — native/Docker/systemd/reverse-proxy deployment +- [`docs/security.md`](docs/security.md) — security-relevant design decisions +- [`docs/backup.md`](docs/backup.md) — what to back up and how (SQLite WAL caveats included) ## Development diff --git a/docs/backup.md b/docs/backup.md new file mode 100644 index 0000000..e134fb8 --- /dev/null +++ b/docs/backup.md @@ -0,0 +1,111 @@ +# Backup + +VibeSonic keeps all of its state under one directory, +`VIBESONIC_DATA_DIR` (default `/var/lib/vibesonic`, or `/data` in the +provided Docker image). Everything that must be backed up lives there: + +``` +/ + vibesonic.db SQLite database — users, library index, + playlists, favorites, sessions, API keys, + podcast subscriptions, shares, ... + vibesonic.db-wal SQLite write-ahead log (see below) + vibesonic.db-shm SQLite shared-memory index for the WAL + cache/covers/ resized cover-art cache (safe to delete — + regenerated on demand from source images) + podcasts/ downloaded podcast episodes +``` + +Your music library itself (the folders configured via +`VIBESONIC_MUSIC_DIRS` / the admin "Music Folders" page) is not inside +`DataDir` and has its own backup story — it's your source media, and +VibeSonic never modifies it. + +## What must be backed up + +1. **`vibesonic.db`** (plus `-wal`/`-shm` if present — see below). This + is the only thing whose loss is unrecoverable: it holds every user + account, password/API-key hash, the entire scanned library index, + playlists, favorites/ratings, play history, podcast subscriptions, + share links, and configuration stored in the `settings` table. +2. **`podcasts/`**, if you don't want to re-download subscribed + episodes after a restore. +3. Anything you passed via `VIBESONIC_*` environment variables + (systemd unit, Docker Compose file, `.env`) — these aren't stored in + the database and won't come back from a database restore alone. + +## What does not need to be backed up + +- **`cache/covers/`** — purely a derived cache, regenerated + automatically the first time each cover art size is requested again. + Safe to delete at any time, including while VibeSonic is running. +- **Your music files** — back these up through whatever process you + already use for your media library; VibeSonic treats them as + read-only input, never as its own state. + +## Correct SQLite backup: mind the WAL + +VibeSonic runs SQLite in **WAL (write-ahead log) mode** for concurrency +(`internal/database/database.go`: `PRAGMA journal_mode=WAL`). In WAL +mode, recently-committed data can live in the `vibesonic.db-wal` file +rather than in `vibesonic.db` itself, and isn't merged back +("checkpointed") into the main file on any fixed schedule. + +**Do not just `cp vibesonic.db` from a live server.** A plain file copy +of `vibesonic.db` alone, taken while VibeSonic is running, can miss data +that only exists in the `-wal` file, or copy `vibesonic.db` and +`vibesonic.db-wal` at slightly different points in time and produce an +inconsistent pair. Either failure mode can leave you with a backup that +looks fine (opens without error) but is silently missing recent writes +or is outright corrupt. + +Use one of these instead: + +### Option A — stop the server, then copy (simplest, requires downtime) + +```sh +systemctl stop vibesonic +cp /var/lib/vibesonic/vibesonic.db* /path/to/backup/ +systemctl start vibesonic +``` + +On a clean shutdown SQLite checkpoints and closes the WAL cleanly, so a +plain copy of the (possibly WAL-less) database directory taken while the +process is stopped is always consistent. + +### Option B — the SQLite online backup API, via the `sqlite3` CLI (no downtime) + +`sqlite3`'s `.backup` command uses SQLite's own backup API, which +correctly handles an in-progress WAL and produces a consistent snapshot +without stopping VibeSonic or blocking writers for more than the brief +final copy step: + +```sh +sqlite3 /var/lib/vibesonic/vibesonic.db ".backup /path/to/backup/vibesonic.db" +``` + +Do this on a schedule (cron/systemd timer) and rotate the output. + +### Option C — filesystem snapshot (LVM, ZFS, Btrfs) + +A crash-consistent filesystem snapshot of the whole `DataDir` (taken +atomically, e.g. `zfs snapshot` or an LVM snapshot) is also safe — SQLite +in WAL mode is designed to recover cleanly from a snapshot equivalent to +an ungraceful power loss, the same way it recovers on the next open +after a crash. Plain `rsync`/`cp` while the server is running is **not** +equivalent to this, since it isn't atomic across the `.db`/`-wal`/`-shm` +trio. + +## Restoring + +1. Stop VibeSonic. +2. Replace `vibesonic.db` (and `-wal`/`-shm` if you backed those up too) + with the backup copy. +3. Restore `podcasts/` if you backed it up. +4. Start VibeSonic. It runs pending migrations automatically on + startup, same as any other launch. + +If you only have a `vibesonic.db` from Option B/C without matching +`-wal`/`-shm` files, that's fine — `.backup` and filesystem snapshots +both produce a database file that's already fully checkpointed/consistent +on its own. diff --git a/docs/client-compatibility.md b/docs/client-compatibility.md new file mode 100644 index 0000000..cd5bccf --- /dev/null +++ b/docs/client-compatibility.md @@ -0,0 +1,69 @@ +# Client compatibility + +This is a **tracking template**, not a report of testing that has +already happened. As of this phase, no real Subsonic/OpenSubsonic +client apps have been run against VibeSonic in this development +environment — every row below is unfilled and marked accordingly. Please +do not read the table as evidence of compatibility; read +[`docs/subsonic-compatibility.md`](subsonic-compatibility.md) for that — +it documents endpoint-by-endpoint protocol conformance validated by +VibeSonic's own automated HTTP-level test suite (`internal/subsonic/*_test.go`), +which is real evidence, exercised on every change. This document exists +to capture *real device/app* testing once someone actually does it. + +## How to test a client and fill in a row + +1. Point the client at your VibeSonic server URL, e.g. + `https://music.example.com` or `http://:8080` for a direct, + un-proxied connection. Most Subsonic clients want the base URL only + (no `/rest` suffix) plus a username/password — they construct + `/rest/*` requests themselves. +2. **Authentication**: try native username/password first (works whether + or not external auth is enabled, as long as the account has a local + password — see [`external-auth.md`](external-auth.md)). If the client + supports OpenSubsonic API keys (the `apiKeyAuthentication` extension — + see [`subsonic-compatibility.md`](subsonic-compatibility.md#opensubsonic-extensions)), + generate one from the web UI's account settings and test that path + too, noting which the client actually used. +3. Work through: server discovery/ping, browsing (artists/albums/songs, + both filesystem and ID3 modes if the client exposes both), search, + cover art, direct streaming, forced transcoding (pick a bitrate/format + the source file doesn't already match), playlists (create/edit/ + delete/reorder), favorites/star, scrobbling, lyrics (plain and + synced, if the client supports OpenSubsonic's `songLyrics`), the play + queue, downloads, podcasts (if the client has podcast support), and + any OpenSubsonic-specific features the client advertises. +4. Fill in the row below with what actually worked, partially worked, or + didn't — and describe **Known Issues** concretely (not "broken", but + "album art doesn't load for tracks with only embedded art, HTTP 404" + or similar) so someone else can reproduce or fix it. +5. If a client needs a compatibility workaround (bending strict protocol + behavior to accommodate something the client gets wrong), that + exception must be documented here **and** in + [`subsonic-compatibility.md`](subsonic-compatibility.md) next to the + affected endpoint — don't silently special-case a client in code + without a paper trail. + +## Tracking table + +| Client | Version | Platform | Authentication | Browsing | Streaming | Transcoding | Playlists | Lyrics | OpenSubsonic | Known Issues | +|---|---|---|---|---|---|---|---|---|---|---| +| Symfonium | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| Ultrasonic | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| Tempo | — | iOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| Amperfy | — | iOS/macOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| play:Sub | — | iOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| Subtracks | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| DSub | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| Supersonic | — | Desktop (Linux/macOS/Windows) | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | +| Feishin | — | Desktop/Web | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — | + +Add new rows for any other client you test — this list isn't exhaustive, +just a reasonable starting set across ecosystems (Android, iOS, desktop). + +## Compatibility exceptions + +None recorded yet. If a real client requires bending protocol behavior +to work correctly, document the exception here: which client/version, +what VibeSonic does differently and why, and a link to the affected +endpoint in [`subsonic-compatibility.md`](subsonic-compatibility.md). diff --git a/docs/configuration.md b/docs/configuration.md index f60a2b8..07f7574 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,8 @@ except the session secret. | `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. | | `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. | | `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. | +| `VIBESONIC_ENABLE_EXTERNAL_METADATA` | `false` | Enable optional MusicBrainz/Cover Art Archive lookups for songs/albums that already carry a locally-tagged MusicBrainz ID. VibeSonic works fully offline with this off; it's the only feature that makes outbound Internet requests. | +| `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow podcast feed/enclosure URLs to resolve to loopback/LAN/link-local addresses. Leave this off unless you intentionally host podcast feeds on a private network — see [`docs/security.md`](security.md#podcast-fetching-ssrf-protections). | ## External authentication diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..34ea517 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,230 @@ +# Deployment + +VibeSonic is a single static binary plus `ffmpeg`/`ffprobe` on `PATH` +(and optionally `mpv`, for the jukebox feature). Docker is convenient +but never required for normal operation. + +## Configuration + +All configuration is via `VIBESONIC_*` environment variables — see +[`docs/configuration.md`](configuration.md) for the full list. There is +no config file. + +## Running natively + +```sh +go build -o vibesonic ./cmd/vibesonic + +export VIBESONIC_DATA_DIR=/var/lib/vibesonic +export VIBESONIC_MUSIC_DIRS=/music +export VIBESONIC_SESSION_SECRET="$(openssl rand -hex 32)" +./vibesonic +``` + +For a persistent deployment, see +[`systemd/vibesonic.service`](../systemd/vibesonic.service) — install it +with: + +```sh +sudo useradd --system --home /var/lib/vibesonic --shell /usr/sbin/nologin vibesonic +sudo install -m 0755 vibesonic /usr/local/bin/vibesonic +sudo cp systemd/vibesonic.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now vibesonic +``` + +The unit runs as a dedicated `vibesonic` user (not `DynamicUser`, +because it needs a stable UID with read access to your music library — +`DynamicUser` allocates a random UID per start, which doesn't compose +well with a fixed set of file permissions on external media). Grant that +user read access to your music folders: + +```sh +sudo usermod -aG vibesonic +# or, more simply, make the music tree group- or world-readable +``` + +`StateDirectory=vibesonic` in the unit creates and owns +`/var/lib/vibesonic` (mode `0700`) for you — don't create it manually +with different permissions first. The unit also applies standard +hardening (`ProtectSystem=strict`, `NoNewPrivileges`, `PrivateDevices`, +etc.) and `Restart=on-failure`. + +Remember to set a real `VIBESONIC_SESSION_SECRET` in the unit (or in an +`EnvironmentFile=`) — the shipped example is a placeholder. + +## Running with Docker + +```sh +docker build -t vibesonic . +``` + +Runtime image is `debian:bookworm-slim` with `ffmpeg`, `mpv`, and CA +certificates installed, running as a non-root `vibesonic` user (uid +10001). See [`Dockerfile`](../Dockerfile). + +Example Compose file (also in [`docker-compose.yml`](../docker-compose.yml)): + +```yaml +services: + vibesonic: + image: vibesonic + ports: + - "8080:8080" + volumes: + - ./data:/var/lib/vibesonic + - /mnt/music:/music:ro + environment: + VIBESONIC_SESSION_SECRET: "change-me-to-a-random-string" +``` + +Mount your music read-only (`:ro`) — VibeSonic never writes to it. +`./data` (or a named volume) holds the SQLite database, cover art cache, +and downloaded podcast episodes; back it up per +[`docs/backup.md`](backup.md). + +## Reverse proxy + +Run VibeSonic behind a reverse proxy for TLS termination in any +real deployment; the examples below forward to VibeSonic on +`127.0.0.1:8080`. + +VibeSonic does not use WebSockets. Streaming and transcoded responses +are ordinary long-lived HTTP responses using standard range requests — +make sure your proxy doesn't buffer the entire response body before +forwarding it (buffering breaks range requests and adds needless +latency to playback start). + +### Caddy + +``` +music.example.com { + reverse_proxy 127.0.0.1:8080 +} +``` + +Caddy handles TLS automatically and streams request/response bodies +without buffering by default — no extra configuration needed for range +requests or large responses. + +### nginx + +```nginx +server { + listen 443 ssl; + server_name music.example.com; + + ssl_certificate /etc/letsencrypt/live/music.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/music.example.com/privkey.pem; + + client_max_body_size 0; + proxy_buffering off; + proxy_request_buffering off; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + } +} +``` + +`proxy_buffering off` matters here: nginx buffers proxied responses to +disk/memory by default, which delays the start of playback and range +responses. `client_max_body_size 0` avoids nginx rejecting large file +uploads if the upload feature is used. + +### Traefik + +```yaml +http: + routers: + vibesonic: + rule: "Host(`music.example.com`)" + service: vibesonic + tls: + certResolver: letsencrypt + services: + vibesonic: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" +``` + +Traefik streams responses without buffering by default, same as Caddy. + +## Reverse proxy + external (trusted-proxy) authentication + +If you're using [`docs/external-auth.md`](external-auth.md) to have the +proxy handle SSO (OIDC, SAML, HTTP Basic Auth, ...) for the browser UI, +two things are essential: + +1. **The proxy must strip any client-supplied identity header before + inserting its own.** If it doesn't, a client can set + `Remote-User: admin` (or whatever header name you configured) on its + own request and have the proxy forward it unchanged, impersonating + any user. Every proxy example below includes this. + +2. **`/rest/*` must bypass the proxy's SSO layer.** Real Subsonic/ + OpenSubsonic clients (mobile apps, desktop players) authenticate + directly against VibeSonic's own token/API-key scheme and have no way + to complete a browser-based SSO flow (OIDC redirect, SAML POST, + etc.). Exclude `/rest/*` from the SSO requirement so those clients + keep working, while still letting `/rest/*` reach VibeSonic normally + — VibeSonic's own `/rest/*` auth is independent of external auth + either way. + +### Caddy example (with `forward_auth` for SSO + header stripping) + +``` +music.example.com { + # Strip any client-supplied identity header before forward_auth + # potentially sets a real one, so a client can't forge it. + header_down -Remote-User + + @needsAuth { + not path /rest/* + } + forward_auth @needsAuth auth.example.com:9091 { + uri /verify + copy_headers Remote-User + } + + reverse_proxy 127.0.0.1:8080 +} +``` + +### nginx example (with `auth_request` for SSO + header stripping) + +```nginx +server { + # ... TLS config as above ... + + location /rest/ { + # Subsonic/OpenSubsonic clients: no SSO, straight through. + proxy_pass http://127.0.0.1:8080; + proxy_set_header Remote-User ""; # never forward a client-supplied value + } + + location / { + auth_request /sso-verify; + auth_request_set $remote_user $upstream_http_remote_user; + proxy_set_header Remote-User $remote_user; + proxy_pass http://127.0.0.1:8080; + } + + location = /sso-verify { + internal; + proxy_pass http://auth.example.com/verify; + proxy_set_header X-Original-URI $request_uri; + } +} +``` + +Either way, also set `VIBESONIC_EXTAUTH_TRUSTED_SOURCES` to only the +proxy's own address (e.g. `127.0.0.1/32` if it's on the same host) — +VibeSonic independently verifies the real TCP peer, so even a +misconfigured proxy can't be used to spoof identity from the internet +directly. See [`docs/external-auth.md`](external-auth.md) for that +check in detail. diff --git a/docs/external-auth.md b/docs/external-auth.md index f8c3c3f..6c92b43 100644 --- a/docs/external-auth.md +++ b/docs/external-auth.md @@ -59,6 +59,10 @@ password on VibeSonic's own `/login` page even when external auth is enabled, unless their account has no password hash set (auto-created external users start this way). -In later phases, `/rest/*` (the native Subsonic API) will authenticate -exclusively via Subsonic's own token/API-key scheme and must never be -affected by external auth's browser-cookie flow. +`/rest/*` (the Subsonic/OpenSubsonic API) never consults external auth +at all: it authenticates exclusively via Subsonic's own token/salt, +password, or `apiKey` scheme (see `internal/subsonic/auth.go`), so real +Subsonic/OpenSubsonic clients keep working unmodified even when external +auth fronts the browser UI. This is also why a reverse proxy enforcing +SSO in front of VibeSonic should let `/rest/*` bypass that SSO layer — +see `docs/deployment.md` for example proxy configuration. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..0a4e145 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,133 @@ +# Security + +This documents VibeSonic's security-relevant design decisions and the +configuration knobs that affect them. See also +[`docs/external-auth.md`](external-auth.md) for the trusted-proxy +authentication model in detail. + +## Authentication + +- **Passwords** are hashed with Argon2id (`internal/auth/password.go`). + VibeSonic never stores or logs a plaintext password. +- **Subsonic legacy token/salt auth** (`t=`/`s=` params, used by older + Subsonic clients) requires comparing an MD5 digest against one derived + from the stored password — but MD5 hashes aren't reversible into + Argon2id, so VibeSonic separately keeps an AES-256-GCM-encrypted copy + of the plaintext password (`internal/auth/subsonic_secret.go`), keyed + by a server-held encryption key, solely to support this legacy scheme. + This is an inherent property of the Subsonic protocol, not a VibeSonic + design choice — every Subsonic-compatible server needs some way to + recover the plaintext to validate `t=md5(password+salt)`. The + comparison itself uses a constant-time compare + (`crypto/subtle.ConstantTimeCompare`) to avoid a timing side-channel. +- **API keys** are stored and looked up by hash, never in plaintext. +- **Sessions** are random opaque tokens in an HTTP-only, `Secure` + (configurable via `VIBESONIC_COOKIE_SECURE`, on by default) cookie. +- **External (trusted-proxy) authentication** is off by default and, + when enabled, trusts an identity-asserting header only from + configured source IPs verified against the real TCP peer address — + never against spoofable headers like `X-Forwarded-For`. Full detail in + [`docs/external-auth.md`](external-auth.md). +- `/rest/*` (the Subsonic/OpenSubsonic API) authenticates independently + of external auth, via token/salt, password, or `apiKey`, so standard + Subsonic clients work unmodified regardless of how the browser UI is + fronted. + +## CSRF + +State-changing browser routes are protected by a double-submit CSRF +token (`internal/auth/csrf.go`), compared in constant time. The +`/rest/*` API is exempt by design — it's a machine API authenticated per +request (token/password/API key), not cookie-session-based, so CSRF +doesn't apply to it the way it does to browser form posts. + +## Path traversal + +Every file served — streamed audio, downloads, cover art — is resolved +through `internal/streaming.ResolvePath`, which joins the stored +relative path onto its music folder root, resolves symlinks, and +verifies the result is still inside that root before anything is opened. +A request cannot escape its folder via `../` segments or a symlink +pointing outside the folder. + +## Podcast fetching: SSRF protections + +Podcast feed and episode-enclosure URLs are, by nature, externally +supplied (an admin adds a feed URL; that feed's own content — including +HTTP redirects — is not otherwise controlled by VibeSonic). Without +restriction, this would let a malicious or compromised feed turn +VibeSonic into a proxy onto its own internal network: pointing a feed +URL, or a redirect from one, at `127.0.0.1`, an RFC1918 LAN address, or +a cloud metadata endpoint (`169.254.169.254`). + +`internal/netguard` closes this off for every connection the podcast +manager's HTTP client makes, **including connections made while +following a redirect** (so a feed can't pass an initial URL check and +then redirect somewhere unsafe), by validating the actual IP address +about to be dialed: + +- Loopback, RFC1918/RFC4193 private ranges, link-local (including the + `169.254.169.254` cloud metadata address), carrier-grade NAT + (`100.64.0.0/10`), multicast, and unspecified addresses are all + rejected. +- The check runs at dial time against the specific resolved IP, not a + hostname re-resolved later — so a DNS record that changes between the + initial check and the connection ("DNS rebinding") can't bypass it. +- Redirects are capped at 5 hops and restricted to `http`/`https`. +- Episode downloads are capped at 4 GB so a misbehaving or malicious + feed can't exhaust disk space via an unbounded response. + +For deployments that intentionally host podcast feeds on a private +network, set `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS=true` to disable +the IP restriction (the size cap and redirect limit still apply). Leave +this off otherwise. + +## Cover art / image handling + +Source image files are size-capped before decoding, and — separately — +their *decoded pixel dimensions* are checked via `image.DecodeConfig` +before the full image is decoded into memory, rejecting anything over +40 megapixels. This prevents a small, highly-compressed image file from +forcing an out-of-proportion memory allocation ("decompression bomb"). + +## Transcoding + +`ffmpeg`/`ffprobe` are invoked with `exec.CommandContext` and an +explicit argument-vector slice — never through a shell — so there is no +shell-metacharacter injection surface. Output paths are passed after a +literal `--` argument so a generated filename can never be +misinterpreted as an additional flag. Transcode concurrency is bounded +by a worker pool, and a client disconnect cancels the underlying +`ffmpeg` process via the request context. + +## HTTP server hardening + +The server sets `ReadHeaderTimeout` to bound how long a client can take +to send request headers, mitigating slow-header ("Slowloris"-style) +connection exhaustion. `WriteTimeout` is deliberately left unset: +audio/video streaming and transcoded output are long-lived writes that a +fixed write deadline would break. + +## Redirects + +All same-site post-action redirects (login's `next=`, playlist actions, +favorites, admin actions) are validated through a shared +`safeRedirectPath` helper that only accepts a same-site relative path — +rejecting protocol-relative (`//evil.example`) or absolute +(`https://evil.example`) targets — before being used in an +`http.Redirect`, so a crafted link can't be used to bounce a logged-in +user's browser off-site. + +## Logging + +VibeSonic logs operational events (startup/shutdown, migrations, +scanner activity, authentication failures, admin actions, transcode/ +podcast failures) via structured logging. It never logs passwords, +session tokens, API keys, Subsonic auth tokens/salts, or `Authorization` +headers. + +## Reporting a vulnerability + +If you find a security issue, please report it privately rather than +opening a public issue, so a fix can be prepared before the details are +public. diff --git a/docs/subsonic-compatibility.md b/docs/subsonic-compatibility.md index f0829cc..e7d104e 100644 --- a/docs/subsonic-compatibility.md +++ b/docs/subsonic-compatibility.md @@ -239,6 +239,16 @@ in `internal/subsonic/opensubsonic_core_test.go` fails the build if an extension is ever registered without its handler actually existing, so this list can't drift ahead of what's real. +Of the extensions below, `apiKeyAuthentication`, `formPost`, and +`songLyrics` come from the published OpenSubsonic extension list. +`getPodcastEpisode`, `indexBasedQueue`, `playbackReport`, +`topSongsByArtistId`, `transcodeOffset`, `transcoding`, and +`sonicSimilarity` are **VibeSonic-original** extensions — real, +implemented, and advertised the same way as any other extension, but +not part of the OpenSubsonic project's own spec, so don't assume +another OpenSubsonic server implements them just because it advertises +OpenSubsonic support in general. + | Extension | Versions | Endpoint(s) | Tested | Notes | |---|---|---|---|---| | `apiKeyAuthentication` | 1 | `tokenInfo`, `apiKey=` param on every endpoint | ✅ | Per-user named keys with plaintext shown once, SHA-256 hash stored, creation/last-used tracking, revocation (self or admin). `apiKey` conflicting with `u` returns error 43; an invalid/revoked key returns error 44. Managed at Settings → API Keys; admins see/revoke everyone's keys at `/admin/api-keys`. | diff --git a/internal/app/app.go b/internal/app/app.go index 30002bd..c295def 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -73,7 +73,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { return nil, fmt.Errorf("deriving auth encryption key: %w", err) } - podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log) + podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log, cfg.PodcastsAllowPrivateNetworks) if err := podcastMgr.RecoverInterruptedDownloads(); err != nil { log.Warn("podcast download recovery failed", "err", err) } @@ -165,8 +165,9 @@ func (a *App) Run(ctx context.Context) error { mux.Handle("/", a.Web.NewRouter()) srv := &http.Server{ - Addr: a.Cfg.ListenAddr, - Handler: mux, + Addr: a.Cfg.ListenAddr, + Handler: mux, + ReadHeaderTimeout: 15 * time.Second, } errCh := make(chan error, 1) diff --git a/internal/config/cidr_fuzz_test.go b/internal/config/cidr_fuzz_test.go new file mode 100644 index 0000000..9fc770e --- /dev/null +++ b/internal/config/cidr_fuzz_test.go @@ -0,0 +1,28 @@ +package config + +import "testing" + +// FuzzParseCIDROrIP guards against a crash in the +// VIBESONIC_EXTAUTH_TRUSTED_SOURCES parser, which is security-sensitive: +// it decides which peers are trusted to assert an authenticated +// identity via external auth. +func FuzzParseCIDROrIP(f *testing.F) { + f.Add("127.0.0.1/32") + f.Add("10.0.0.0/8") + f.Add("::1") + f.Add("fd00::/8") + f.Add("") + f.Add("not-an-ip") + f.Add("127.0.0.1/99") + f.Add("999.999.999.999") + f.Add("::/0") + + f.Fuzz(func(t *testing.T, s string) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("parseCIDROrIP panicked on %q: %v", s, r) + } + }() + _, _ = parseCIDROrIP(s) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index c2ccaa3..005b096 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,15 @@ type Config struct { // to the outside world. EnableExternalMetadata bool + // PodcastsAllowPrivateNetworks disables SSRF protection on podcast + // feed/enclosure fetching, allowing feeds hosted on loopback/LAN/ + // link-local addresses. Off by default: podcast URLs are supplied + // by an admin (or, in principle, a compromised feed's redirects) + // and must not be usable to probe the server's own internal + // network. Only enable this for deployments that intentionally + // host podcast feeds on a private network. + PodcastsAllowPrivateNetworks bool + ExtAuth ExtAuthConfig } @@ -62,6 +71,8 @@ func Load() (Config, error) { CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true), EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false), + + PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false), } cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db")) diff --git a/internal/coverart/coverart.go b/internal/coverart/coverart.go index 741c9c1..b8c7352 100644 --- a/internal/coverart/coverart.go +++ b/internal/coverart/coverart.go @@ -12,6 +12,7 @@ import ( _ "image/gif" "image/jpeg" _ "image/png" + "io" "os" "os/exec" "path/filepath" @@ -19,6 +20,12 @@ import ( "golang.org/x/image/draw" ) +// maxDecodePixels bounds the decoded pixel count of a source image so a +// small, highly-compressed file (a "decompression bomb") can't force an +// enormous in-memory pixel buffer allocation. 40 megapixels comfortably +// covers any legitimate cover art. +const maxDecodePixels = 40_000_000 + // commonFolderImageNames are checked, in priority order, for a // folder-level cover image when a file has no embedded artwork. var commonFolderImageNames = []string{ @@ -112,6 +119,15 @@ func (s *Store) GetOrCreate(sourcePath, contentHash string, size int) (string, e } defer src.Close() + if cfg, _, err := image.DecodeConfig(src); err == nil { + if cfg.Width*cfg.Height > maxDecodePixels { + return "", fmt.Errorf("source image %s exceeds %d pixel limit (%dx%d)", sourcePath, maxDecodePixels, cfg.Width, cfg.Height) + } + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", err + } + img, _, err := image.Decode(src) if err != nil { return "", fmt.Errorf("decoding image: %w", err) diff --git a/internal/database/users.go b/internal/database/users.go index 032fbed..b5d83b3 100644 --- a/internal/database/users.go +++ b/internal/database/users.go @@ -144,6 +144,14 @@ func ListUsers(db *sql.DB) ([]*User, error) { return out, rows.Err() } +// CountAdmins returns the number of enabled admin accounts, so callers +// can refuse to demote or disable the last one. +func CountAdmins(db *sql.DB) (int, error) { + var n int + err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1 AND disabled = 0`).Scan(&n) + return n, err +} + func scanUserRow(rows *sql.Rows) (*User, error) { var u User var isAdmin, disabled, external int diff --git a/internal/lyrics/lrc_fuzz_test.go b/internal/lyrics/lrc_fuzz_test.go new file mode 100644 index 0000000..dfa993b --- /dev/null +++ b/internal/lyrics/lrc_fuzz_test.go @@ -0,0 +1,26 @@ +package lyrics + +import "testing" + +// FuzzParse guards against a crash/panic in the LRC parser when fed +// arbitrary bytes — lyrics come from sidecar files and embedded tags +// found while scanning a music library, so the content is effectively +// untrusted input. +func FuzzParse(f *testing.F) { + f.Add("[00:12.34]First line\n[00:15.00]Second line") + f.Add("[la:en]\n[00:12.34]<00:12.34>Hello <00:12.90>world") + f.Add("") + f.Add("[not a tag") + f.Add("[99:99.999999999999]overflowing timestamp") + f.Add("[00:12.34][00:15.00]shared timestamp line") + f.Add("no timestamps at all, just plain text") + + f.Fuzz(func(t *testing.T, content string) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("Parse panicked on %q: %v", content, r) + } + }() + Parse(content) + }) +} diff --git a/internal/netguard/netguard.go b/internal/netguard/netguard.go new file mode 100644 index 0000000..c93f2ef --- /dev/null +++ b/internal/netguard/netguard.go @@ -0,0 +1,147 @@ +// Package netguard builds HTTP clients that refuse to connect to +// non-public IP addresses. It exists so features that fetch +// externally-controlled URLs (podcast feeds and enclosures, most +// notably) cannot be turned into a proxy onto the server's own +// loopback interface, its LAN, or cloud-metadata endpoints +// (169.254.169.254 and friends) via a malicious or compromised feed +// URL, a redirect, or DNS rebinding. +// +// The check is enforced at dial time, on the specific IP address about +// to be connected to, for every connection the client makes — including +// ones made while following a redirect. Because the resolved IP is +// validated immediately before dialing that exact address (rather than +// re-resolving the hostname later), a DNS record that changes between +// requests cannot rebind a validated hostname onto a blocked address. +package netguard + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "time" +) + +// AllowedSchemes are the only URL schemes a guarded client will ever +// dial; anything else (file://, ftp://, gopher://, ...) is rejected +// before a connection is attempted. +var AllowedSchemes = map[string]bool{"http": true, "https": true} + +// ValidateURL rejects an obviously-bad externally-supplied URL (wrong +// scheme, missing host) before it's stored or fetched. This is a +// fast, user-facing sanity check; the actual SSRF protection is +// enforced at connection time by the client NewSafeHTTPClient returns, +// regardless of what passes this check. +func ValidateURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if !AllowedSchemes[u.Scheme] { + return fmt.Errorf("URL must be http or https") + } + if u.Host == "" { + return fmt.Errorf("URL must include a host") + } + return nil +} + +// NewSafeHTTPClient returns an *http.Client that only connects to +// public IP addresses, unless allowPrivateNetworks is true (for +// deployments that intentionally point podcast feeds at a LAN/self- +// hosted server). It also refuses to follow a redirect more than 5 +// times or onto a non-http(s) scheme. +func NewSafeHTTPClient(timeout time.Duration, allowPrivateNetworks bool) *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second} + transport := &http.Transport{ + DialContext: safeDialContext(dialer, allowPrivateNetworks), + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 20 * time.Second, + ForceAttemptHTTP2: true, + } + return &http.Client{ + Timeout: timeout, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("stopped after 5 redirects") + } + if !AllowedSchemes[req.URL.Scheme] { + return fmt.Errorf("refusing to follow redirect to unsupported scheme %q", req.URL.Scheme) + } + return nil + }, + } +} + +func safeDialContext(base *net.Dialer, allowPrivateNetworks bool) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + // If the host is already a literal IP, LookupIP still handles + // it (returning it unchanged) without a real DNS round trip. + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + + var lastErr error + for _, ip := range ips { + if !allowPrivateNetworks && !IsPublicIP(ip) { + lastErr = fmt.Errorf("refusing to connect to non-public address %s", ip) + continue + } + conn, dialErr := base.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + if lastErr == nil { + lastErr = fmt.Errorf("no addresses found for %s", host) + } + return nil, lastErr + } +} + +// IsPublicIP reports whether ip is a globally routable unicast address +// that a server-side fetcher should be allowed to connect to. It +// rejects loopback, link-local (including the 169.254.169.254 cloud +// metadata address), RFC1918/RFC4193 private ranges, carrier-grade NAT +// (100.64.0.0/10), multicast, and unspecified addresses. +func IsPublicIP(ip net.IP) bool { + ip = ip.To16() + if ip == nil { + return false + } + if v4 := ip.To4(); v4 != nil { + if cgnat.Contains(v4) { + return false + } + } + switch { + case ip.IsLoopback(), + ip.IsPrivate(), + ip.IsLinkLocalUnicast(), + ip.IsLinkLocalMulticast(), + ip.IsInterfaceLocalMulticast(), + ip.IsMulticast(), + ip.IsUnspecified(): + return false + } + return true +} + +var cgnat = mustParseCIDR("100.64.0.0/10") + +func mustParseCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return n +} diff --git a/internal/netguard/netguard_fuzz_test.go b/internal/netguard/netguard_fuzz_test.go new file mode 100644 index 0000000..63e79a5 --- /dev/null +++ b/internal/netguard/netguard_fuzz_test.go @@ -0,0 +1,53 @@ +package netguard + +import ( + "net" + "testing" +) + +// FuzzValidateURL guards against a crash in the podcast feed/enclosure +// URL sanity check — the URLs it validates are admin-supplied but the +// check must never panic regardless of input. +func FuzzValidateURL(f *testing.F) { + f.Add("https://example.com/feed.xml") + f.Add("http://127.0.0.1/feed") + f.Add("ftp://example.com") + f.Add("not a url at all") + f.Add("") + f.Add("http://") + f.Add("javascript:alert(1)") + f.Add("http://[::1]:8080/feed") + + f.Fuzz(func(t *testing.T, raw string) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("ValidateURL panicked on %q: %v", raw, r) + } + }() + _ = ValidateURL(raw) + }) +} + +// FuzzIsPublicIP guards the address classifier that decides whether a +// resolved IP is safe to connect to. +func FuzzIsPublicIP(f *testing.F) { + f.Add("8.8.8.8") + f.Add("127.0.0.1") + f.Add("::1") + f.Add("169.254.169.254") + f.Add("100.64.0.1") + f.Add("::ffff:127.0.0.1") + + f.Fuzz(func(t *testing.T, s string) { + ip := net.ParseIP(s) + if ip == nil { + return + } + defer func() { + if r := recover(); r != nil { + t.Fatalf("IsPublicIP panicked on %q: %v", s, r) + } + }() + IsPublicIP(ip) + }) +} diff --git a/internal/netguard/netguard_test.go b/internal/netguard/netguard_test.go new file mode 100644 index 0000000..8f758c9 --- /dev/null +++ b/internal/netguard/netguard_test.go @@ -0,0 +1,85 @@ +package netguard + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestIsPublicIP(t *testing.T) { + cases := map[string]bool{ + "8.8.8.8": true, + "1.1.1.1": true, + "127.0.0.1": false, + "10.0.0.1": false, + "172.16.0.1": false, + "192.168.1.1": false, + "169.254.169.254": false, // cloud metadata + "100.64.0.1": false, // CGNAT + "0.0.0.0": false, + "::1": false, + "fe80::1": false, + "fc00::1": false, + "2606:4700::1111": true, + } + for s, want := range cases { + ip := net.ParseIP(s) + if ip == nil { + t.Fatalf("bad test IP %q", s) + } + if got := IsPublicIP(ip); got != want { + t.Errorf("IsPublicIP(%q) = %v, want %v", s, got, want) + } + } +} + +func TestSafeHTTPClientBlocksLoopback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + client := NewSafeHTTPClient(5*time.Second, false) + _, err := client.Get(srv.URL) + if err == nil { + t.Fatal("expected request to loopback server to be blocked") + } +} + +func TestSafeHTTPClientAllowsLoopbackWhenPermitted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + client := NewSafeHTTPClient(5*time.Second, true) + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("expected request to loopback server to succeed: %v", err) + } + resp.Body.Close() +} + +func TestSafeHTTPClientBlocksRedirectToPrivate(t *testing.T) { + blocked := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("should never be reached")) + })) + defer blocked.Close() + + // A "public-looking" server (still loopback in this test, but with + // AllowPrivateNetworks=false the initial dial to it would itself be + // blocked — so instead verify redirect scheme enforcement directly + // via CheckRedirect using a context that never dials at all). + client := NewSafeHTTPClient(5*time.Second, false) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, blocked.URL, nil) + if err := client.CheckRedirect(req, nil); err != nil { + t.Fatalf("unexpected error for allowed scheme: %v", err) + } + req.URL.Scheme = "file" + if err := client.CheckRedirect(req, nil); err == nil { + t.Fatal("expected file:// redirect to be rejected") + } +} diff --git a/internal/podcasts/podcasts.go b/internal/podcasts/podcasts.go index b5a7117..8bbb614 100644 --- a/internal/podcasts/podcasts.go +++ b/internal/podcasts/podcasts.go @@ -23,9 +23,16 @@ import ( "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/idgen" "git.noroutes.net/NoRoutes/vibetunes/internal/metadata" + "git.noroutes.net/NoRoutes/vibetunes/internal/netguard" ) -const maxConcurrentDownloads = 2 +const ( + maxConcurrentDownloads = 2 + // maxEnclosureSize bounds a single episode download so a malicious + // or misbehaving feed can't exhaust disk space; legitimate podcast + // episodes are essentially never larger than this. + maxEnclosureSize = 4 << 30 // 4GB +) type Manager struct { DB *sql.DB @@ -37,12 +44,18 @@ type Manager struct { sem chan struct{} } -func NewManager(db *sql.DB, dataDir, ffprobePath string, log *slog.Logger) *Manager { +// NewManager builds a podcast manager whose HTTP client refuses to +// connect to non-public (loopback/LAN/link-local/cloud-metadata) +// addresses — feed URLs and episode enclosure URLs are attacker- or +// admin-supplied and must never turn VibeSonic into an internal +// network proxy. Set allowPrivateNetworks only for deployments that +// intentionally host feeds on a private network. +func NewManager(db *sql.DB, dataDir, ffprobePath string, log *slog.Logger, allowPrivateNetworks bool) *Manager { return &Manager{ DB: db, DataDir: dataDir, FFprobePath: ffprobePath, - HTTPClient: &http.Client{Timeout: 60 * time.Second}, + HTTPClient: netguard.NewSafeHTTPClient(60*time.Second, allowPrivateNetworks), Log: log, sem: make(chan struct{}, maxConcurrentDownloads), } @@ -78,6 +91,11 @@ func (m *Manager) RefreshChannel(ctx context.Context, channelID string) error { } _ = database.SetPodcastChannelStatus(m.DB, channelID, "downloading", "") + if err := netguard.ValidateURL(channel.URL); err != nil { + _ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error()) + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, channel.URL, nil) if err != nil { _ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error()) @@ -235,6 +253,9 @@ func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error { } func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error { + if err := netguard.ValidateURL(sourceURL); err != nil { + return err + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { return err @@ -253,11 +274,17 @@ func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string if err != nil { return err } - if _, err := io.Copy(out, resp.Body); err != nil { + n, err := io.Copy(out, io.LimitReader(resp.Body, maxEnclosureSize+1)) + if err != nil { out.Close() os.Remove(tmp) return err } + if n > maxEnclosureSize { + out.Close() + os.Remove(tmp) + return fmt.Errorf("enclosure exceeds maximum allowed size of %d bytes", maxEnclosureSize) + } if err := out.Close(); err != nil { os.Remove(tmp) return err diff --git a/internal/podcasts/podcasts_test.go b/internal/podcasts/podcasts_test.go index 256f7f8..e75b99f 100644 --- a/internal/podcasts/podcasts_test.go +++ b/internal/podcasts/podcasts_test.go @@ -79,7 +79,10 @@ func newTestManager(t *testing.T) (*Manager, string) { } t.Cleanup(func() { db.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) - return NewManager(db, dir, "", log), dir + // Tests talk to httptest.Server, which listens on loopback, so + // private networks must be allowed here even though production + // defaults to blocking them (see internal/netguard). + return NewManager(db, dir, "", log, true), dir } func TestRefreshChannel(t *testing.T) { diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go index 91ce16a..5c6b4d6 100644 --- a/internal/subsonic/auth.go +++ b/internal/subsonic/auth.go @@ -2,6 +2,7 @@ package subsonic import ( "crypto/md5" + "crypto/subtle" "database/sql" "encoding/hex" "strconv" @@ -60,7 +61,7 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, * } sum := md5.Sum([]byte(plaintext + salt)) expected := hex.EncodeToString(sum[:]) - if !strings.EqualFold(expected, token) { + if subtle.ConstantTimeCompare([]byte(expected), []byte(strings.ToLower(token))) != 1 { return nil, errWrongCredentials } diff --git a/internal/subsonic/podcasts.go b/internal/subsonic/podcasts.go index c2a3a06..b898ab4 100644 --- a/internal/subsonic/podcasts.go +++ b/internal/subsonic/podcasts.go @@ -5,6 +5,7 @@ import ( "database/sql" "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/netguard" ) func init() { @@ -176,6 +177,9 @@ func handleCreatePodcastChannel(rc *Context) *apiError { if aerr != nil { return aerr } + if err := netguard.ValidateURL(feedURL); err != nil { + return newError(ErrGeneric, err.Error()) + } channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL) if err != nil { return newError(ErrGeneric, err.Error()) diff --git a/internal/subsonic/testutil_test.go b/internal/subsonic/testutil_test.go index 09387af..0eb4467 100644 --- a/internal/subsonic/testutil_test.go +++ b/internal/subsonic/testutil_test.go @@ -40,7 +40,7 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) { Scanner: scanner.New(db, &metadata.Prober{}, log), CoverArt: coverart.New(t.TempDir(), ""), Transcoder: transcoding.NewManager(""), - Podcasts: podcasts.NewManager(db, t.TempDir(), "", log), + Podcasts: podcasts.NewManager(db, t.TempDir(), "", log, true), Jukebox: jukebox.New(""), Log: log, } diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go index 90486bf..550fc28 100644 --- a/internal/web/admin_handlers.go +++ b/internal/web/admin_handlers.go @@ -3,6 +3,7 @@ package web import ( "context" "net/http" + "net/url" "time" "git.noroutes.net/NoRoutes/vibetunes/internal/auth" @@ -48,6 +49,16 @@ func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users", http.StatusFound) } +// userPermissionsPageData bundles the target user with the data the +// permissions form needs beyond the plain user row: every music folder +// (so the form can render a checkbox per folder) and which of those the +// user is currently restricted to. +type userPermissionsPageData struct { + *database.User + Folders []*database.MusicFolder + AllowedFolders map[string]bool +} + func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") target, err := database.GetUserByID(h.DB, id) @@ -55,12 +66,31 @@ func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) http.NotFound(w, r) return } - h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, target) + folders, err := database.ListMusicFolders(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + allowedIDs, err := database.AllowedMusicFolderIDs(h.DB, id) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + allowed := make(map[string]bool, len(allowedIDs)) + for _, fid := range allowedIDs { + allowed[fid] = true + } + h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, userPermissionsPageData{ + User: target, + Folders: folders, + AllowedFolders: allowed, + }) } func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if _, err := database.GetUserByID(h.DB, id); err != nil { + target, err := database.GetUserByID(h.DB, id) + if err != nil { http.NotFound(w, r) return } @@ -86,6 +116,32 @@ func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Re http.Error(w, "internal error", http.StatusInternalServerError) return } + + wantAdmin := get("admin") + if target.IsAdmin && !wantAdmin { + // Refuse to demote the last remaining admin — otherwise the + // instance could end up with no one able to reach /admin/*. + n, err := database.CountAdmins(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if n <= 1 { + http.Error(w, "cannot remove admin role from the last remaining admin", http.StatusBadRequest) + return + } + } + if err := database.SetUserAdmin(h.DB, id, wantAdmin); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + folderIDs := r.Form["music_folders"] + if err := database.SetAllowedMusicFolders(h.DB, id, folderIDs); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/users", http.StatusFound) } @@ -189,11 +245,15 @@ func (h *Web) handleAdminFolderScan(w http.ResponseWriter, r *http.Request) { if h.Scanner != nil { go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }() } - referer := r.Header.Get("Referer") - if referer == "" { - referer = "/admin/scanner" + dest := "/admin/scanner" + if referer := r.Header.Get("Referer"); referer != "" { + if u, err := url.Parse(referer); err == nil { + if safe := safeRedirectPath(u.Path); safe != "/" { + dest = safe + } + } } - http.Redirect(w, r, referer, http.StatusFound) + http.Redirect(w, r, dest, http.StatusFound) } // --- Scanner status --- diff --git a/internal/web/admin_podcasts_handlers.go b/internal/web/admin_podcasts_handlers.go index 10386c8..3b9bf75 100644 --- a/internal/web/admin_podcasts_handlers.go +++ b/internal/web/admin_podcasts_handlers.go @@ -5,6 +5,7 @@ import ( "net/http" "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/netguard" ) func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) { @@ -35,6 +36,10 @@ func (h *Web) handleAdminPodcastCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/podcasts", http.StatusFound) return } + if err := netguard.ValidateURL(feedURL); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } channel, err := database.CreatePodcastChannel(h.DB, feedURL) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) diff --git a/internal/web/playlist_import_export.go b/internal/web/playlist_import_export.go new file mode 100644 index 0000000..d2f77b9 --- /dev/null +++ b/internal/web/playlist_import_export.go @@ -0,0 +1,194 @@ +package web + +import ( + "bufio" + "database/sql" + "fmt" + "net/http" + "path/filepath" + "strings" + + "git.noroutes.net/NoRoutes/vibetunes/internal/auth" + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +// handlePlaylistExport streams a playlist out as a standard M3U8 file: +// an #EXTM3U header, an #EXTINF duration/title line per track, and the +// track's absolute on-disk path (folder root + relative path) so a +// re-import against the same library's folders resolves cleanly. +func (h *Web) handlePlaylistExport(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + id := r.PathValue("id") + playlist, err := database.GetPlaylist(h.DB, id) + if err != nil { + http.NotFound(w, r) + return + } + if playlist.OwnerID != user.ID && !playlist.IsPublic { + http.Error(w, "403 Forbidden", http.StatusForbidden) + return + } + entries, err := database.ListPlaylistEntries(h.DB, id) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + folders, err := database.ListMusicFolders(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + folderByID := make(map[string]*database.MusicFolder, len(folders)) + for _, f := range folders { + folderByID[f.ID] = f + } + + filename := sanitizeFilename(playlist.Name) + ".m3u8" + w.Header().Set("Content-Type", "audio/x-mpegurl; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + + buf := bufio.NewWriter(w) + defer buf.Flush() + buf.WriteString("#EXTM3U\n") + for _, e := range entries { + song, err := database.GetSong(h.DB, e.SongID) + if err != nil { + continue + } + mf, err := database.GetMediaFile(h.DB, song.MediaFileID) + if err != nil { + continue + } + folder, ok := folderByID[mf.MusicFolderID] + if !ok { + continue + } + artist := "" + if a := albumOf(h.DB, song); a != nil && a.PrimaryAlbumArtistID.Valid { + if art, err := database.GetArtist(h.DB, a.PrimaryAlbumArtistID.String); err == nil { + artist = art.Name + } + } + title := song.Title + if artist != "" { + title = artist + " - " + title + } + fmt.Fprintf(buf, "#EXTINF:%d,%s\n", int(mf.DurationSeconds), title) + buf.WriteString(filepath.Join(folder.Path, mf.RelativePath)) + buf.WriteString("\n") + } +} + +// handlePlaylistImportForm renders the upload form. +func (h *Web) handlePlaylistImportForm(w http.ResponseWriter, r *http.Request) { + h.page(w, r, "playlist_import", "playlists", "Import Playlist", nil) +} + +// playlistImportResult is the summary shown after an import: how many +// M3U entries matched a track in the library versus were skipped. +type playlistImportResult struct { + Playlist *database.Playlist + Matched int + Skipped int +} + +// handlePlaylistImport accepts an uploaded .m3u/.m3u8 file, resolves each +// entry to a song already known to the library (matching by absolute +// path against a scanned music folder's root, falling back to a +// filename-only match within that folder), and creates a new playlist +// from whatever matched. Unmatched lines are reported, not treated as an +// error — a partial match is still useful to the user. +func (h *Web) handlePlaylistImport(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + if err := r.ParseMultipartForm(4 << 20); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "no file uploaded", http.StatusBadRequest) + return + } + defer file.Close() + + folders, err := database.ListMusicFolders(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + name := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) + if name == "" { + name = "Imported Playlist" + } + playlist, err := database.CreatePlaylist(h.DB, user.ID, name, "", false) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + matched, skipped := 0, 0 + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + song := resolvePlaylistImportLine(h.DB, folders, line) + if song == nil { + skipped++ + continue + } + if err := database.AppendPlaylistEntry(h.DB, playlist.ID, song.ID); err != nil { + skipped++ + continue + } + matched++ + } + + h.page(w, r, "playlist_import_result", "playlists", "Import Result", playlistImportResult{ + Playlist: playlist, + Matched: matched, + Skipped: skipped, + }) +} + +// resolvePlaylistImportLine matches one M3U entry (an absolute path, or a +// path relative to a music folder root) to a known song. +func resolvePlaylistImportLine(db *sql.DB, folders []*database.MusicFolder, line string) *database.Song { + for _, folder := range folders { + rel := line + if strings.HasPrefix(line, folder.Path) { + rel = strings.TrimPrefix(line, folder.Path) + rel = strings.TrimPrefix(rel, string(filepath.Separator)) + } else if filepath.IsAbs(line) { + continue // absolute path under a different folder root: not a match here + } + mf, err := database.FindMediaFileByPath(db, folder.ID, rel) + if err != nil { + continue + } + song, err := database.GetSongByMediaFileID(db, mf.ID) + if err != nil { + continue + } + return song + } + return nil +} + +func sanitizeFilename(name string) string { + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '_' + } + return r + }, name) + name = strings.TrimSpace(name) + if name == "" { + return "playlist" + } + return name +} diff --git a/internal/web/playlist_import_export_test.go b/internal/web/playlist_import_export_test.go new file mode 100644 index 0000000..67a5731 --- /dev/null +++ b/internal/web/playlist_import_export_test.go @@ -0,0 +1,174 @@ +package web + +import ( + "bytes" + "database/sql" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +// seedPlaylistFixture creates a logged-in user with one song in a music +// folder, matching the shape seedShareFixture uses elsewhere in this +// package. +func seedPlaylistFixture(t *testing.T, db *sql.DB) (folderPath string, mediaFile *database.MediaFile, song *database.Song) { + t.Helper() + folderPath = t.TempDir() + folder, err := database.CreateMusicFolder(db, "Music", folderPath, "music") + if err != nil { + t.Fatal(err) + } + mf, err := database.CreateMediaFile(db, folder.ID, "artist/album/track.mp3", 100, 0, "hash1", "mp3", "song", 123, 128, 44100, 2) + if err != nil { + t.Fatal(err) + } + s, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Test Track"}) + if err != nil { + t.Fatal(err) + } + return folderPath, mf, s +} + +func TestPlaylistExportProducesValidM3U(t *testing.T) { + h, db := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + + jar := map[string]*http.Cookie{} + doSetup(t, srv, jar, t.TempDir()) + + folderPath, mf, song := seedPlaylistFixture(t, db) + + user, err := database.GetUserByUsername(db, "admin") + if err != nil { + t.Fatal(err) + } + playlist, err := database.CreatePlaylist(db, user.ID, "My Mix", "", false) + if err != nil { + t.Fatal(err) + } + if err := database.AppendPlaylistEntry(db, playlist.ID, song.ID); err != nil { + t.Fatal(err) + } + + resp := doRequest(t, srv, jar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET export = %d, want 200", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + text := string(body) + if !strings.HasPrefix(text, "#EXTM3U\n") { + t.Fatalf("export missing #EXTM3U header: %q", text) + } + wantPath := filepath.Join(folderPath, mf.RelativePath) + if !strings.Contains(text, wantPath) { + t.Fatalf("export = %q, want it to contain track path %q", text, wantPath) + } + if !strings.Contains(text, "Test Track") { + t.Fatalf("export = %q, want it to contain track title", text) + } +} + +func TestPlaylistExportRequiresOwnershipOrPublic(t *testing.T) { + h, db := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + + adminJar := map[string]*http.Cookie{} + doSetup(t, srv, adminJar, t.TempDir()) + seedPlaylistFixture(t, db) + + owner, err := database.CreateUser(db, "owner", "", "", false) + if err != nil { + t.Fatal(err) + } + playlist, err := database.CreatePlaylist(db, owner.ID, "Private", "", false) + if err != nil { + t.Fatal(err) + } + + resp := doRequest(t, srv, adminJar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("GET export of another user's private playlist = %d, want 403", resp.StatusCode) + } +} + +func TestPlaylistImportMatchesTracksByPath(t *testing.T) { + h, db := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + + jar := map[string]*http.Cookie{} + doSetup(t, srv, jar, t.TempDir()) + + folderPath, mf, _ := seedPlaylistFixture(t, db) + trackPath := filepath.Join(folderPath, mf.RelativePath) + + m3u := "#EXTM3U\n#EXTINF:123,Some Artist - Test Track\n" + trackPath + "\nunmatched/nonexistent.mp3\n" + + token := csrfToken(t, srv, jar, "/playlists/import") + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + if err := mw.WriteField("csrf_token", token); err != nil { + t.Fatal(err) + } + fw, err := mw.CreateFormFile("file", "My Import.m3u8") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write([]byte(m3u)); err != nil { + t.Fatal(err) + } + mw.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/playlists/import", &body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + for _, c := range jar { + req.AddCookie(c) + } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("POST import = %d, want 200; body: %s", resp.StatusCode, respBody) + } + respBody, _ := io.ReadAll(resp.Body) + text := string(respBody) + if !strings.Contains(text, "1") { + t.Fatalf("import result page = %q, want it to mention 1 matched track", text) + } + + user, err := database.GetUserByUsername(db, "admin") + if err != nil { + t.Fatal(err) + } + playlists, err := database.ListPlaylistsByOwner(db, user.ID) + if err != nil { + t.Fatal(err) + } + if len(playlists) != 1 { + t.Fatalf("ListPlaylistsByOwner = %d playlists, want 1", len(playlists)) + } + entries, err := database.ListPlaylistEntries(db, playlists[0].ID) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("imported playlist has %d entries, want 1 (one unmatched line should be skipped)", len(entries)) + } +} diff --git a/internal/web/router.go b/internal/web/router.go index 78a304d..8196a43 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -40,8 +40,11 @@ func (h *Web) NewRouter() http.Handler { mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists)) mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate)) + mux.HandleFunc("GET /playlists/import", h.requireUser(h.handlePlaylistImportForm)) + mux.HandleFunc("POST /playlists/import", h.requireUser(h.handlePlaylistImport)) mux.HandleFunc("GET /playlists/{id}", h.requireUser(h.handlePlaylist)) mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete)) + mux.HandleFunc("GET /playlists/{id}/export", h.requireUser(h.handlePlaylistExport)) mux.HandleFunc("GET /favorites", h.requireUser(h.handleFavorites)) mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar)) diff --git a/internal/web/router_test.go b/internal/web/router_test.go index cfa96ae..99726f1 100644 --- a/internal/web/router_test.go +++ b/internal/web/router_test.go @@ -270,3 +270,71 @@ func TestLoginRejectsWrongPassword(t *testing.T) { t.Error("expected login failure message in response body") } } + +func TestAdminPermissionsCannotDemoteLastAdmin(t *testing.T) { + h, db := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + + adminJar := map[string]*http.Cookie{} + doSetup(t, srv, adminJar, t.TempDir()) + + admin, err := database.GetUserByUsername(db, "admin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + + token := csrfToken(t, srv, adminJar, "/admin/users/"+admin.ID+"/permissions") + resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+admin.ID+"/permissions", url.Values{ + "csrf_token": {token}, + // "admin" intentionally omitted: attempting to demote the only admin. + "stream": {"1"}, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("demote last admin = %d, want 400", resp.StatusCode) + } + + got, err := database.GetUserByID(db, admin.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if !got.IsAdmin { + t.Fatal("last admin was demoted despite the guard") + } +} + +func TestAdminPermissionsSetsMusicFolderRestriction(t *testing.T) { + h, db := newTestWeb(t) + srv := httptest.NewServer(h.NewRouter()) + defer srv.Close() + + adminJar := map[string]*http.Cookie{} + doSetup(t, srv, adminJar, t.TempDir()) + + user, err := database.CreateUser(db, "regular", "", "", false) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + folder, err := database.CreateMusicFolder(db, "Music", t.TempDir(), "music") + if err != nil { + t.Fatalf("CreateMusicFolder: %v", err) + } + + token := csrfToken(t, srv, adminJar, "/admin/users/"+user.ID+"/permissions") + resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+user.ID+"/permissions", url.Values{ + "csrf_token": {token}, + "music_folders": {folder.ID}, + "stream": {"1"}, + }) + if resp.StatusCode != http.StatusFound { + t.Fatalf("save permissions = %d, want 302", resp.StatusCode) + } + + allowed, err := database.AllowedMusicFolderIDs(db, user.ID) + if err != nil { + t.Fatalf("AllowedMusicFolderIDs: %v", err) + } + if len(allowed) != 1 || allowed[0] != folder.ID { + t.Fatalf("AllowedMusicFolderIDs = %v, want [%s]", allowed, folder.ID) + } +} diff --git a/web/static/css/app.css b/web/static/css/app.css index 0fa9e9d..ea2bdb4 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -262,6 +262,10 @@ body { font-size: 1.4rem; } +.vs-player-btn.active { + color: var(--bs-primary); +} + .vs-player-timeline { display: flex; align-items: center; diff --git a/web/static/js/app.js b/web/static/js/app.js index e9c0acd..234162c 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -26,9 +26,20 @@ this.ui = ui; this.queue = []; this.index = -1; + this.shuffle = false; + this.repeat = "off"; // "off" | "all" | "one" + this.shuffleOrder = []; // permutation of queue indices; shufflePos points into it + this.shufflePos = -1; + try { + this.shuffle = localStorage.getItem("vs-shuffle") === "1"; + var storedRepeat = localStorage.getItem("vs-repeat"); + if (storedRepeat === "all" || storedRepeat === "one") this.repeat = storedRepeat; + } catch (e) { + /* storage may be unavailable (private browsing); state just won't persist */ + } this.audio.addEventListener("timeupdate", this._onTimeUpdate.bind(this)); this.audio.addEventListener("loadedmetadata", this._onTimeUpdate.bind(this)); - this.audio.addEventListener("ended", this.next.bind(this)); + this.audio.addEventListener("ended", this._onEnded.bind(this)); this.audio.addEventListener("play", this._syncPlayIcon.bind(this)); this.audio.addEventListener("pause", this._syncPlayIcon.bind(this)); } @@ -36,10 +47,68 @@ VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) { this.queue = songs; this.index = startIndex || 0; + this._reshuffle(); this._loadCurrent(); this.audio.play().catch(function () {}); }; + // _reshuffle rebuilds the shuffle order (a Fisher-Yates permutation of + // every queue index except the currently-playing one, which is pinned + // to the front) whenever the queue changes or shuffle is toggled on, + // so "next" has a fresh random order to draw from without immediately + // repeating the track that's already playing. + VibeSonicPlayer.prototype._reshuffle = function () { + var order = []; + for (var i = 0; i < this.queue.length; i++) { + if (i !== this.index) order.push(i); + } + for (var j = order.length - 1; j > 0; j--) { + var k = Math.floor(Math.random() * (j + 1)); + var tmp = order[j]; + order[j] = order[k]; + order[k] = tmp; + } + order.unshift(this.index); + this.shuffleOrder = order; + this.shufflePos = 0; + }; + + VibeSonicPlayer.prototype.setShuffle = function (on) { + this.shuffle = on; + try { + localStorage.setItem("vs-shuffle", on ? "1" : "0"); + } catch (e) {} + if (on) this._reshuffle(); + if (this.ui.shuffleBtn) this.ui.shuffleBtn.classList.toggle("active", on); + }; + + VibeSonicPlayer.prototype.cycleRepeat = function () { + this.repeat = this.repeat === "off" ? "all" : this.repeat === "all" ? "one" : "off"; + try { + localStorage.setItem("vs-repeat", this.repeat); + } catch (e) {} + this._syncRepeatButton(); + }; + + VibeSonicPlayer.prototype._syncRepeatButton = function () { + var btn = this.ui.repeatBtn; + if (!btn) return; + btn.dataset.repeat = this.repeat; + btn.classList.toggle("active", this.repeat !== "off"); + var icon = btn.querySelector("i"); + if (icon) icon.className = this.repeat === "one" ? "bi bi-repeat-1" : "bi bi-repeat"; + btn.title = this.repeat === "off" ? "Repeat: off" : this.repeat === "all" ? "Repeat: all" : "Repeat: one"; + }; + + VibeSonicPlayer.prototype._onEnded = function () { + if (this.repeat === "one") { + this.audio.currentTime = 0; + this.audio.play().catch(function () {}); + return; + } + this.next(); + }; + VibeSonicPlayer.prototype._loadCurrent = function () { var song = this.queue[this.index]; if (!song) return; @@ -145,11 +214,25 @@ }; VibeSonicPlayer.prototype.next = function () { - if (this.index + 1 < this.queue.length) { + if (!this.queue.length) return; + if (this.shuffle) { + if (this.shufflePos + 1 < this.shuffleOrder.length) { + this.shufflePos++; + } else if (this.repeat === "all") { + this._reshuffle(); + } else { + return; + } + this.index = this.shuffleOrder[this.shufflePos]; + } else if (this.index + 1 < this.queue.length) { this.index++; - this._loadCurrent(); - this.audio.play().catch(function () {}); + } else if (this.repeat === "all") { + this.index = 0; + } else { + return; } + this._loadCurrent(); + this.audio.play().catch(function () {}); }; VibeSonicPlayer.prototype.prev = function () { @@ -157,11 +240,17 @@ this.audio.currentTime = 0; return; } - if (this.index > 0) { + if (this.shuffle) { + if (this.shufflePos <= 0) return; + this.shufflePos--; + this.index = this.shuffleOrder[this.shufflePos]; + } else if (this.index > 0) { this.index--; - this._loadCurrent(); - this.audio.play().catch(function () {}); + } else { + return; } + this._loadCurrent(); + this.audio.play().catch(function () {}); }; VibeSonicPlayer.prototype.seekTo = function (fraction) { @@ -241,8 +330,23 @@ volumeSlider: document.getElementById("vs-volume"), starBtn: document.getElementById("vs-now-star"), lyricsPanel: document.getElementById("vs-lyrics-panel"), + shuffleBtn: document.getElementById("vs-shuffle-btn"), + repeatBtn: document.getElementById("vs-repeat-btn"), }; var player = new VibeSonicPlayer(audio, ui); + player.setShuffle(player.shuffle); + player._syncRepeatButton(); + + if (ui.shuffleBtn) { + ui.shuffleBtn.addEventListener("click", function () { + player.setShuffle(!player.shuffle); + }); + } + if (ui.repeatBtn) { + ui.repeatBtn.addEventListener("click", function () { + player.cycleRepeat(); + }); + } var lyricsBtn = document.getElementById("vs-lyrics-btn"); if (lyricsBtn && ui.lyricsPanel) { diff --git a/web/templates/admin_user_permissions.html b/web/templates/admin_user_permissions.html index 63fd46c..94b35e7 100644 --- a/web/templates/admin_user_permissions.html +++ b/web/templates/admin_user_permissions.html @@ -4,6 +4,10 @@

{{.Data.Username}}

{{template "csrf_field" .CSRFToken}} +
+ + +
@@ -48,8 +52,21 @@
- - Cancel +
+

Music Folder Access

+

Leave all unchecked for unrestricted access to every folder.

+ {{range .Data.Folders}} +
+ + +
+ {{else}} +

No music folders configured.

+ {{end}} +
+ + Cancel +
diff --git a/web/templates/partials/footer.html b/web/templates/partials/footer.html index 4463d58..049511d 100644 --- a/web/templates/partials/footer.html +++ b/web/templates/partials/footer.html @@ -24,9 +24,11 @@
+ +
0:00 diff --git a/web/templates/playlist.html b/web/templates/playlist.html index bb57eaa..5ada381 100644 --- a/web/templates/playlist.html +++ b/web/templates/playlist.html @@ -4,12 +4,15 @@

{{.Data.Playlist.Name}}

{{if .Data.Playlist.Description}}
{{.Data.Playlist.Description}}
{{end}}
- {{if .Data.Owned}} -
- {{template "csrf_field" .CSRFToken}} - -
- {{end}} +
+ Export M3U + {{if .Data.Owned}} +
+ {{template "csrf_field" .CSRFToken}} + +
+ {{end}} +
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}

This playlist is empty.

{{end}} {{template "footer" .}}{{end}} diff --git a/web/templates/playlist_import.html b/web/templates/playlist_import.html new file mode 100644 index 0000000..10182c0 --- /dev/null +++ b/web/templates/playlist_import.html @@ -0,0 +1,12 @@ +{{define "playlist_import"}}{{template "header" .}} +

Import Playlist

+
+ {{template "csrf_field" .CSRFToken}} +
+ +
+ + Cancel +
+

Accepts an M3U/M3U8 file. Tracks are matched by file path against your scanned music folders; a new playlist is created from the tracks that match.

+{{template "footer" .}}{{end}} diff --git a/web/templates/playlist_import_result.html b/web/templates/playlist_import_result.html new file mode 100644 index 0000000..19e773c --- /dev/null +++ b/web/templates/playlist_import_result.html @@ -0,0 +1,9 @@ +{{define "playlist_import_result"}}{{template "header" .}} +

Import Complete

+

+ Created playlist {{.Data.Playlist.Name}} + with {{.Data.Matched}} matched track{{if ne .Data.Matched 1}}s{{end}} + {{if .Data.Skipped}}({{.Data.Skipped}} entr{{if eq .Data.Skipped 1}}y{{else}}ies{{end}} could not be matched to a track in your library){{end}}. +

+View Playlist +{{template "footer" .}}{{end}} diff --git a/web/templates/playlists.html b/web/templates/playlists.html index fe47360..54d4617 100644 --- a/web/templates/playlists.html +++ b/web/templates/playlists.html @@ -1,5 +1,5 @@ {{define "playlists"}}{{template "header" .}} -
+ {{template "csrf_field" .CSRFToken}}
@@ -8,6 +8,9 @@
+ {{if .Data}}