diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..909f089 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +data/ +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..023b6e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.25-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/vibesonic ./cmd/vibesonic + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -r -u 10001 -d /data -s /usr/sbin/nologin vibesonic \ + && mkdir -p /data /music \ + && chown -R vibesonic:vibesonic /data + +COPY --from=build /out/vibesonic /usr/local/bin/vibesonic + +USER vibesonic +VOLUME ["/data", "/music"] +EXPOSE 8080 + +ENV VIBESONIC_LISTEN_ADDR=:8080 \ + VIBESONIC_DATA_DIR=/data \ + VIBESONIC_DB=/data/vibesonic.db \ + VIBESONIC_MUSIC_DIRS=/music \ + VIBESONIC_FFMPEG_PATH=/usr/bin/ffmpeg \ + VIBESONIC_FFPROBE_PATH=/usr/bin/ffprobe \ + VIBESONIC_SCAN_INTERVAL=1h + +ENTRYPOINT ["/usr/local/bin/vibesonic"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..15e6599 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# VibeSonic + +VibeSonic is a lightweight, self-hosted music/media server written in Go. +It follows the same philosophy as other small `Vibe*` tools: a single Go +binary, SQLite storage, server-rendered Bootstrap 5 templates, dark mode +by default, minimal JavaScript, and no external services (no Redis, no +message broker, no microservices, nothing Kubernetes-specific). + +**This is Phase 1 of 4.** It builds the foundation — configuration, +database schema, authentication, the library scanner, metadata reading, +direct streaming, cover art, and the web UI shell — that later phases +build the full Subsonic/OpenSubsonic API on top of. Phase 1 does **not** +implement the Subsonic protocol itself; see +[`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for +what's planned. + +## Features (Phase 1) + +- SQLite storage (WAL mode, foreign keys, versioned migrations) with a + schema already modeling artists, albums, songs, genres, contributors, + playlists, favorites, ratings, scrobbles, playback history, bookmarks, + play queues, shares, podcasts, internet radio, and settings +- Native authentication: Argon2id password hashing, HTTP-only signed + session cookies, CSRF-protected forms, session revocation +- Optional Navidrome-style external authentication via a trusted reverse + proxy header, gated on the real TCP peer address (never on + spoofable `X-Forwarded-For`/`X-Real-IP`/`Forwarded` headers) +- A library scanner supporting multiple music folders, incremental + rescans, new/changed/deleted/renamed file detection, and a bounded + worker pool — never a blind full rebuild +- Real metadata extraction via `ffprobe` (multi-artist, multi-genre, + MusicBrainz IDs, ReplayGain, embedded artwork/lyrics, sort fields, and + more) +- Stable, opaque, URL-safe IDs that survive restarts and rescans +- Direct media streaming with HTTP range request support, correct MIME + types, and no full-file buffering +- Cover art discovery (embedded artwork or folder images) with a + resized-image disk cache +- A server-rendered, dark-mode, keyboard-friendly web UI: browsing, + search, playlists, favorites, a persistent bottom player, and an admin + section (users, music folders, scanner status, system settings) +- `/healthz` and `/readyz` + +## Quick start + +``` +go build -o vibesonic ./cmd/vibesonic +VIBESONIC_SESSION_SECRET=$(openssl rand -hex 32) ./vibesonic +``` + +Then open `http://localhost:8080` — on a fresh database you'll land on +the setup wizard, which creates the administrator account, your first +music folder, and kicks off the first scan. + +`ffmpeg`/`ffprobe` should be on `PATH` (or pointed to via +`VIBESONIC_FFMPEG_PATH`/`VIBESONIC_FFPROBE_PATH`) for metadata reading +and cover art extraction to work. + +### Docker + +``` +docker compose up -d +``` + +See [`docker-compose.yml`](docker-compose.yml) — mount your music +directory read-only at `/music` and set `VIBESONIC_SESSION_SECRET`. + +### systemd + +See [`systemd/vibesonic.service`](systemd/vibesonic.service). + +## Configuration + +VibeSonic is configured entirely through `VIBESONIC_*` environment +variables — see [`docs/configuration.md`](docs/configuration.md) for the +full list and defaults. There is no separate config file. + +## Documentation + +- [`docs/architecture.md`](docs/architecture.md) — how the pieces fit together +- [`docs/configuration.md`](docs/configuration.md) — every environment variable +- [`docs/external-auth.md`](docs/external-auth.md) — trusted reverse-proxy authentication +- [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) — protocol endpoint status + +## Development + +``` +go fmt ./... +go vet ./... +go test ./... +``` + +Most tests exercise real SQLite databases and, where relevant, the real +`ffmpeg`/`ffprobe` binaries (skipped automatically if not installed). +No mocks are used for the database. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e9bb307 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + vibesonic: + build: . + image: vibesonic:latest + container_name: vibesonic + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - vibesonic-data:/data + - /path/to/your/music:/music:ro + environment: + VIBESONIC_SESSION_SECRET: "change-me-to-a-random-string" + # VIBESONIC_SCAN_INTERVAL: 1h + # VIBESONIC_EXTAUTH_ENABLED: "false" + +volumes: + vibesonic-data: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..996aa15 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,111 @@ +# Architecture + +VibeSonic is a single Go binary. There are no separate network services: +the web UI, (in later phases) the Subsonic API, the scanner, and +streaming all run in one process against one SQLite database. + +``` +Browser -----------+ + | +Subsonic clients ---+--> VibeSonic (single process) + (Phase 2+) | | + | +-- HTTP/Web (internal/web) + | +-- Authentication (internal/auth) + | +-- Library (internal/library) + | +-- Scanner (internal/scanner) + | +-- Streaming (internal/streaming) + | +-- Metadata (internal/metadata) + | +-- Cover art (internal/coverart) + | +-- SQLite (internal/database) + | + +--> Local music folders + + ffmpeg/ffprobe (external processes, invoked with + explicit argument lists — never a shell) +``` + +## Package layout + +``` +cmd/vibesonic/ entry point: loads config, opens the app, runs it +internal/ + app/ wires every subsystem together; owns the HTTP + server lifecycle and background loops + auth/ password hashing, session cookies, CSRF, + external (trusted-proxy) authentication + config/ VIBESONIC_* environment variable loading + coverart/ cover art discovery + resized-image disk cache + database/ SQLite connection, migrations, and one query + file per area of the schema (users, sessions, + library, activity, ...) + idgen/ stable opaque ID generation + library/ turns parsed tags into the artist/album/song + relational model; filesystem-style directory + listing + metadata/ ffprobe-based tag/technical-property extraction + scanner/ walks music folders, detects new/changed/ + deleted/renamed files, indexes them + streaming/ range-request-aware file serving, path safety + web/ server-rendered Bootstrap 5 UI (handlers, + templates via the top-level web/ package) +migrations/ embedded SQL schema migrations +web/ embedded templates + static assets (Bootstrap, + icons, player JS/CSS) +``` + +`internal/database` is the only package that talks to SQLite directly. +Every other package calls into it rather than holding its own +`*sql.DB` query logic, so the schema and its access patterns stay in one +place. + +## Data flow: scanning + +1. `internal/scanner` walks each enabled music folder's filesystem tree + with a bounded worker pool (4 concurrent files). +2. For each recognized audio file, it compares size+mtime against the + `media_files` row for that (folder, relative path) natural key: + - Unchanged → just marked seen (`last_scanned_at` touched), no reprobe. + - Missing by path but a `media_files` row with the same content + fingerprint exists elsewhere in the folder → treated as a + rename/move; the existing row's path is updated in place. + - New or changed → probed via `internal/metadata` (ffprobe), then + `internal/library.IndexFile` creates/updates the artist, album, + song, genre, and credit rows, reusing existing rows by natural key + (name, title+artist, etc.) so IDs stay stable. +3. After the walk, any file not touched during this scan pass is marked + `missing` (not deleted) — playlists, favorites, and play history + referencing it survive if the file reappears later. + +## Data flow: streaming + +1. The web handler resolves a song ID to its `media_files` row and owning + `music_folders` row. +2. `internal/streaming.ResolvePath` re-joins the folder root with the + stored relative path and verifies (after resolving symlinks) that the + result is still inside the folder root, rejecting any traversal. +3. `internal/streaming.ServeFile` opens the file and hands it to + `http.ServeContent`, which streams directly from disk (no full-file + buffering) and handles HTTP range requests and conditional GETs. + +Authorization (is this caller allowed to stream anything at all) happens +in the router, before any of the above runs: the route is wrapped in +`auth.Middleware.RequireUser`. + +## Stable IDs + +See [`internal/idgen`](../internal/idgen/idgen.go). IDs are random, +prefixed by type (`sg_` songs, `al_` albums, `ar_` artists, ...), and +assigned exactly once per row. Rescans never regenerate them: the +scanner and library indexer always look up existing rows by natural key +first (file path within a folder; artist name + MusicBrainz ID; album +title + primary artist; ...) and only call `idgen.New` for rows that have +never been seen before. + +## Why no ORM / interfaces-for-purity + +`database/sql` with explicit SQL and hand-scanned structs, one query file +per schema area. No interfaces are introduced purely for testability or +abstraction — tests use a real temporary SQLite database (see +`internal/database/database_test.go` and friends), because SQLite is +fast enough that this adds no meaningful cost and keeps the tests honest +about actual query behavior (foreign keys, constraints, collation). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..3fb7bef --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,37 @@ +# Configuration + +VibeSonic is configured entirely through environment variables. There is +no separate config file — everything needed to run the server is +available via `VIBESONIC_*` variables, with sane defaults for everything +except the session secret. + +| Variable | Default | Description | +|---|---|---| +| `VIBESONIC_LISTEN_ADDR` | `:8080` | HTTP listen address | +| `VIBESONIC_DATA_DIR` | `/var/lib/vibesonic` | Directory for the database, resized cover-art cache, etc. | +| `VIBESONIC_DB` | `/vibesonic.db` | Path to the SQLite database file | +| `VIBESONIC_MUSIC_DIRS` | *(none)* | Colon-separated list of music folders to import on startup, e.g. `/music:/music-lossless:/audiobooks`. Additional folders can also be added later from the admin UI. | +| `VIBESONIC_FFMPEG_PATH` | `ffmpeg` (from `PATH`) | Path to the `ffmpeg` binary | +| `VIBESONIC_FFPROBE_PATH` | `ffprobe` (from `PATH`) | Path to the `ffprobe` binary | +| `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. | +| `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. | +| `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. | + +## External authentication + +See [`docs/external-auth.md`](external-auth.md) for the full +`VIBESONIC_EXTAUTH_*` reference. + +## First run + +On a database with no users yet, every request redirects to `/setup`, +which walks you through creating the administrator account and your +first music folder, detects `ffmpeg`/`ffprobe`, and kicks off the first +scan. VibeSonic never ships default credentials — the administrator +account only exists once you create it through this wizard. + +## Migrations + +Schema migrations are embedded in the binary (`migrations/*.sql`) and +applied automatically on startup, tracked in a `schema_migrations` table. +There is nothing to run manually. diff --git a/docs/external-auth.md b/docs/external-auth.md new file mode 100644 index 0000000..f8c3c3f --- /dev/null +++ b/docs/external-auth.md @@ -0,0 +1,64 @@ +# External authentication + +VibeSonic supports optional Navidrome-style externalized authentication: +a trusted reverse proxy authenticates the browser user (via whatever +mechanism it likes — OIDC, SAML, HTTP Basic Auth, a VPN identity, ...) +and asserts that identity to VibeSonic via an HTTP header. **VibeSonic +never implements OIDC or talks to an identity provider itself** — the +proxy handles authentication, VibeSonic handles users and authorization. + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `VIBESONIC_EXTAUTH_ENABLED` | `false` | Enable external authentication | +| `VIBESONIC_EXTAUTH_TRUSTED_SOURCES` | *(none)* | Comma-separated list of IPv4/IPv6 CIDRs (or bare IPs) that are trusted to assert identity, e.g. `127.0.0.1/32,10.0.0.5/32` | +| `VIBESONIC_EXTAUTH_USER_HEADER` | `Remote-User` | Header name carrying the authenticated username | +| `VIBESONIC_EXTAUTH_LOGOUT_URL` | *(none)* | Optional URL to send users to on logout (e.g. the proxy's own logout endpoint), instead of VibeSonic's local `/login` | +| `VIBESONIC_EXTAUTH_AUTO_CREATE_USERS` | `true` | Automatically create a VibeSonic user the first time a trusted identity is seen | + +## The critical security property + +**The identity header is only trusted when the actual TCP connection +peer address matches a configured trusted CIDR.** This check +(`internal/auth/extauth.go`) reads `http.Request.RemoteAddr` — the real +socket peer — never a header. + +Headers like `X-Forwarded-For`, `X-Real-IP`, or `Forwarded` are +**never** consulted to decide whether the peer is trusted. Those headers +are attacker-controlled: any client can set them to anything. If +VibeSonic trusted them, an arbitrary internet client could add +`X-Forwarded-For: 127.0.0.1` and a `Remote-User: admin` header to its own +direct request and be treated as an authenticated administrator. + +This is why `VIBESONIC_EXTAUTH_TRUSTED_SOURCES` has **no default** — not +`0.0.0.0/0`, not `::/0`, not RFC1918 ranges. External auth trusts nothing +until you explicitly name a network (typically `127.0.0.1/32` for a +proxy on the same host, or the proxy's specific address on a Docker +bridge network). + +See `internal/auth/extauth_test.go` for the regression tests covering +this, including a test that a spoofed `X-Forwarded-For`/`X-Real-IP`/ +`Forwarded` header from an untrusted peer is ignored entirely. + +## How it resolves to a user + +A trusted identity resolves to an ordinary VibeSonic user (with +`external_auth = 1`), never a separate "external user" type. If +`VIBESONIC_EXTAUTH_AUTO_CREATE_USERS=true` and no user with that username +exists yet, one is created with no local password. If a user with that +username already exists (created locally or by a previous external +login), it's reused. + +## Interaction with native auth + +External auth and native session/password login coexist: the +`Authenticate` middleware tries the session cookie first, then falls back +to the trusted-proxy header. A user can still log in with a local +password on VibeSonic's own `/login` page even when external auth is +enabled, unless their account has no password hash set (auto-created +external users start this way). + +In later phases, `/rest/*` (the native Subsonic API) will authenticate +exclusively via Subsonic's own token/API-key scheme and must never be +affected by external auth's browser-cookie flow. diff --git a/docs/subsonic-compatibility.md b/docs/subsonic-compatibility.md new file mode 100644 index 0000000..000bc62 --- /dev/null +++ b/docs/subsonic-compatibility.md @@ -0,0 +1,125 @@ +# Subsonic / OpenSubsonic compatibility + +VibeSonic aims to eventually implement the Subsonic API (`/rest/*`) and +OpenSubsonic extensions. **Phase 1 implements none of this** — it only +builds the foundation (database schema, stable IDs, library indexing, +streaming, cover art) that the protocol layer will sit on top of in a +later phase. + +This matrix will be filled in as `internal/subsonic` and +`internal/opensubsonic` are implemented. For now, every endpoint is +"Not yet implemented." + +## System + +| Endpoint | Status | +|---|---| +| `ping` | Not yet implemented | +| `getLicense` | Not yet implemented | + +## Browsing (filesystem-style) + +| Endpoint | Status | Notes | +|---|---|---| +| `getMusicFolders` | Not yet implemented | `music_folders` table and `internal/database` queries exist | +| `getIndexes` | Not yet implemented | `internal/library.ListDirectory` provides the foundation | +| `getMusicDirectory` | Not yet implemented | same | + +## Browsing (ID3/tag-style) + +| Endpoint | Status | Notes | +|---|---|---| +| `getArtists` | Not yet implemented | `database.ListArtists` exists | +| `getArtist` | Not yet implemented | `database.ListAlbumsByArtist` exists | +| `getAlbum` | Not yet implemented | `database.ListSongsByAlbum` exists | +| `getSong` | Not yet implemented | `database.GetSong` exists | +| `getGenres` | Not yet implemented | `database.ListGenres` exists | +| `getAlbumList` / `getAlbumList2` | Not yet implemented | | +| `getRandomSongs` | Not yet implemented | | +| `getSongsByGenre` | Not yet implemented | | + +## Search + +| Endpoint | Status | +|---|---| +| `search3` | Not yet implemented | + +## Streaming / media retrieval + +| Endpoint | Status | Notes | +|---|---|---| +| `stream` | Not yet implemented | `internal/streaming` provides range-request-aware serving; the web UI's `/stream/{id}` route is the non-Subsonic equivalent used today | +| `download` | Not yet implemented | | +| `getCoverArt` | Not yet implemented | `internal/coverart` provides discovery + resize cache; the web UI's `/cover-art/{id}` route is the non-Subsonic equivalent used today | +| `getLyrics` / `getLyricsBySongId` | Not yet implemented | `lyrics`/`lyrics_lines`/`lyrics_cues` tables exist | +| `getAvatar` | Not yet implemented | | + +## Playlists + +| Endpoint | Status | Notes | +|---|---|---| +| `getPlaylists` | Not yet implemented | `database.ListPlaylistsVisibleToUser` exists; web UI has a playlists page | +| `getPlaylist` | Not yet implemented | | +| `createPlaylist` | Not yet implemented | | +| `updatePlaylist` | Not yet implemented | | +| `deletePlaylist` | Not yet implemented | | + +## Favorites / ratings / activity + +| Endpoint | Status | Notes | +|---|---|---| +| `star` / `unstar` | Not yet implemented | `database.Star`/`Unstar` exist; web UI has favorite buttons | +| `setRating` | Not yet implemented | `database.SetRating` exists | +| `scrobble` | Not yet implemented | `scrobbles` table exists | +| `getStarred` / `getStarred2` | Not yet implemented | | +| `getNowPlaying` | Not yet implemented | `now_playing` table + `database.ListNowPlaying` exist | + +## Bookmarks / play queue + +| Endpoint | Status | Notes | +|---|---|---| +| `getBookmarks` / `createBookmark` / `deleteBookmark` | Not yet implemented | `database` functions exist | +| `getPlayQueue` / `savePlayQueue` | Not yet implemented | `database` functions exist | + +## Sharing + +| Endpoint | Status | Notes | +|---|---|---| +| `getShares` / `createShare` / `updateShare` / `deleteShare` | Not yet implemented | `shares`/`share_items` tables exist | + +## Podcasts + +| Endpoint | Status | Notes | +|---|---|---| +| `getPodcasts` | Not yet implemented | `podcast_channels`/`podcast_episodes` tables exist | +| `createPodcastChannel` / `deletePodcastChannel` | Not yet implemented | | +| `downloadPodcastEpisode` / `deletePodcastEpisode` | Not yet implemented | | + +## Internet radio + +| Endpoint | Status | Notes | +|---|---|---| +| `getInternetRadioStations` | Not yet implemented | `internet_radio_stations` table exists | +| `createInternetRadioStation` / `updateInternetRadioStation` / `deleteInternetRadioStation` | Not yet implemented | | + +## Users / chat + +| Endpoint | Status | Notes | +|---|---|---| +| `getUser` / `getUsers` | Not yet implemented | native web admin has a users page | +| `createUser` / `updateUser` / `deleteUser` | Not yet implemented | | +| `addChatMessage` / `getChatMessages` | Not yet implemented | `chat_messages` table exists | + +## Transcoding + +| Endpoint | Status | Notes | +|---|---|---| +| Transcoding profiles (`internal/transcoding`) | Not yet implemented | `transcode_profiles` table exists; Phase 1 only supports direct (non-transcoded) streaming | + +## Authentication + +Phase 1's `/rest/*` authentication (token-based `u`/`t`/`s` or API key) +is not yet implemented. When it is, it will authenticate independently +of the browser session/CSRF/external-auth flow used by the web UI, per +the project's design constraint that external auth must never interfere +with native Subsonic client authentication. diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index ee6c75e..bfbbdd8 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -65,7 +65,11 @@ func (h *Web) loginFailed(w http.ResponseWriter, r *http.Request, next string) { func (h *Web) handleLogout(w http.ResponseWriter, r *http.Request) { h.Sessions.Clear(w, r) - http.Redirect(w, r, "/login", http.StatusFound) + dest := "/login" + if h.Config.ExtAuth.Enabled && h.Config.ExtAuth.LogoutURL != "" { + dest = h.Config.ExtAuth.LogoutURL + } + http.Redirect(w, r, dest, http.StatusFound) } // --- First-run setup --- diff --git a/systemd/vibesonic.service b/systemd/vibesonic.service new file mode 100644 index 0000000..97f3786 --- /dev/null +++ b/systemd/vibesonic.service @@ -0,0 +1,44 @@ +[Unit] +Description=VibeSonic - lightweight self-hosted music server +After=network-online.target +Wants=network-online.target +Documentation=https://git.noroutes.net/NoRoutes/vibetunes + +[Service] +Type=simple +User=vibesonic +Group=vibesonic +Environment=VIBESONIC_LISTEN_ADDR=:8080 +Environment=VIBESONIC_DATA_DIR=/var/lib/vibesonic +Environment=VIBESONIC_DB=/var/lib/vibesonic/vibesonic.db +Environment=VIBESONIC_MUSIC_DIRS=/music +Environment=VIBESONIC_FFMPEG_PATH=/usr/bin/ffmpeg +Environment=VIBESONIC_FFPROBE_PATH=/usr/bin/ffprobe +Environment=VIBESONIC_SCAN_INTERVAL=1h +# Generate a real secret: openssl rand -hex 32 +Environment=VIBESONIC_SESSION_SECRET=change-me-to-a-random-string +ExecStart=/usr/local/bin/vibesonic +Restart=on-failure +RestartSec=2s +TimeoutStopSec=30s + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +ReadOnlyPaths=/music +ReadWritePaths=/var/lib/vibesonic + +StateDirectory=vibesonic +StateDirectoryMode=0700 + +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target