Merge pull request 'feat: web UI server auth foundation (/api/* login, logout, me)' (#14) from dev into main

This commit was merged in pull request #14.
This commit is contained in:
2026-04-21 02:51:45 +00:00
21 changed files with 2658 additions and 7 deletions
+3
View File
@@ -25,3 +25,6 @@ go.work.sum
# env file
.env
# Superpowers brainstorming companion sessions
.superpowers/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,268 @@
# Web UI Scaffold — Design
**Status:** Approved (brainstorm), ready for implementation plan
**Date:** 2026-04-20
**Milestone:** M1.5 (new) — scaffold for the web UI that M2M5 layer features onto
## Purpose
Minstrel needs a built-in web UI with feature parity to the planned Flutter client. The M1 Subsonic baseline is verified end-to-end via third-party clients (Feishin), but Minstrel's own UI surface is currently empty. This spec describes the scaffold: the stack, the packaging, the auth model, the API surface the SPA consumes, the shell layout, and the first-cut feature set.
Features that depend on backend work not yet built (events, recommendations, radio, Lidarr) are explicitly deferred and will land alongside their respective backend milestones. The web UI is not a late-stage M6 milestone — it advances through M2M5 in step with the server.
## Non-goals
- OIDC / SSO login (deferred until after a solid UI exists; possibly post-v1).
- Light-theme support (dark theme only for first cut; light mode follows).
- Mobile-first responsive layouts (desktop-first; the web UI is expected to be *more dense* than the Flutter client, which will own the mobile experience).
- SSR / server-rendered initial HTML (pure SPA — SvelteKit static adapter).
- Reusing the `/rest/*` Subsonic surface from the SPA.
## Stack
- **Framework:** SvelteKit + TypeScript + Vite.
- **Adapter:** `@sveltejs/adapter-static`, building to `web/build/` as fully-static HTML/CSS/JS.
- **SPA fallback:** `adapter-static` configured with `fallback: 'index.html'` so deep links (`/artists/abc`) load the SPA shell and client-side routing resolves the path.
- **Styling:** Tailwind CSS + component-scoped Svelte styles. No UI kit — hand-rolled components kept minimal.
- **State:** Svelte stores. No external state library.
- **HTTP:** Native `fetch`; a thin wrapper (`web/src/lib/api.ts`) adds credentials, parses errors, returns typed responses.
### Why Svelte over React/Solid
- Compiled output is small and fast. Matters when rendering large library views (paginated at first; virtualization if we hit perf walls later).
- Built-in routing / stores / forms mean fewer library decisions for a solo project.
- The trade-off (smaller ecosystem) is acceptable because the components we need — list views, player UI, form controls — are trivial to build or exist as libraries.
## Packaging
- **Single binary, single image.** SvelteKit builds to static assets; Go embeds `web/build/` via `//go:embed` and serves from root (`/`) with SPA fallback (unmatched paths return `index.html`).
- **Dockerfile:** multi-stage.
1. `FROM node:<lts> AS web``npm ci`, `npm run build`, produces `web/build/`.
2. `FROM golang:<ver> AS go``go build`, with `web/build/` copied in before build so `//go:embed` picks it up.
3. `FROM debian:stable-slim` (or existing base) — copy binary, ffmpeg, run.
- Release artifact remains a single container image. `docker compose up` stays one service for the app.
## Dev workflow
- Two processes during development:
1. `docker compose up` — Go server on `:4533`, Postgres, scanner.
2. `cd web && npm run dev` — Vite dev server on `:5173` with HMR.
- Vite dev server proxies `/api/*` and `/rest/*` to `http://localhost:4533`. The SPA is loaded from `:5173` in dev; cookies on `:5173` work because the proxy rewrites the origin.
- Production build: `npm run build` runs in CI / Docker build; static assets get embedded.
## API surface
### Principle
- `/rest/*` is the Subsonic-compatibility surface for third-party clients. It keeps salted-md5 + apiKey auth, Subsonic envelope, XML/JSON shape. **The SPA does not call `/rest/*`.**
- `/api/*` is the native surface for Minstrel's own clients (web SPA now, Flutter later). Clean JSON, cookie-or-bearer auth, designed for modern consumers.
### First-cut endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/auth/login` | Verify credentials. Returns `{token, user}`, sets `httpOnly` session cookie. |
| POST | `/api/auth/logout` | Invalidate session cookie and the bearer token associated with the caller's session. |
| GET | `/api/me` | Return the authenticated user (username, is_admin, etc.). |
| GET | `/api/artists` | Paginated artist list with sort keys. |
| GET | `/api/artists/{id}` | Artist with albums array. |
| GET | `/api/albums/{id}` | Album with tracks array. |
| GET | `/api/tracks/{id}` | Single track (used for now-playing detail / direct-link). |
| GET | `/api/search?q=` | Unified search returning `{artists, albums, tracks}` arrays. |
| GET | `/api/stream/{trackId}` | Audio stream with `Accept-Ranges: bytes` for scrubbing. Cookie-auth works because `<audio src>` sends cookies; bearer works for non-browser clients. |
| GET | `/api/cover/{albumId}` | Album cover art, sized via `?size=` query. |
### Implementation approach
- Handlers are thin — argument parsing, call to existing sqlc queries in `internal/db/dbq/*`, JSON marshalling.
- Shared types go in `internal/api/types.go` (request/response shapes). These map 1:1 to TypeScript types in `web/src/lib/types.ts` (kept hand-synced; codegen deferred).
- Errors follow `{error: {code, message}}` with standard HTTP status codes. No Subsonic-style "status=failed" envelope on `/api/*`.
### Deferred endpoints (land with their backend milestone)
- M2: `POST /api/events/{play|skip|complete}`, `POST /api/tracks/{id}/star`, `DELETE /api/tracks/{id}/star`, `GET /api/me/recently-played`.
- M3: `GET /api/shuffle`, `POST /api/tracks/{id}/contextual-like`, related artist/session queries.
- M4: `POST /api/radio`, `GET /api/radio/{id}`, ListenBrainz settings.
- M5: Lidarr proxy + quarantine endpoints.
## Authentication
### Login flow
1. SPA `POST /api/auth/login` with `{username, password}`.
2. Server verifies against `password_hash`, creates a session row (opaque 32-byte random token), returns `{token, user}`.
3. Server also sets `Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/`.
4. SPA stores the user object in a Svelte store; the cookie travels automatically. **SPA does not persist the bearer token** — it's returned only so Flutter can consume the same endpoint later.
### Middleware
- `internal/auth.RequireUser` middleware resolves the caller in this order:
1. `Cookie: session=<token>` → sessions table lookup.
2. `Authorization: Bearer <token>` → sessions table lookup.
3. Legacy Subsonic params (only within `/rest/*` — unchanged from today).
- On success, user is placed in request context (same pattern as `UserFromContext` used in `internal/subsonic`).
- Unauthenticated requests to `/api/*``401 {error: {code: "unauthenticated", message: "..."}}`.
### Sessions table
- New migration: `sessions(id uuid pk, user_id uuid fk, token_hash bytea, created_at, last_seen_at, user_agent text)`.
- Token is hashed in DB (sha256) so a DB leak doesn't grant active sessions.
- Logout deletes the session row; cookie is expired via `Set-Cookie: session=; Max-Age=0`.
### Why this design
- Web is cookie-safe (no token in JS / localStorage → no XSS extraction).
- Flutter later uses the same `POST /api/auth/login` endpoint; it ignores the cookie and sends `Authorization: Bearer` on every call.
- OIDC, when it eventually lands, plugs in at the same login-success point: the callback handler mints the same `{token + cookie}` response. The rest of the app doesn't change.
## Shell layout
### Structure
```
+-------------------------------------------------------------+
| SIDEBAR | MAIN CONTENT |
| | |
| Minstrel | (route content — artists list, |
| | album grid, search results, |
| ▸ Home | artist detail, settings, etc.) |
| ▸ Library | |
| ▸ Search | |
| ▸ Recent | |
| ▸ Settings | |
| | |
| — Admin — | |
| ▸ Users | |
| ▸ Scan | |
| ▸ Quarantine | |
| | |
+-------------------------------------------------------------+
| PLAYER BAR: art · title / artist · controls · scrub · time |
+-------------------------------------------------------------+
```
### Components
- `Sidebar.svelte` — brand, primary nav, admin section (items render as disabled until their backend exists).
- `PlayerBar.svelte` — persistent across route changes. Owns a single `<audio>` element via `bind:this`. Reads from and writes to the `player` Svelte store.
- `RouteShell.svelte` — root layout. Mounts sidebar + outlet + player bar. Subscribes `player` store to `<audio>` element events (`timeupdate`, `ended`, `error`).
- Routes: `/login`, `/` (home), `/library`, `/artists/[id]`, `/albums/[id]`, `/search`, `/settings`, `/admin/*`.
### Player state (Svelte store)
```typescript
interface PlayerState {
queue: Track[];
queueIndex: number;
isPlaying: boolean;
currentTime: number; // seconds
duration: number; // seconds
volume: number; // 0..1
}
```
- Queue operations: `enqueue`, `playNow`, `prev`, `next`, `seek`, `setVolume`.
- Store methods kick the `<audio>` element via imperative calls (`audio.play()`, `audio.currentTime = …`).
- The store is the only place that writes player state; components read via `$player` subscriptions.
### Theme
- Dark-only for first cut. Colors via CSS custom properties in `:root`. Palette: neutral dark grays (`#14161a` / `#1a1d22` / `#2a2f36`), light text (`#cdd3db` / `#e8ecf2`), accent color picked during implementation — scaffold works with any single-hue accent.
- Layout convention: left sidebar width `240px`, player bar height `72px`, content scrolls independently of shell.
### Keyboard shortcuts
- `Space` — play/pause (blocked when focus is in an input).
- `ArrowLeft` / `ArrowRight` — skip back / forward 5s.
- `Shift+ArrowLeft` / `Shift+ArrowRight` — prev / next track.
- `/` — focus search input.
## First-cut feature scope
- Login / logout.
- Artist list (sortable, searchable within-list).
- Artist detail → albums grid.
- Album detail → track list with inline play buttons.
- Global search: `?q=` against tracks + albums + artists, results grouped.
- Player: queue, play/pause/seek/prev/next, volume, keyboard shortcuts, auto-advance on `ended`.
- Now-playing: persistent player bar always visible; clicking it opens an expanded queue panel.
## Deferred
Tracked in Fable milestones 2730 (M2M5). Each milestone adds the corresponding UI alongside its backend work.
- **M2:** Star/unstar UI, recently-played view, event reporting from player (play/skip/complete at the appropriate thresholds).
- **M3:** Smart-shuffle entry points, contextual-like controls.
- **M4:** Radio seed / start UI, ListenBrainz account connection in Settings.
- **M5:** Lidarr quarantine admin, "suggested additions" panel on radio.
- **Post-v1:** Stats dashboards, user management UI, OIDC login, light theme, mobile-responsive polish.
## Testing
- **Svelte (Vitest):** component tests for non-trivial units only — `player` store (queue/seek/advance logic), `api.ts` (error parsing / auth header injection), auth store. Skip snapshot tests and boilerplate component rendering.
- **Go (`internal/api`):** handler-level tests per the existing pattern in `internal/subsonic/*_test.go` — httptest ResponseRecorder, context-injected user, JSON decode of response body.
- **No E2E / Playwright** for first cut. If a full-stack smoke test becomes valuable, add one headless Playwright scenario (login → play a track) later.
## Repo structure
```
minstrel/
├── cmd/minstrel/ # Go binary (unchanged)
├── internal/
│ ├── api/ # NEW: /api/* handlers + shared types
│ │ ├── auth.go
│ │ ├── library.go
│ │ ├── search.go
│ │ ├── stream.go
│ │ ├── types.go
│ │ └── *_test.go
│ ├── auth/ # existing; add session token logic
│ ├── subsonic/ # unchanged
│ ├── library/ # unchanged
│ ├── db/ # + migration for sessions
│ └── server/ # Router() wires /api/* alongside /rest/*
├── web/ # NEW: SvelteKit project
│ ├── src/
│ │ ├── lib/
│ │ │ ├── api.ts # fetch wrapper
│ │ │ ├── stores/
│ │ │ │ ├── player.ts
│ │ │ │ └── auth.ts
│ │ │ ├── components/ # Sidebar, PlayerBar, etc.
│ │ │ └── types.ts # TypeScript mirrors of internal/api/types.go
│ │ ├── routes/
│ │ │ ├── +layout.svelte
│ │ │ ├── +page.svelte # Home
│ │ │ ├── login/+page.svelte
│ │ │ ├── artists/+page.svelte
│ │ │ ├── artists/[id]/+page.svelte
│ │ │ ├── albums/[id]/+page.svelte
│ │ │ ├── search/+page.svelte
│ │ │ └── settings/+page.svelte
│ │ └── app.html
│ ├── static/
│ ├── svelte.config.js # adapter-static + fallback
│ ├── vite.config.ts # /api + /rest proxy for dev
│ ├── package.json
│ └── tsconfig.json
├── docs/
├── Dockerfile # multi-stage: node → go → slim
├── docker-compose.yml
└── ...
```
## Open questions resolved
- **Framework:** Svelte (B in brainstorm).
- **Packaging:** Single binary with embedded static (A in brainstorm).
- **Auth:** Cookie + bearer from same endpoint (C in brainstorm).
- **API shape:** New `/api/*` surface (B in brainstorm).
- **Scope:** Rich target, scaffold first, features woven through M2M5 (C in brainstorm with sequencing plan).
- **Layout:** Left sidebar + bottom persistent player (A in brainstorm).
## Risks / decisions to revisit during implementation
- **Cover art sizing.** `/rest/getCoverArt` does thumbnail scaling via ffmpeg (or similar). `/api/cover/{id}?size=` may need the same logic; could call the existing Subsonic helper internally rather than re-implementing.
- **TypeScript / Go type sync.** Manual hand-sync is fine at first-cut scale (maybe 10 types). If it becomes a churn point, introduce codegen (oapi-codegen / sqlc-like generator) — not first-cut scope.
- **Stream auth with `<audio>` elements.** Cookies carry automatically. Verify Chromium, Firefox, Safari all forward the cookie on `<audio src>` requests — we believe they do; flag if not.
- **Keyboard shortcut collisions.** Space-bar for play/pause conflicts with page scroll. Only capture when body isn't scrolled-to-focus-element; easy to get wrong. Write a small test when implementing.
+34
View File
@@ -0,0 +1,34 @@
// Package api implements Minstrel's native JSON surface under /api. It is
// consumed by the built-in web SPA and (eventually) the Flutter client.
// Subsonic-compatible endpoints under /rest are intentionally separate —
// see internal/subsonic — and the two packages must not depend on each other.
package api
import (
"log/slog"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
h := &handlers{pool: pool, logger: logger}
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
authed.Post("/auth/logout", h.handleLogout)
authed.Get("/me", h.handleGetMe)
})
})
}
type handlers struct {
pool *pgxpool.Pool
logger *slog.Logger
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire
// server-side yet (future work); the cookie still caps browser-side lifetime
// so an abandoned laptop doesn't stay logged in forever.
const sessionCookieMaxAge = 30 * 24 * time.Hour
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
// The session token can be on the cookie OR bearer header — RequireUser
// accepted either. Re-resolve it here so we can delete the row.
token := sessionTokenFromHTTP(r)
if token != "" {
if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil {
h.logger.Warn("api: delete session failed", "err", err)
// Continue — logout is best-effort; the client still gets the
// cookie cleared.
}
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
w.WriteHeader(http.StatusNoContent)
}
// sessionTokenFromHTTP duplicates the internal helper in internal/auth
// because that one is unexported. Cheap to repeat here; keeping the auth
// package's internal helper package-private is worth more than DRY. Must
// match that helper's trimming behavior exactly — otherwise a bearer with
// trailing whitespace authenticates via RequireUser (which trims) but logout
// hashes the padded value and silently no-ops, leaving the session alive.
func sessionTokenFromHTTP(r *http.Request) string {
if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" {
return c.Value
}
h := r.Header.Get("Authorization")
const prefix = "bearer "
if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") {
return strings.TrimSpace(h[len(prefix):])
}
return ""
}
func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
if req.Username == "" || req.Password == "" {
writeErr(w, http.StatusBadRequest, "bad_request", "username and password required")
return
}
q := dbq.New(h.pool)
user, err := q.GetUserByUsername(r.Context(), req.Username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
h.logger.Error("api: user lookup failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
token, err := auth.MintSessionToken()
if err != nil {
h.logger.Error("api: mint session token failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "mint failed")
return
}
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
UserAgent: r.UserAgent(),
}); err != nil {
h.logger.Error("api: insert session failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(LoginResponse{
Token: token,
User: UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
},
})
}
+239
View File
@@ -0,0 +1,239 @@
package api
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
// Skips in -short mode or when the env var is missing, matching the pattern
// used elsewhere (scanner_test.go, etc.).
func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
t.Helper()
if testing.Short() {
t.Skip("skipping api integration in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(),
"TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
return &handlers{pool: pool, logger: logger}, pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: username,
PasswordHash: string(hash),
ApiToken: "test-api-token-" + username,
IsAdmin: isAdmin,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u
}
func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) {
h, pool := testHandlers(t)
seedUser(t, pool, "alice", "hunter2", false)
body := strings.NewReader(`{"username":"alice","password":"hunter2"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
User struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
}
if resp.Token == "" {
t.Error("token empty")
}
if resp.User.Username != "alice" || resp.User.IsAdmin {
t.Errorf("user = %+v, want alice/non-admin", resp.User)
}
var cookieFound bool
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
cookieFound = true
if !c.HttpOnly {
t.Error("session cookie missing HttpOnly")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("SameSite = %v, want Strict", c.SameSite)
}
if c.Value != resp.Token {
t.Error("cookie value does not match response token")
}
}
}
if !cookieFound {
t.Error("session cookie not set")
}
}
func TestHandleLogin_WrongPasswordReturns401(t *testing.T) {
h, pool := testHandlers(t)
seedUser(t, pool, "alice", "hunter2", false)
body := strings.NewReader(`{"username":"alice","password":"wrong"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleLogin_UnknownUserReturns401(t *testing.T) {
h, _ := testHandlers(t)
body := strings.NewReader(`{"username":"ghost","password":"whatever"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
bytes.NewReader([]byte("not-json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() }
func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
// Manually create a session to log out of.
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
}); err != nil {
t.Fatalf("insert: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
// handleLogout runs behind RequireUser in real routing; simulate that by
// putting the user into context here.
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleLogout(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
// Cookie should be cleared.
var cleared bool
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("session cookie not cleared")
}
// Session row should be gone.
_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
if err == nil {
t.Error("session row still present after logout")
}
}
func TestHandleLogout_BearerHeaderWithTrailingWhitespaceDeletesSession(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
}); err != nil {
t.Fatalf("insert: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
// Trailing whitespace — RequireUser trims this, so logout must too.
req.Header.Set("Authorization", "Bearer "+token+" ")
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleLogout(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
if err == nil {
t.Error("session row still present after bearer logout with trailing whitespace")
}
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"encoding/json"
"net/http"
)
// errorBody is the JSON envelope for failures on /api/*. Kept separate from
// the Subsonic envelope so native clients don't have to parse two shapes.
type errorBody struct {
Error errorPayload `json:"error"`
}
type errorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
}
// writeErr is the canonical way to emit a failure. Code is a stable
// short-string clients can switch on; message is human-readable.
func writeErr(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestWriteErr_EmitsJSONEnvelope(t *testing.T) {
w := httptest.NewRecorder()
writeErr(w, http.StatusBadRequest, "bad_request", "field x required")
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if got := w.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("content-type = %q, want application/json", got)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
}
if body.Error.Code != "bad_request" || body.Error.Message != "field x required" {
t.Errorf("body = %+v", body)
}
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"encoding/json"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
// Hitting /me without RequireUser in front of it is a routing bug;
// it can't happen in real traffic.
h.logger.Error("api: /me reached without authenticated user")
writeErr(w, http.StatusInternalServerError, "server_error", "missing auth context")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
})
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", true)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleGetMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var got UserView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Username != "alice" || !got.IsAdmin {
t.Errorf("user = %+v, want alice/admin", got)
}
}
func TestHandleGetMe_MissingContextReturns500(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
w := httptest.NewRecorder()
h.handleGetMe(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500 (context should be populated by RequireUser)", w.Code)
}
_ = dbq.User{} // keep the import used
}
+26
View File
@@ -0,0 +1,26 @@
package api
import "github.com/jackc/pgx/v5/pgtype"
// LoginRequest is the POST /api/auth/login body. Kept boring on purpose —
// web and Flutter both send the same payload.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse carries the opaque session token AND the authenticated user
// so the SPA can hydrate its auth store in one round-trip. The token is also
// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer.
type LoginResponse struct {
Token string `json:"token"`
User UserView `json:"user"`
}
// UserView is the /api/* view of a user. Narrower than dbq.User — no hash,
// no api_token, no subsonic_password.
type UserView struct {
ID pgtype.UUID `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
}
+135
View File
@@ -0,0 +1,135 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// sessionTokenBytes is the raw entropy per session token. 32 bytes of
// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie
// value is 43 chars with no padding.
const sessionTokenBytes = 32
// MintSessionToken returns a freshly-generated, url-safe opaque token.
// The token is what the client carries; the DB only ever sees its sha256.
func MintSessionToken() (string, error) {
b := make([]byte, sessionTokenBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// HashSessionToken is the single source of truth for mapping a raw token to
// the `sessions.token_hash` column. sha256 is fine here — we're not guarding
// against offline brute force (the token has 256 bits of entropy); we only
// want "leaked DB row can't be replayed without also having the raw token."
func HashSessionToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
// VerifyPassword is the canonical bcrypt comparison. Returns false on a
// malformed hash so callers don't need to distinguish "hash invalid" from
// "password wrong" — both are auth failures from the client's perspective.
func VerifyPassword(hash, plaintext string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
}
// SessionCookieName is the cookie the web SPA rides. Exported because handlers
// that issue/clear the cookie (handleLogin / handleLogout) need to match it.
const SessionCookieName = "minstrel_session"
// RequireUser resolves the caller from a session cookie OR Authorization
// bearer header and puts the dbq.User in request context via userCtxKey.
// Requests without a valid session return 401 with no body so callers don't
// leak whether the username existed (matches the /rest/* auth posture).
func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := sessionTokenFromRequest(r)
if token == "" {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if pool == nil {
// Test-only path: the test at the top of this file constructs
// the middleware with nil pool to prove the no-token case
// short-circuits without a DB call. Any real token here is
// programmer error.
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
q := dbq.New(pool)
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
slog.Error("api: session lookup failed", "err", err)
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
return
}
user, err := q.GetUserByID(r.Context(), sess.UserID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Session points at a deleted user — treat as unauth,
// best-effort cleanup.
_ = q.DeleteSession(r.Context(), sess.ID)
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
slog.Error("api: user lookup failed", "err", err)
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
return
}
// Best-effort last-seen update. A failure here shouldn't fail the
// request; the session is still valid and this is observability.
if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil {
slog.Warn("api: touch session last_seen failed", "err", err)
}
ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// UserCtxKeyForTest is exported ONLY for tests in sibling packages that need
// to inject a dbq.User into request context without going through the
// middleware. Do not use this outside _test.go files.
func UserCtxKeyForTest() any { return userCtxKey }
func sessionTokenFromRequest(r *http.Request) string {
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
return c.Value
}
return extractBearerToken(r.Header.Get("Authorization"))
}
// extractBearerToken pulls the token out of an Authorization header in
// either "Bearer xyz" or "bearer xyz" form. Returns "" when the header is
// missing, malformed, or uses a different scheme.
func extractBearerToken(header string) string {
if header == "" {
return ""
}
const prefix = "bearer "
lower := strings.ToLower(header)
if !strings.HasPrefix(lower, prefix) {
return ""
}
return strings.TrimSpace(header[len(prefix):])
}
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"crypto/sha256"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestMintSessionToken_ReturnsUrlSafeBase64(t *testing.T) {
token, err := MintSessionToken()
if err != nil {
t.Fatalf("MintSessionToken: %v", err)
}
if len(token) < 40 {
t.Errorf("token length = %d, want >= 40 (32B base64 url-safe)", len(token))
}
if strings.ContainsAny(token, "+/=") {
t.Errorf("token %q contains non-url-safe chars", token)
}
}
func TestHashSessionToken_IsDeterministicSHA256(t *testing.T) {
token := "test-token-xyz"
want := sha256.Sum256([]byte(token))
got := HashSessionToken(token)
if len(got) != sha256.Size {
t.Fatalf("hash length = %d, want %d", len(got), sha256.Size)
}
if base64.StdEncoding.EncodeToString(got) != base64.StdEncoding.EncodeToString(want[:]) {
t.Errorf("hash = %x, want %x", got, want)
}
}
func TestVerifyPassword(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
if err != nil {
t.Fatalf("GenerateFromPassword: %v", err)
}
if !VerifyPassword(string(hash), "hunter2") {
t.Error("correct password rejected")
}
if VerifyPassword(string(hash), "wrong") {
t.Error("wrong password accepted")
}
if VerifyPassword("not-a-hash", "hunter2") {
t.Error("malformed hash accepted")
}
}
func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("handler must not be called")
})
h := RequireUser(nil)(next)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestExtractBearerToken(t *testing.T) {
cases := []struct {
header string
want string
}{
{"", ""},
{"Bearer abc", "abc"},
{"bearer abc", "abc"},
{"Token abc", ""},
{"Bearer", ""},
{"Bearer whitespace-token ", "whitespace-token"},
}
for _, c := range cases {
got := extractBearerToken(c.header)
if got != c.want {
t.Errorf("extractBearerToken(%q) = %q, want %q", c.header, got, c.want)
}
}
}
+9
View File
@@ -29,6 +29,15 @@ type Artist struct {
UpdatedAt pgtype.Timestamptz
}
type Session struct {
ID pgtype.UUID
UserID pgtype.UUID
TokenHash []byte
UserAgent string
CreatedAt pgtype.Timestamptz
LastSeenAt pgtype.Timestamptz
}
type Track struct {
ID pgtype.UUID
Title string
+83
View File
@@ -0,0 +1,83 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: sessions.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = $1
`
func (q *Queries) DeleteSession(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteSession, id)
return err
}
const deleteSessionByTokenHash = `-- name: DeleteSessionByTokenHash :exec
DELETE FROM sessions WHERE token_hash = $1
`
func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte) error {
_, err := q.db.Exec(ctx, deleteSessionByTokenHash, tokenHash)
return err
}
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at FROM sessions WHERE token_hash = $1
`
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash)
var i Session
err := row.Scan(
&i.ID,
&i.UserID,
&i.TokenHash,
&i.UserAgent,
&i.CreatedAt,
&i.LastSeenAt,
)
return i, err
}
const insertSession = `-- name: InsertSession :one
INSERT INTO sessions (user_id, token_hash, user_agent)
VALUES ($1, $2, $3)
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at
`
type InsertSessionParams struct {
UserID pgtype.UUID
TokenHash []byte
UserAgent string
}
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (Session, error) {
row := q.db.QueryRow(ctx, insertSession, arg.UserID, arg.TokenHash, arg.UserAgent)
var i Session
err := row.Scan(
&i.ID,
&i.UserID,
&i.TokenHash,
&i.UserAgent,
&i.CreatedAt,
&i.LastSeenAt,
)
return i, err
}
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
UPDATE sessions SET last_seen_at = now() WHERE id = $1
`
func (q *Queries) TouchSessionLastSeen(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, touchSessionLastSeen, id)
return err
}
+19
View File
@@ -74,6 +74,25 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE username = $1
`
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS sessions_user_id_idx;
DROP TABLE IF EXISTS sessions;
@@ -0,0 +1,18 @@
-- Session tokens authenticate /api/* requests. We store sha256(token) so a
-- read-only DB leak doesn't grant active sessions; the raw token lives only
-- in the client's cookie (web) or bearer header (Flutter).
--
-- last_seen_at enables an "active sessions" UI later (not wired in this plan)
-- without schema churn. user_agent is captured at issue time for the same
-- reason — free metadata now, no migration later.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash bytea NOT NULL UNIQUE,
user_agent text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
+16
View File
@@ -0,0 +1,16 @@
-- name: InsertSession :one
INSERT INTO sessions (user_id, token_hash, user_agent)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetSessionByTokenHash :one
SELECT * FROM sessions WHERE token_hash = $1;
-- name: TouchSessionLastSeen :exec
UPDATE sessions SET last_seen_at = now() WHERE id = $1;
-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = $1;
-- name: DeleteSessionByTokenHash :exec
DELETE FROM sessions WHERE token_hash = $1;
+3
View File
@@ -16,3 +16,6 @@ SELECT count(*) FROM users;
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
UPDATE users SET subsonic_password = $2 WHERE id = $1;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
+7 -7
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
@@ -40,13 +41,12 @@ func (s *Server) Router() http.Handler {
r.Get("/healthz", s.handleHealthz)
if s.Pool != nil {
r.Route("/api", func(api chi.Router) {
api.Route("/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin(s.Pool))
if s.Scanner != nil {
admin.Post("/scan", s.handleAdminScan)
}
})
api.Mount(r, s.Pool, s.Logger)
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin(s.Pool))
if s.Scanner != nil {
admin.Post("/scan", s.handleAdminScan)
}
})
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
}