Complete Phase 4: security hardening, player/playlist/admin UI, docs

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
2026-08-23 09:18:17 -05:00
co-authored by Claude Sonnet 5
parent 933ed31550
commit 789bf91eed
36 changed files with 1722 additions and 96 deletions
+60 -61
View File
@@ -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://<host>: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
+111
View File
@@ -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:
```
<DataDir>/
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.
+69
View File
@@ -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://<host>: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).
+2
View File
@@ -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
+230
View File
@@ -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 <group-that-owns-your-music> 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.
+7 -3
View File
@@ -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.
+133
View File
@@ -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.
+10
View File
@@ -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`. |
+2 -1
View File
@@ -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)
}
@@ -167,6 +167,7 @@ func (a *App) Run(ctx context.Context) error {
srv := &http.Server{
Addr: a.Cfg.ListenAddr,
Handler: mux,
ReadHeaderTimeout: 15 * time.Second,
}
errCh := make(chan error, 1)
+28
View File
@@ -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)
})
}
+11
View File
@@ -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"))
+16
View File
@@ -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)
+8
View File
@@ -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
+26
View File
@@ -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)
})
}
+147
View File
@@ -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
}
+53
View File
@@ -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)
})
}
+85
View File
@@ -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")
}
}
+31 -4
View File
@@ -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
+4 -1
View File
@@ -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) {
+2 -1
View File
@@ -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
}
+4
View File
@@ -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())
+1 -1
View File
@@ -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,
}
+66 -6
View File
@@ -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 ---
+5
View File
@@ -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)
+194
View File
@@ -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
}
+174
View File
@@ -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))
}
}
+3
View File
@@ -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))
+68
View File
@@ -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)
}
}
+4
View File
@@ -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;
+109 -5
View File
@@ -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++;
} 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--;
} 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) {
+17
View File
@@ -4,6 +4,10 @@
<h2 class="h6">{{.Data.Username}}</h2>
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
{{template "csrf_field" .CSRFToken}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="admin" value="1" id="p_admin" {{if .Data.IsAdmin}}checked{{end}}>
<label class="form-check-label" for="p_admin"><strong>Administrator</strong></label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="settings" value="1" id="p_settings" {{if .Data.Permissions.Settings}}checked{{end}}>
<label class="form-check-label" for="p_settings">Settings</label>
@@ -48,8 +52,21 @@
<input class="form-check-input" type="checkbox" name="video_conversion" value="1" id="p_video" {{if .Data.Permissions.VideoConversion}}checked{{end}}>
<label class="form-check-label" for="p_video">Video Conversion</label>
</div>
<hr>
<h3 class="h6">Music Folder Access</h3>
<p class="small text-body-secondary">Leave all unchecked for unrestricted access to every folder.</p>
{{range .Data.Folders}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="music_folders" value="{{.ID}}" id="f_{{.ID}}" {{if index $.Data.AllowedFolders .ID}}checked{{end}}>
<label class="form-check-label" for="f_{{.ID}}">{{.Name}}</label>
</div>
{{else}}
<p class="small text-body-secondary">No music folders configured.</p>
{{end}}
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
+2
View File
@@ -24,9 +24,11 @@
</div>
<div class="vs-player-controls">
<div class="vs-player-buttons">
<button id="vs-shuffle-btn" type="button" class="vs-player-btn" title="Shuffle"><i class="bi bi-shuffle"></i></button>
<button id="vs-prev-btn" type="button" class="vs-player-btn" title="Previous (J)"><i class="bi bi-skip-start-fill"></i></button>
<button id="vs-play-btn" type="button" class="vs-player-btn vs-play" title="Play/Pause (Space)"><i class="bi bi-play-fill"></i></button>
<button id="vs-next-btn" type="button" class="vs-player-btn" title="Next (L)"><i class="bi bi-skip-end-fill"></i></button>
<button id="vs-repeat-btn" type="button" class="vs-player-btn" title="Repeat: off" data-repeat="off"><i class="bi bi-repeat"></i></button>
</div>
<div class="vs-player-timeline">
<span id="vs-current-time" class="vs-player-time">0:00</span>
+3
View File
@@ -4,6 +4,8 @@
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
</div>
<div class="d-flex gap-2">
<a href="/playlists/{{.Data.Playlist.ID}}/export" class="btn btn-outline-secondary btn-sm"><i class="bi bi-download"></i> Export M3U</a>
{{if .Data.Owned}}
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
{{template "csrf_field" .CSRFToken}}
@@ -11,5 +13,6 @@
</form>
{{end}}
</div>
</div>
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
{{template "footer" .}}{{end}}
+12
View File
@@ -0,0 +1,12 @@
{{define "playlist_import"}}{{template "header" .}}
<h2 class="h5 mb-3">Import Playlist</h2>
<form method="post" action="/playlists/import" enctype="multipart/form-data" class="mb-3" style="max-width: 480px;">
{{template "csrf_field" .CSRFToken}}
<div class="mb-2">
<input type="file" name="file" class="form-control" accept=".m3u,.m3u8,audio/x-mpegurl" required>
</div>
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-upload"></i> Import</button>
<a href="/playlists" class="btn btn-outline-secondary btn-sm">Cancel</a>
</form>
<p class="text-secondary small">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.</p>
{{template "footer" .}}{{end}}
@@ -0,0 +1,9 @@
{{define "playlist_import_result"}}{{template "header" .}}
<h2 class="h5 mb-3">Import Complete</h2>
<p>
Created playlist <a href="/playlists/{{.Data.Playlist.ID}}">{{.Data.Playlist.Name}}</a>
with <strong>{{.Data.Matched}}</strong> 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}}.
</p>
<a href="/playlists/{{.Data.Playlist.ID}}" class="btn btn-primary btn-sm">View Playlist</a>
{{template "footer" .}}{{end}}
+4 -1
View File
@@ -1,5 +1,5 @@
{{define "playlists"}}{{template "header" .}}
<form method="post" action="/playlists" class="row g-2 mb-4" style="max-width: 480px;">
<form method="post" action="/playlists" class="row g-2 mb-2" style="max-width: 480px;">
{{template "csrf_field" .CSRFToken}}
<div class="col-8">
<input type="text" name="name" class="form-control" placeholder="New playlist name" required>
@@ -8,6 +8,9 @@
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-lg"></i> Create</button>
</div>
</form>
<div class="mb-4">
<a href="/playlists/import" class="btn btn-outline-secondary btn-sm"><i class="bi bi-upload"></i> Import M3U</a>
</div>
{{if .Data}}
<div class="list-group">