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:
@@ -1,71 +1,57 @@
|
|||||||
# VibeSonic
|
# VibeSonic
|
||||||
|
|
||||||
VibeSonic is a lightweight, self-hosted music/media server written in Go.
|
VibeSonic is a lightweight, self-hosted music/media server written in
|
||||||
It follows the same philosophy as other small `Vibe*` tools: a single Go
|
Go. Single binary, SQLite storage, server-rendered Bootstrap 5
|
||||||
binary, SQLite storage, server-rendered Bootstrap 5 templates, dark mode
|
templates, dark mode by default, minimal JavaScript, no external
|
||||||
by default, minimal JavaScript, and no external services (no Redis, no
|
services (no Redis, no message broker, no microservices).
|
||||||
message broker, no microservices, nothing Kubernetes-specific).
|
|
||||||
|
|
||||||
**This is Phase 2 of 4.** Phase 1 built the foundation (config, schema,
|
It implements the **original Subsonic REST API through version
|
||||||
auth, scanner, metadata, streaming, cover art, web UI shell). Phase 2
|
1.16.1**, in full, plus a set of **OpenSubsonic extensions**, so it
|
||||||
adds a **complete implementation of the original Subsonic REST API
|
works with existing Subsonic/OpenSubsonic client apps, alongside a
|
||||||
through version 1.16.1** under `/rest/*` — every endpoint, backed by
|
complete server-rendered web UI for browser use. See
|
||||||
real data, including transcoding, HLS, a real mpv-backed jukebox, and a
|
|
||||||
real podcast subsystem. See
|
|
||||||
[`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for
|
[`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for
|
||||||
the full endpoint-by-endpoint matrix. OpenSubsonic extensions are not
|
the endpoint-by-endpoint matrix and
|
||||||
yet implemented (Phase 3).
|
[`docs/client-compatibility.md`](docs/client-compatibility.md) for
|
||||||
|
real-client testing status.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Phase 1: foundation
|
- SQLite storage (WAL mode, foreign keys, versioned migrations)
|
||||||
|
- Native authentication (Argon2id, HTTP-only session cookies, CSRF) and
|
||||||
- SQLite storage (WAL mode, foreign keys, versioned migrations) with a
|
optional Navidrome-style external authentication via a trusted
|
||||||
schema already modeling artists, albums, songs, genres, contributors,
|
reverse-proxy header, gated on the real TCP peer address — see
|
||||||
playlists, favorites, ratings, scrobbles, playback history, bookmarks,
|
[`docs/external-auth.md`](docs/external-auth.md)
|
||||||
play queues, shares, podcasts, internet radio, and settings
|
- A library scanner: multiple music folders, incremental rescans,
|
||||||
- Native authentication: Argon2id password hashing, HTTP-only signed
|
new/changed/deleted/renamed detection, bounded worker pool
|
||||||
session cookies, CSRF-protected forms, session revocation
|
- Real metadata extraction via `ffprobe` (multi-artist/genre,
|
||||||
- Optional Navidrome-style external authentication via a trusted reverse
|
MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields)
|
||||||
proxy header, gated on the real TCP peer address (never on
|
- Direct streaming with HTTP range support, plus real ffmpeg-backed
|
||||||
spoofable `X-Forwarded-For`/`X-Real-IP`/`Forwarded` headers)
|
transcoding (mp3/opus/aac) and HLS segmenting, bounded concurrency,
|
||||||
- A library scanner supporting multiple music folders, incremental
|
cancelled on client disconnect
|
||||||
rescans, new/changed/deleted/renamed file detection, and a bounded
|
- Structured lyrics: plain, line-synced, and (where the source
|
||||||
worker pool — never a blind full rebuild
|
provides it) word/syllable-level timing, exposed via the
|
||||||
- Real metadata extraction via `ffprobe` (multi-artist, multi-genre,
|
`songLyrics` OpenSubsonic extension and rendered in the web player
|
||||||
MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields, and
|
with click-to-seek and active-line highlighting
|
||||||
more)
|
- Sonic similarity: real audio-feature analysis (tempo, loudness,
|
||||||
- Stable, opaque, URL-safe IDs that survive restarts and rescans
|
spectral shape, chroma) computed during scanning, used for
|
||||||
- Direct media streaming with HTTP range request support, correct MIME
|
similar-track lookups and path-finding between songs
|
||||||
types, and no full-file buffering
|
- Optional MusicBrainz/Cover Art Archive metadata enrichment (off by
|
||||||
- Cover art discovery (embedded artwork or folder images) with a
|
default — VibeSonic works fully offline otherwise)
|
||||||
resized-image disk cache
|
- Podcasts (RSS subscriptions, bounded background downloads, restart
|
||||||
- A server-rendered, dark-mode, keyboard-friendly web UI: browsing,
|
recovery), Internet radio stations, shares (public, no-login-required
|
||||||
search, playlists, favorites, a persistent bottom player, and an admin
|
links), bookmarks, and multi-device play queues
|
||||||
section (users, music folders, scanner status, system settings)
|
- 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`
|
- `/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
|
## 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"
|
available — without it, `jukeboxControl` returns a clear "unavailable"
|
||||||
error rather than pretending to work.
|
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
|
### Docker
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -92,10 +83,14 @@ docker compose up -d
|
|||||||
|
|
||||||
See [`docker-compose.yml`](docker-compose.yml) — mount your music
|
See [`docker-compose.yml`](docker-compose.yml) — mount your music
|
||||||
directory read-only at `/music` and set `VIBESONIC_SESSION_SECRET`.
|
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
|
### 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
|
## 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/architecture.md`](docs/architecture.md) — how the pieces fit together
|
||||||
- [`docs/configuration.md`](docs/configuration.md) — every environment variable
|
- [`docs/configuration.md`](docs/configuration.md) — every environment variable
|
||||||
- [`docs/external-auth.md`](docs/external-auth.md) — trusted reverse-proxy authentication
|
- [`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
|
## Development
|
||||||
|
|
||||||
|
|||||||
+111
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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_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_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_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
|
## External authentication
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
enabled, unless their account has no password hash set (auto-created
|
||||||
external users start this way).
|
external users start this way).
|
||||||
|
|
||||||
In later phases, `/rest/*` (the native Subsonic API) will authenticate
|
`/rest/*` (the Subsonic/OpenSubsonic API) never consults external auth
|
||||||
exclusively via Subsonic's own token/API-key scheme and must never be
|
at all: it authenticates exclusively via Subsonic's own token/salt,
|
||||||
affected by external auth's browser-cookie flow.
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
extension is ever registered without its handler actually existing, so
|
||||||
this list can't drift ahead of what's real.
|
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 |
|
| 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`. |
|
| `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
@@ -73,7 +73,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
|||||||
return nil, fmt.Errorf("deriving auth encryption key: %w", err)
|
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 {
|
if err := podcastMgr.RecoverInterruptedDownloads(); err != nil {
|
||||||
log.Warn("podcast download recovery failed", "err", err)
|
log.Warn("podcast download recovery failed", "err", err)
|
||||||
}
|
}
|
||||||
@@ -167,6 +167,7 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: a.Cfg.ListenAddr,
|
Addr: a.Cfg.ListenAddr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 15 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -33,6 +33,15 @@ type Config struct {
|
|||||||
// to the outside world.
|
// to the outside world.
|
||||||
EnableExternalMetadata bool
|
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
|
ExtAuth ExtAuthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +71,8 @@ func Load() (Config, error) {
|
|||||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
||||||
|
|
||||||
EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false),
|
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"))
|
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,6 +20,12 @@ import (
|
|||||||
"golang.org/x/image/draw"
|
"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
|
// commonFolderImageNames are checked, in priority order, for a
|
||||||
// folder-level cover image when a file has no embedded artwork.
|
// folder-level cover image when a file has no embedded artwork.
|
||||||
var commonFolderImageNames = []string{
|
var commonFolderImageNames = []string{
|
||||||
@@ -112,6 +119,15 @@ func (s *Store) GetOrCreate(sourcePath, contentHash string, size int) (string, e
|
|||||||
}
|
}
|
||||||
defer src.Close()
|
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)
|
img, _, err := image.Decode(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("decoding image: %w", err)
|
return "", fmt.Errorf("decoding image: %w", err)
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ func ListUsers(db *sql.DB) ([]*User, error) {
|
|||||||
return out, rows.Err()
|
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) {
|
func scanUserRow(rows *sql.Rows) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
var isAdmin, disabled, external int
|
var isAdmin, disabled, external int
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,16 @@ import (
|
|||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
"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 {
|
type Manager struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
@@ -37,12 +44,18 @@ type Manager struct {
|
|||||||
sem chan 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{
|
return &Manager{
|
||||||
DB: db,
|
DB: db,
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
FFprobePath: ffprobePath,
|
FFprobePath: ffprobePath,
|
||||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
HTTPClient: netguard.NewSafeHTTPClient(60*time.Second, allowPrivateNetworks),
|
||||||
Log: log,
|
Log: log,
|
||||||
sem: make(chan struct{}, maxConcurrentDownloads),
|
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", "")
|
_ = 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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, channel.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error())
|
_ = 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 {
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -253,11 +274,17 @@ func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
out.Close()
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
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 {
|
if err := out.Close(); err != nil {
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ func newTestManager(t *testing.T) (*Manager, string) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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) {
|
func TestRefreshChannel(t *testing.T) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package subsonic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -60,7 +61,7 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *
|
|||||||
}
|
}
|
||||||
sum := md5.Sum([]byte(plaintext + salt))
|
sum := md5.Sum([]byte(plaintext + salt))
|
||||||
expected := hex.EncodeToString(sum[:])
|
expected := hex.EncodeToString(sum[:])
|
||||||
if !strings.EqualFold(expected, token) {
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(strings.ToLower(token))) != 1 {
|
||||||
return nil, errWrongCredentials
|
return nil, errWrongCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -176,6 +177,9 @@ func handleCreatePodcastChannel(rc *Context) *apiError {
|
|||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
return aerr
|
return aerr
|
||||||
}
|
}
|
||||||
|
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||||
|
return newError(ErrGeneric, err.Error())
|
||||||
|
}
|
||||||
channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL)
|
channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return newError(ErrGeneric, err.Error())
|
return newError(ErrGeneric, err.Error())
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) {
|
|||||||
Scanner: scanner.New(db, &metadata.Prober{}, log),
|
Scanner: scanner.New(db, &metadata.Prober{}, log),
|
||||||
CoverArt: coverart.New(t.TempDir(), ""),
|
CoverArt: coverart.New(t.TempDir(), ""),
|
||||||
Transcoder: transcoding.NewManager(""),
|
Transcoder: transcoding.NewManager(""),
|
||||||
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log),
|
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log, true),
|
||||||
Jukebox: jukebox.New(""),
|
Jukebox: jukebox.New(""),
|
||||||
Log: log,
|
Log: log,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
"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)
|
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) {
|
func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
target, err := database.GetUserByID(h.DB, 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)
|
http.NotFound(w, r)
|
||||||
return
|
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) {
|
func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,6 +116,32 @@ func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Re
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
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 {
|
if h.Scanner != nil {
|
||||||
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
|
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
|
||||||
}
|
}
|
||||||
referer := r.Header.Get("Referer")
|
dest := "/admin/scanner"
|
||||||
if referer == "" {
|
if referer := r.Header.Get("Referer"); referer != "" {
|
||||||
referer = "/admin/scanner"
|
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 ---
|
// --- Scanner status ---
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) {
|
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)
|
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := netguard.ValidateURL(feedURL); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
|
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,8 +40,11 @@ func (h *Web) NewRouter() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
|
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
|
||||||
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
|
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("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
|
||||||
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
|
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("GET /favorites", h.requireUser(h.handleFavorites))
|
||||||
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
|
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
|
||||||
|
|||||||
@@ -270,3 +270,71 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
|||||||
t.Error("expected login failure message in response body")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ body {
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vs-player-btn.active {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.vs-player-timeline {
|
.vs-player-timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+109
-5
@@ -26,9 +26,20 @@
|
|||||||
this.ui = ui;
|
this.ui = ui;
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
this.index = -1;
|
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("timeupdate", this._onTimeUpdate.bind(this));
|
||||||
this.audio.addEventListener("loadedmetadata", 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("play", this._syncPlayIcon.bind(this));
|
||||||
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
||||||
}
|
}
|
||||||
@@ -36,10 +47,68 @@
|
|||||||
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
||||||
this.queue = songs;
|
this.queue = songs;
|
||||||
this.index = startIndex || 0;
|
this.index = startIndex || 0;
|
||||||
|
this._reshuffle();
|
||||||
this._loadCurrent();
|
this._loadCurrent();
|
||||||
this.audio.play().catch(function () {});
|
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 () {
|
VibeSonicPlayer.prototype._loadCurrent = function () {
|
||||||
var song = this.queue[this.index];
|
var song = this.queue[this.index];
|
||||||
if (!song) return;
|
if (!song) return;
|
||||||
@@ -145,11 +214,25 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
VibeSonicPlayer.prototype.next = function () {
|
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.index++;
|
||||||
|
} else if (this.repeat === "all") {
|
||||||
|
this.index = 0;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._loadCurrent();
|
this._loadCurrent();
|
||||||
this.audio.play().catch(function () {});
|
this.audio.play().catch(function () {});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
VibeSonicPlayer.prototype.prev = function () {
|
VibeSonicPlayer.prototype.prev = function () {
|
||||||
@@ -157,11 +240,17 @@
|
|||||||
this.audio.currentTime = 0;
|
this.audio.currentTime = 0;
|
||||||
return;
|
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.index--;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._loadCurrent();
|
this._loadCurrent();
|
||||||
this.audio.play().catch(function () {});
|
this.audio.play().catch(function () {});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
||||||
@@ -241,8 +330,23 @@
|
|||||||
volumeSlider: document.getElementById("vs-volume"),
|
volumeSlider: document.getElementById("vs-volume"),
|
||||||
starBtn: document.getElementById("vs-now-star"),
|
starBtn: document.getElementById("vs-now-star"),
|
||||||
lyricsPanel: document.getElementById("vs-lyrics-panel"),
|
lyricsPanel: document.getElementById("vs-lyrics-panel"),
|
||||||
|
shuffleBtn: document.getElementById("vs-shuffle-btn"),
|
||||||
|
repeatBtn: document.getElementById("vs-repeat-btn"),
|
||||||
};
|
};
|
||||||
var player = new VibeSonicPlayer(audio, ui);
|
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");
|
var lyricsBtn = document.getElementById("vs-lyrics-btn");
|
||||||
if (lyricsBtn && ui.lyricsPanel) {
|
if (lyricsBtn && ui.lyricsPanel) {
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<h2 class="h6">{{.Data.Username}}</h2>
|
<h2 class="h6">{{.Data.Username}}</h2>
|
||||||
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
|
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
|
||||||
{{template "csrf_field" .CSRFToken}}
|
{{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">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="settings" value="1" id="p_settings" {{if .Data.Permissions.Settings}}checked{{end}}>
|
<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>
|
<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}}>
|
<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>
|
<label class="form-check-label" for="p_video">Video Conversion</label>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||||
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="vs-player-controls">
|
<div class="vs-player-controls">
|
||||||
<div class="vs-player-buttons">
|
<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-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-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-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>
|
||||||
<div class="vs-player-timeline">
|
<div class="vs-player-timeline">
|
||||||
<span id="vs-current-time" class="vs-player-time">0:00</span>
|
<span id="vs-current-time" class="vs-player-time">0:00</span>
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
|
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
|
||||||
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
|
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
|
||||||
</div>
|
</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}}
|
{{if .Data.Owned}}
|
||||||
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
||||||
{{template "csrf_field" .CSRFToken}}
|
{{template "csrf_field" .CSRFToken}}
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
|
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
|
||||||
{{template "footer" .}}{{end}}
|
{{template "footer" .}}{{end}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "playlists"}}{{template "header" .}}
|
{{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}}
|
{{template "csrf_field" .CSRFToken}}
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<input type="text" name="name" class="form-control" placeholder="New playlist name" required>
|
<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>
|
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-lg"></i> Create</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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}}
|
{{if .Data}}
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
|
|||||||
Reference in New Issue
Block a user