diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml deleted file mode 100644 index 248deaae..00000000 --- a/.gitea/workflows/android.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: android - -# Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client. -# This workflow is testing only — lint + detekt + unit tests on every push -# to dev/main, plus a debug APK artifact for main. The signed-release -# build + asset attach + image-bundling lives in release.yml under a -# `needs:` chain so the docker image cannot ship without the APK. - -on: - push: - branches: [main, dev] - paths: - - 'android/**' - - '.gitea/workflows/android.yml' - -# pull_request trigger intentionally omitted — see test-web.yml for -# the rationale (single-author repo, push covers PR-merge equivalent). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - # Silences the JDK 22+ "restricted method in java.lang.System has been - # called" warning that Gradle 9.1's bundled native-platform jar trips - # at launch (System.load for native primitives). Affects the LAUNCHER - # JVM, not the daemon — that's why org.gradle.jvmargs in - # gradle.properties isn't enough. Future-compat: required opt-in once - # JDK 25 promotes the warning to an error. - JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" - -jobs: - build: - name: Build + lint + test - # Using flutter-ci runner label because it's the only proven-working - # label with docker that can pull our container.image. Switch to - # android-ci once the operator registers that runner label. - runs-on: flutter-ci - container: - image: git.fabledsword.com/bvandeusen/ci-android:36 - - defaults: - run: - working-directory: android - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Cache Gradle dirs - # Resolved deps + Gradle distribution + Kotlin daemon caches. - # Saves ~3 min per CI run after the first warm-up. - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - ~/.kotlin - key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Make gradlew executable - run: chmod +x ./gradlew - - - name: Gradle wrapper validation - run: ./gradlew --version - - - name: ktlint - run: ./gradlew ktlintCheck - - - name: detekt - run: ./gradlew detekt - - - name: Unit tests - run: ./gradlew testDebugUnitTest - - - name: Assemble debug - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: ./gradlew assembleDebug - - - name: Upload debug APK - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # Stock action: it works on this forge since the runner moved to - # gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal - # out of the action bundle (Scribe snippet #2271). Never @v3 — it reports - # success while Gitea serves artifacts back only through the v4 API, and - # it is what left 72 unreachable artifacts on this repo (Scribe 2270). - uses: actions/upload-artifact@v7 - with: - name: minstrel-android-debug-${{ github.sha }} - path: android/app/build/outputs/apk/debug/app-debug.apk - if-no-files-found: error diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 22aca520..6a0bf115 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -72,9 +72,8 @@ name: release # Android APK and uploads it as a workflow artifact. The image-release # job declares `needs: android-release`, so the docker image cannot # start building until the APK is guaranteed-ready — no polling, no -# race, no silent-failure mode. Asset attachment to the gitea Release -# happens in the same android-release job, so the Release-page download -# link and the in-image bundled APK are both populated atomically. +# race, no silent-failure mode. Attaching the APK to the gitea Release is +# its own job (release-assets), behind the test gate below. # # :latest always carries an APK. Because every main push also moves # :latest (not just tags), a main build with no APK would silently strip @@ -84,8 +83,33 @@ name: release # recomputed ones — so no rebuild is needed, just a rebundle. Tag builds # keep bundling their own freshly-built APK. # -# Android testing (lint + detekt + unit tests, debug APK upload on main) -# lives in android.yml and runs independently on every push. +# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file — +# Go (vet, lint, short tests), the Postgres integration suite, the web app +# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and +# govulncheck — and every job that publishes something names each of them in +# `needs:` and requires `success` from each, by name. Nothing publishes on red. +# +# They used to be three separate workflows (test-go, test-web, android) on the +# same push trigger as this one. Separate workflows cannot see each other's +# verdict, so :dev meant "it built", never "it passed": a red test run and a +# fresh :dev could carry the same timestamp. One graph is the only place the +# edge can be written. +# +# A skipped lane is NOT a pass. The publishing conditions check +# `result == 'success'` per lane rather than `!failure()`, so a lane that +# never started blocks the publish exactly as a red one does. Lanes carry no +# path filters for the same reason: a web-only push still runs the Go suite, +# because "not run" must never read as "passed". +# +# What publishes, and is therefore gated: the image tags (image-release) and +# the APK + version sidecar attached to a tag's Release (release-assets). +# android-release only BUILDS the signed APK into a workflow artifact, which +# nobody outside this run can pull, so it runs in parallel with the lanes +# instead of after them; attaching it to the Release is the publishing half, +# and that half waits for the gate. +# +# To watch the gate refuse: dispatch this workflow with force_red=true. The go +# lane fails on purpose, and both publishing jobs must report skipped. on: push: @@ -95,6 +119,11 @@ on: - 'docs/**' - '**/*.md' workflow_dispatch: + inputs: + force_red: + description: Fail the go lane on purpose, to check that nothing publishes on red + type: boolean + default: false # A rapid re-push to main should supersede the in-flight build — the # operator explicitly wants the later commit to win. Tags no longer enter @@ -105,6 +134,276 @@ concurrency: cancel-in-progress: true jobs: + # ---------------------------------------------------------------- lanes -- + # Verifying jobs. Each one is named in the `needs:` of every publishing job + # below; add a lane here and it must be added there in the same commit. + + go: + runs-on: go-ci + container: + image: git.fabledsword.com/bvandeusen/ci-go:1.26 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Forced failure (gate check) + if: github.event.inputs.force_red == 'true' + run: | + echo "::error::force_red dispatch: failing on purpose so the publishing jobs must skip" + exit 1 + + - name: Toolchain versions + run: | + go version + golangci-lint --version + + - name: Generated code matches queries (sqlc) + run: make verify-generate + + - name: go vet + run: go vet ./... + + - name: golangci-lint + run: golangci-lint run ./... + + - name: go test (short, race) + run: go test -short -race ./... + + # Full `go test -race` against an ephemeral Postgres. + # + # DB wiring follows the act_runner shared-daemon pattern: the runner's Docker + # daemon also runs the operator's dev compose stack, so service containers + # get NO published ports (collision) and no service-name DNS. We discover the + # service container through the mounted docker socket and reach it by bridge + # IP. The exactly-one assertion is a hard guard — pointing tests at the dev + # Postgres would truncate it (the disaster Fable #339 exists to prevent). + # + # The key stays `integration` with no `name:` (rule 80): act_runner derives + # the service container's name from the job's display name. + # + # `web/build/` has a committed placeholder index.html so go:embed succeeds + # without the SPA being built first. + integration: + runs-on: go-ci + container: + image: git.fabledsword.com/bvandeusen/ci-go:1.26 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: minstrel + POSTGRES_PASSWORD: minstrel + POSTGRES_DB: minstrel_test + # No `ports:` — the runner shares the operator's dev compose + # Docker daemon; publishing a fixed host port collides. + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Integration suite (discover service by bridge IP, migrate, test) + run: | + set -eux + # Discover THIS job's Postgres service container via the + # mounted docker socket. act_runner attaches the job + # container and its service container(s) to a shared per-job + # network, so scope discovery to a postgres that sits on a + # network THIS job container is also on. The old + # `--filter name=integration` matched EVERY concurrent + # integration run's postgres (a dev push + the main-merge run + # overlap → 2 candidates → false "expected exactly 1" abort). + # The operator's dev compose `minstrel-postgres-*` is never on + # this job's network; skip it explicitly as belt-and-suspenders + # (a wrong target would truncate real data). + SELF=$(cat /etc/hostname) + SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF") + test -n "$SELF_NETS" + echo "self ($SELF) networks: $SELF_NETS" + PG_ID="" + PG_NAME="" + for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do + nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##') + case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac + for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do + case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac + done + done + test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; } + echo "selected postgres: $PG_ID $PG_NAME" + PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID") + test -n "$PG_IP" + export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable" + + # Wait for Postgres to accept connections. Asked of the service + # container itself: the run: shell is dash (rule 81), where the + # old `/dev/tcp` probe never connects and the loop silently + # burned its full two minutes on every run. + ready="" + for i in $(seq 1 60); do + if docker exec "$PG_ID" pg_isready -U minstrel -d minstrel_test -q; then ready=1; break; fi + sleep 2 + done + test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; } + + # Relax durability on the throwaway CI Postgres. Our test pattern + # is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before + # every test, and the per-TRUNCATE commit fsync is the dominant + # cost of the integration suite. The CI DB is rebuilt every run so + # fsync / full_page_writes / synchronous_commit buy nothing. Apply + # via docker exec because: + # - The act_runner `services:` block can't override the container + # command, so `postgres -c fsync=off` at boot isn't an option. + # - ALTER SYSTEM cannot run inside a transaction; psql -c + # auto-commits each statement, which is what we need. + # - fsync / full_page_writes are sighup GUCs and + # synchronous_commit is user-context, so pg_reload_conf() picks + # all three up with no restart. + # Non-fatal: a perms surprise degrades to "slower", never red CI. + docker exec "$PG_ID" psql -U minstrel -d minstrel_test \ + -c "ALTER SYSTEM SET fsync = off" \ + -c "ALTER SYSTEM SET synchronous_commit = off" \ + -c "ALTER SYSTEM SET full_page_writes = off" \ + -c "SELECT pg_reload_conf()" \ + || echo "WARN: durability relax failed; continuing" + + # Apply embedded migrations to the fresh test DB, then run the + # full suite (no -short → integration tests execute). -p 1: + # every integration package TRUNCATEs the one shared test DB; + # concurrent package binaries → TRUNCATE deadlocks. Serialize + # package execution (the documented local invocation too). + MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate + go test -p 1 -race ./... + + web: + runs-on: go-ci + container: + image: git.fabledsword.com/bvandeusen/ci-go:1.26 + + defaults: + run: + working-directory: web + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install deps + run: npm ci + + # What ships to browsers: `dependencies` and the runtime they pull in + # (svelte, devalue). Build and test tooling (vite, vitest, tailwind, + # kit's dev server) is left out because none of it reaches a user, and + # its open advisories need major-version upgrades tracked separately. + - name: npm audit (shipped dependencies) + run: npm audit --omit=dev --audit-level=moderate + + - name: Type-check + svelte-check + run: npm run check + + - name: Vitest + run: npm test + + android: + # Using flutter-ci runner label because it's the only proven-working + # label with docker that can pull our container.image. + runs-on: flutter-ci + container: + image: git.fabledsword.com/bvandeusen/ci-android:36 + + defaults: + run: + working-directory: android + + env: + # Silences the JDK 22+ "restricted method in java.lang.System has been + # called" warning that Gradle's bundled native-platform jar trips at + # launch. Affects the LAUNCHER JVM, not the daemon — that's why + # org.gradle.jvmargs in gradle.properties isn't enough. + JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache Gradle dirs + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.kotlin + key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Gradle wrapper validation + run: ./gradlew --version + + - name: ktlint + run: ./gradlew ktlintCheck + + - name: detekt + run: ./gradlew detekt + + - name: Unit tests + run: ./gradlew testDebugUnitTest + + - name: Assemble debug + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: ./gradlew assembleDebug + + - name: Upload debug APK + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Stock action: it works on this forge since the runner moved to + # gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal + # out of the action bundle (Scribe snippet #2271). Never @v3 — it reports + # success while Gitea serves artifacts back only through the v4 API, and + # it is what left 72 unreachable artifacts on this repo (Scribe 2270). + uses: actions/upload-artifact@v7 + with: + name: minstrel-android-debug-${{ github.sha }} + path: android/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + + # Known vulnerabilities in the Go code and the standard library it is built + # with. Runs in the SAME image the Dockerfile's builder stage uses, so the + # standard library it checks is the one that ends up in the shipped binary; + # the ci-go image carries its own Go and would be checking a different + # toolchain. Keep this image and the Dockerfile's builder in step. + # + # govulncheck is fetched at CI time, unpinned (rule 154): the vulnerability + # database and the tool that reads it should both be current. + govulncheck: + runs-on: go-ci + container: + image: golang:1.26-bookworm + + steps: + # Plain git, not actions/checkout: that action runs on node, which the + # golang image does not carry. This step is dash (rule 81). + - name: Checkout + env: + TOKEN: ${{ github.token }} + run: | + set -eu + auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 -w0) + git init -q . + git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" + git -c http.extraHeader="Authorization: Basic ${auth}" fetch -q --depth 1 origin "${{ github.sha }}" + git checkout -q FETCH_HEAD + git log -1 --format='%H %s' + + - name: govulncheck + run: | + go version + go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + # ------------------------------------------------------------ artifacts -- + android-release: name: Build signed APK (releases and dev) # Also builds on `dev`, which is what makes a test channel possible at @@ -245,21 +544,47 @@ jobs: # artifact existing, so an empty upload must fail here, not there. if-no-files-found: error + # Publishes the signed APK and its version sidecar on the tag's Release. + # Split out of android-release so the APK can be BUILT in parallel with the + # lanes while being PUBLISHED only once they have all passed. + release-assets: + name: Attach APK to the Release (tag releases only) + needs: [go, integration, web, android, govulncheck, android-release] + if: >- + ${{ + !cancelled() + && needs.go.result == 'success' + && needs.integration.result == 'success' + && needs.web.result == 'success' + && needs.android.result == 'success' + && needs.govulncheck.result == 'success' + && needs.android-release.result == 'success' + && startsWith(github.ref, 'refs/tags/v') }} + runs-on: go-ci + container: + image: git.fabledsword.com/bvandeusen/ci-go:1.26 + + steps: + - name: Download signed APK artifact + uses: actions/download-artifact@v8 + with: + name: minstrel-apk + path: release-apk/ + - name: Attach APK to gitea Release # Tag releases only. A dev build has no Release to hang assets on and # does not need one — the :dev image bundles the APK, and the server # serves it from /api/client/apk like any other. - if: startsWith(github.ref, 'refs/tags/v') shell: bash env: CI_TOKEN: ${{ secrets.CI_TOKEN }} - VERSION_NAME: ${{ steps.ver.outputs.name }} - VERSION_CODE: ${{ steps.ver.outputs.code }} + VERSION_NAME: ${{ needs.android-release.outputs.version_name }} + VERSION_CODE: ${{ needs.android-release.outputs.version_code }} run: | set -euxo pipefail TAG="${GITHUB_REF#refs/tags/}" REPO="${GITHUB_REPOSITORY}" - APK_PATH="app/build/outputs/apk/release/app-release.apk" + APK_PATH="release-apk/app-release.apk" ls -lh "${APK_PATH}" # Publish the version sidecar as a release asset next to the APK. @@ -313,13 +638,21 @@ jobs: image-release: name: Build + push container image - # `needs:` waits for android-release. For tag pushes android-release - # runs and must succeed before this job starts — guaranteeing the - # APK artifact is present. For main pushes android-release is - # skipped; the `if: ...` below lets this job run anyway and the - # download/copy steps gate themselves on the tag context. - needs: [android-release] - if: ${{ !failure() && !cancelled() }} + # Every lane must have SUCCEEDED, each named here (rule 177). Then the + # APK: tag and dev pushes build one and it must have succeeded; main + # pushes skip android-release and bundle the latest release's APK + # instead, so for main alone a skipped android-release is expected. + needs: [go, integration, web, android, govulncheck, android-release] + if: >- + ${{ + !cancelled() + && needs.go.result == 'success' + && needs.integration.result == 'success' + && needs.web.result == 'success' + && needs.android.result == 'success' + && needs.govulncheck.result == 'success' + && (needs.android-release.result == 'success' + || (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }} runs-on: go-ci container: image: git.fabledsword.com/bvandeusen/ci-go:1.26 @@ -528,8 +861,13 @@ jobs: - name: Build and push if: steps.guard.outputs.ready == 'true' + # --pull: the Dockerfile's base images are floating tags (golang:1.26, + # debian:bookworm-slim). Without it the runner's daemon reuses + # whatever it cached, and the shipped binary can sit on a Go patch + # release govulncheck already flagged while the lane, which pulls + # fresh, reports clean. run: | - docker buildx build \ + docker buildx build --pull \ --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \ --build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \ --push ${{ steps.tags.outputs.args }} . @@ -554,7 +892,7 @@ jobs: # above did NOT succeed. verify-release: name: Verify release artifacts (tag releases only) - needs: [android-release, image-release] + needs: [android-release, release-assets, image-release] if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }} runs-on: go-ci container: diff --git a/.gitea/workflows/test-go.yml b/.gitea/workflows/test-go.yml deleted file mode 100644 index 49cb3684..00000000 --- a/.gitea/workflows/test-go.yml +++ /dev/null @@ -1,156 +0,0 @@ -name: test-go - -# Go server: vet + golangci-lint + short race tests. Runs on push to -# dev/main and PRs to main, scoped to Go-side files only — web-only or -# Flutter-only diffs don't trigger this workflow. -# -# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and -# `integration` (full `go test -race` against an ephemeral Postgres). -# -# Integration-job DB wiring follows the act_runner shared-daemon pattern: -# the runner's Docker daemon also runs the operator's dev compose stack, -# so service containers get NO published ports (collision) and no -# service-name DNS. We discover the service container by the job-scoped -# name filter via the mounted docker socket and reach it by bridge IP. -# The exactly-one assertion is a hard guard — pointing tests at the dev -# Postgres would truncate it (the disaster Fable #339 exists to prevent). -# -# `web/build/` has a committed placeholder index.html so go:embed succeeds -# without needing the SPA to be freshly built. Real builds happen in -# release.yml (container) and locally during dev. - -on: - push: - branches: [dev, main] - paths: - - '**/*.go' - - 'go.mod' - - 'go.sum' - - 'sqlc.yaml' - - 'Makefile' - - 'internal/**' - - 'cmd/**' - - '.golangci.yml' - - '.gitea/workflows/test-go.yml' - # The release lane's own trigger is `main` + tags, so nothing it - # contains is exercised until a release is already running. These two - # entries are what let internal/server/release_version_test.go guard - # the version derivation on ordinary dev pushes instead. - - 'ci/**' - - '.gitea/workflows/release.yml' - -# pull_request trigger intentionally omitted — see test-web.yml for -# the rationale (single-author repo, push covers PR-merge equivalent). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: go-ci - container: - image: git.fabledsword.com/bvandeusen/ci-go:1.26 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Toolchain versions - run: | - go version - golangci-lint --version - - - name: Generated code matches queries (sqlc) - run: make verify-generate - - - name: go vet - run: go vet ./... - - - name: golangci-lint - run: golangci-lint run ./... - - - name: go test (short, race) - run: go test -short -race ./... - - integration: - runs-on: go-ci - container: - image: git.fabledsword.com/bvandeusen/ci-go:1.26 - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: minstrel - POSTGRES_PASSWORD: minstrel - POSTGRES_DB: minstrel_test - # No `ports:` — the runner shares the operator's dev compose - # Docker daemon; publishing a fixed host port collides. - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Integration suite (discover service by bridge IP, migrate, test) - run: | - set -eux - # Discover THIS job's Postgres service container via the - # mounted docker socket. act_runner attaches the job - # container and its service container(s) to a shared per-job - # network, so scope discovery to a postgres that sits on a - # network THIS job container is also on. The old - # `--filter name=integration` matched EVERY concurrent - # integration run's postgres (a dev push + the main-merge run - # overlap → 2 candidates → false "expected exactly 1" abort). - # The operator's dev compose `minstrel-postgres-*` is never on - # this job's network; skip it explicitly as belt-and-suspenders - # (a wrong target would truncate real data). - SELF=$(cat /etc/hostname) - SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF") - test -n "$SELF_NETS" - echo "self ($SELF) networks: $SELF_NETS" - PG_ID="" - PG_NAME="" - for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do - nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##') - case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac - for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do - case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac - done - done - test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; } - echo "selected postgres: $PG_ID $PG_NAME" - PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID") - test -n "$PG_IP" - export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable" - - # Wait for Postgres to accept TCP (no health-check dependency). - for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done - - # Relax durability on the throwaway CI Postgres. Our test pattern - # is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before - # every test, and the per-TRUNCATE commit fsync is the dominant - # cost of the integration suite. The CI DB is rebuilt every run so - # fsync / full_page_writes / synchronous_commit buy nothing. Apply - # via docker exec because: - # - The act_runner `services:` block can't override the container - # command, so `postgres -c fsync=off` at boot isn't an option. - # - ALTER SYSTEM cannot run inside a transaction; psql -c - # auto-commits each statement, which is what we need. - # - fsync / full_page_writes are sighup GUCs and - # synchronous_commit is user-context, so pg_reload_conf() picks - # all three up with no restart. - # Non-fatal: a perms surprise degrades to "slower", never red CI. - docker exec "$PG_ID" psql -U minstrel -d minstrel_test \ - -c "ALTER SYSTEM SET fsync = off" \ - -c "ALTER SYSTEM SET synchronous_commit = off" \ - -c "ALTER SYSTEM SET full_page_writes = off" \ - -c "SELECT pg_reload_conf()" \ - || echo "WARN: durability relax failed; continuing" - - # Apply embedded migrations to the fresh test DB, then run the - # full suite (no -short → integration tests execute). -p 1: - # every integration package TRUNCATEs the one shared test DB; - # concurrent package binaries → TRUNCATE deadlocks. Serialize - # package execution (the documented local invocation too). - MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate - go test -p 1 -race ./... diff --git a/.gitea/workflows/test-web.yml b/.gitea/workflows/test-web.yml deleted file mode 100644 index 9893608d..00000000 --- a/.gitea/workflows/test-web.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: test-web - -# Web SPA: vitest + svelte-check. Runs on push to dev/main only — -# the `pull_request` trigger is intentionally omitted because every -# branch on this repo is local-only (no fork PRs), so the dev push -# fully covers what a PR run would re-execute. Keeping both events -# doubled CI cost on every commit. - -on: - push: - branches: [dev, main] - paths: - - 'web/**' - - '.gitea/workflows/test-web.yml' - -# Cancel an earlier in-flight run for the same ref when a newer -# commit arrives. With cancel-in-progress, rapid re-pushes don't -# pile up zombie runs. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: go-ci - container: - image: git.fabledsword.com/bvandeusen/ci-go:1.26 - - defaults: - run: - working-directory: web - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install deps - run: npm ci - - - name: Type-check + svelte-check - run: npm run check - - - name: Vitest - run: npm test diff --git a/Dockerfile b/Dockerfile index 55b3690d..91903306 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN npm ci COPY web/ ./ RUN npm run build -FROM golang:1.25-bookworm AS builder +FROM golang:1.26-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download diff --git a/README.md b/README.md index 3f0ac051..3437ba73 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz services: minstrel: image: git.fabledsword.com/bvandeusen/minstrel:latest + # Reachable from your LAN at http://:4533. If this host faces the + # internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead: + # see docs/hosting.md. ports: ['4533:4533'] volumes: # Your music library. Point ./music at wherever your audio files @@ -76,9 +79,9 @@ docker compose up -d ## First run -With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required). +With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address. Plain `http://` is fine on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through. -**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5). +**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance becomes the administrator, and creating it asks for the **setup token** the server prints in its log (`docker compose logs minstrel | grep setup_token`), so nobody else can claim a newly exposed server first. Later users join through the same form or an invite token (step 5). Creating the first (admin) account on a fresh instance @@ -100,6 +103,8 @@ With the stack up, a handful of in-app steps get you to a working library. Use y For the full configuration surface, see [`config.example.yaml`](./config.example.yaml). +Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md). + ## Configuration Most operators only need the env vars in the quickstart above. A few extras worth knowing: @@ -154,7 +159,8 @@ Two concurrent dev processes: truncates your dev `minstrel` data (admin user, library, likes). It brings up the compose Postgres and creates the test DB if missing. - CI runs both: a fast `go test -short -race` gate plus an integration - job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`). + job with its own ephemeral Postgres (the `integration` lane in + `.gitea/workflows/release.yml`, which also gates every image publish). ### Production build diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt index 3f345851..9a7314ad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -75,6 +75,7 @@ object ErrorCopy { "forbidden" to "You don't have permission to do that.", "not_authorized" to "You don't have permission to do that.", "invalid_credentials" to "Wrong username or password.", + "rate_limited" to "Too many attempts. Wait a few minutes and try again.", "wrong_password" to "Current password is incorrect.", "password_too_short" to "Password must be at least 8 characters.", "username_invalid" to "That username isn't valid.", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt index f4aeabc1..9ff7bdad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -5,11 +5,17 @@ import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.di.ApplicationScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -27,6 +33,14 @@ import javax.inject.Singleton * in-memory state changes synchronously so the next interceptor read * sees the new value immediately; the DAO write coroutine catches up * shortly after. + * + * **The session cookie is the exception** (M462 #4985): it is persisted + * through [SessionVault] (Keystore-encrypted), not the Room row. On the + * first launch after the upgrade, a cookie still in the row is moved into + * the vault and the column cleared, so nobody is signed out by the change. + * If the Keystore cannot be used on a device, the cookie stays in the row + * as before rather than being lost. [awaitSessionHydrated] lets a caller + * that needs a definitive answer (the auth gate) wait for this. */ // AuthStore is the single-row facade over auth_session (de-facto // app_preferences — see entity comment). It legitimately owns one @@ -39,6 +53,7 @@ import javax.inject.Singleton @Singleton class AuthStore @Inject constructor( private val dao: AuthSessionDao, + private val vault: SessionVault, @ApplicationScope private val scope: CoroutineScope, ) { private val sessionCookieState = MutableStateFlow(null) @@ -64,10 +79,20 @@ class AuthStore @Inject constructor( private val json = Json { ignoreUnknownKeys = true } + // Serialises every cookie persist with the one-time hydration, so a + // sign-in or a 401 that lands while hydration runs is never overwritten + // by the stale value hydration read. + private val cookieLock = Mutex() + + // Set by setSessionCookie. Once something has written the cookie this + // process, that value wins over whatever hydration finds on disk. + @Volatile private var cookieTouched = false + + private val cookieHydration: Deferred = scope.async { hydrateSessionCookie() } + init { scope.launch { dao.observe().collect { row -> - sessionCookieState.value = row?.sessionCookie baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL userJsonState.value = row?.userJson themeModeState.value = row?.themeMode @@ -85,9 +110,50 @@ class AuthStore @Inject constructor( }.getOrDefault(CacheSettings.DEFAULT) } + /** + * Suspends until the stored session cookie has been loaded into + * [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged + * Keystore must not leave the start screen spinning). Returns false on + * the deadline; the caller then decides from whatever has loaded, and a + * late hydration still lands in [sessionCookie]. + */ + suspend fun awaitSessionHydrated(): Boolean { + val done = withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } != null + if (!done) Timber.w("auth store: session hydration passed its deadline; deciding without it") + return done + } + fun setSessionCookie(value: String?) { + cookieTouched = true sessionCookieState.value = value - scope.launch { persistCookie(value) } + scope.launch { cookieLock.withLock { storeCookie(value) } } + } + + private suspend fun hydrateSessionCookie() = cookieLock.withLock { + val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull() + if (cookieTouched) return@withLock + // A cookie in the row is the newer one when both exist: the row is + // only written when the vault failed, and an install upgrading from + // before the vault has nothing in the vault yet. + val cookie = legacy ?: vault.read() + // Best-effort: if moving it fails, the session still loads this time + // and the move is retried on the next launch. Hydration must never + // throw, or awaitSessionHydrated would leave the auth gate stuck. + if (legacy != null) { + runCatching { storeCookie(legacy) } + .onFailure { Timber.w(it, "auth store: could not move the session cookie into the vault") } + } + sessionCookieState.value = cookie + } + + // Vault first; the Room row only when the Keystore is unusable, so a + // broken Keystore degrades to the old storage rather than a sign-out. + private suspend fun storeCookie(value: String?) { + if (vault.write(value)) { + if (dao.get()?.sessionCookie != null) dao.setSessionCookie(null) + } else { + persistLegacyCookie(value) + } } fun setBaseUrl(value: String) { @@ -121,7 +187,7 @@ class AuthStore @Inject constructor( scope.launch { persistDiagnosticsOptOut(value) } } - private suspend fun persistCookie(value: String?) { + private suspend fun persistLegacyCookie(value: String?) { if (dao.get() == null) { dao.upsert(currentEntity().copy(sessionCookie = value)) } else { @@ -179,7 +245,9 @@ class AuthStore @Inject constructor( private fun currentEntity(): AuthSessionEntity = AuthSessionEntity( id = ROW_ID, - sessionCookie = sessionCookieState.value, + // Never copied into the row: the cookie lives in the vault, and + // persistLegacyCookie sets it explicitly on the fallback path. + sessionCookie = null, baseUrl = baseUrlState.value, userJson = userJsonState.value, themeMode = themeModeState.value, @@ -193,6 +261,10 @@ class AuthStore @Inject constructor( companion object { const val DEFAULT_BASE_URL: String = "http://localhost:8080" + + // Generous on purpose: hydration is one local row read and one + // Keystore decrypt, normally milliseconds. This only bounds "never". + const val HYDRATION_DEADLINE_MS: Long = 10_000 private const val ROW_ID = 0 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/SessionVault.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/SessionVault.kt new file mode 100644 index 00000000..72e7a236 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/SessionVault.kt @@ -0,0 +1,147 @@ +package com.fabledsword.minstrel.auth + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import timber.log.Timber +import java.security.KeyStore +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Where the session cookie lives at rest (M462 #4985). + * + * The cookie is a bearer credential: anyone holding it is signed in as the + * user until the server expires or revokes it. It used to sit in plain text + * in the Room `auth_session` row, readable from any copy of the app's data + * directory (a rooted device, an adb backup of a debuggable build, a + * forensic image). Now only ciphertext is stored, under an AES key that + * lives in the Android Keystore and never leaves it, so a copy of the + * files alone yields nothing usable. + */ +interface SessionVault { + /** The stored cookie, or null when none is stored or it can't be decrypted. */ + fun read(): String? + + /** + * Stores [value], or clears the stored cookie when null. Returns false + * when the Keystore could not be used, so the caller can fall back + * rather than lose the session. + */ + fun write(value: String?): Boolean +} + +/** + * AES-GCM sealing of a short string, framed as base64(iv || ciphertext+tag). + * Kept apart from the Keystore so the framing can be unit-tested on the JVM + * with an ordinary key; the Android Keystore has no JVM implementation. + */ +internal object SealedBox { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG_BITS = 128 + private const val IV_BYTES = 12 + + // Binds a sealed value to its purpose: a blob sealed for something else + // under the same key will not open as a session cookie. + private val AAD = "minstrel-session-cookie-v1".toByteArray(Charsets.UTF_8) + + fun seal(key: SecretKey, plaintext: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + // No IV passed: the provider generates a fresh random one. Keystore + // keys refuse a caller-chosen IV for encryption by default. + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(AAD) + val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return Base64.getEncoder().encodeToString(sealed) + } + + fun open(key: SecretKey, sealed: String): String { + val bytes = Base64.getDecoder().decode(sealed) + require(bytes.size > IV_BYTES) { "sealed value too short" } + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES)) + cipher.updateAAD(AAD) + return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8) + } +} + +/** + * [SessionVault] backed by a Keystore AES key and a private prefs file that + * holds only the sealed value. + * + * A value that will not open (the key was wiped by a factory-reset of the + * Keystore, or the file was restored onto another device; app backup is off, + * but a vendor transfer tool may still copy files) is discarded and reported + * as absent. The user signs in again, which is the right outcome for a + * credential that no longer verifies. + */ +@Singleton +class KeystoreSessionVault @Inject constructor( + @ApplicationContext context: Context, +) : SessionVault { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + override fun read(): String? { + val sealed = prefs.getString(KEY_COOKIE, null) ?: return null + return runCatching { SealedBox.open(key(), sealed) } + .onFailure { + Timber.w(it, "session vault: stored cookie would not decrypt; discarding it") + prefs.edit().remove(KEY_COOKIE).commit() + } + .getOrNull() + } + + override fun write(value: String?): Boolean = runCatching { + val editor = prefs.edit() + if (value == null) { + editor.remove(KEY_COOKIE) + } else { + editor.putString(KEY_COOKIE, SealedBox.seal(key(), value)) + } + editor.commit() + }.onFailure { + Timber.w(it, "session vault: Keystore unavailable; cookie not stored in the vault") + }.getOrDefault(false) + + private fun key(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_BITS) + .build(), + ) + return generator.generateKey() + } + + private companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "minstrel_session_cookie" + const val KEY_BITS = 256 + const val PREFS_NAME = "session_vault" + const val KEY_COOKIE = "sealed_cookie" + } +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class SessionVaultModule { + @Binds + abstract fun bindSessionVault(impl: KeystoreSessionVault): SessionVault +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt index f4bd2fa8..0ccd82d5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt @@ -16,10 +16,11 @@ import javax.inject.Inject /** * Computes the initial startDestination for the root NavHost based on - * persisted auth state. Sits on top of AuthSessionDao directly rather - * than AuthStore's StateFlow because the StateFlow defaults to null - * until Room's first async emission — we need a definitive answer - * before drawing any nav graph. + * persisted auth state. Reads the row from AuthSessionDao directly, and + * the session cookie only after [AuthStore.awaitSessionHydrated]: both + * StateFlows default to null until their async load lands, and we need a + * definitive answer before drawing any nav graph. The cookie is no longer + * in the row (it lives in the Keystore-backed SessionVault, #4985). * * - no row at all → ServerUrl (first launch) * - row with baseUrl, no cookie → Login (URL configured, not yet signed in) @@ -31,6 +32,7 @@ import javax.inject.Inject @HiltViewModel class AuthGateViewModel @Inject constructor( private val dao: AuthSessionDao, + private val authStore: AuthStore, ) : ViewModel() { private val internal = MutableStateFlow(null) @@ -38,12 +40,13 @@ class AuthGateViewModel @Inject constructor( init { viewModelScope.launch { + authStore.awaitSessionHydrated() + val signedIn = !authStore.sessionCookie.value.isNullOrEmpty() val row = dao.get() internal.value = when { row == null -> ServerUrl - row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() -> - ServerUrl - row.sessionCookie.isNullOrEmpty() -> Login + row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl + !signedIn -> Login else -> Home } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt index 2745ea02..ea50c42d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordViewModel.kt @@ -57,7 +57,7 @@ class PasswordViewModel @Inject constructor( try { repository.changePassword(current = s.current, next = s.next) internal.update { - PasswordUiState(message = "Password changed.") + PasswordUiState(message = "Password changed. Your other devices have been signed out.") } } catch ( @Suppress("TooGenericExceptionCaught") e: Throwable, diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt index 039e8a8a..5b91d267 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt @@ -1,6 +1,7 @@ package com.fabledsword.minstrel.api import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.auth.FakeSessionVault import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import io.mockk.coEvery import io.mockk.every @@ -43,7 +44,7 @@ class AuthCookieInterceptorTest { coEvery { setSessionCookie(any()) } returns Unit coEvery { setBaseUrl(any()) } returns Unit } - authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher())) + authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher())) // BaseUrlInterceptor rewrites placeholder.invalid → mock server. // AuthCookieInterceptor scopes its attach + clear behavior to diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt index a5771f9a..d818c9e8 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt @@ -1,6 +1,7 @@ package com.fabledsword.minstrel.api import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.auth.FakeSessionVault import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import io.mockk.coEvery import io.mockk.every @@ -38,7 +39,7 @@ class BaseUrlInterceptorTest { coEvery { setSessionCookie(any()) } returns Unit coEvery { setBaseUrl(any()) } returns Unit } - authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher())) + authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher())) } @AfterEach diff --git a/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthStoreSessionVaultTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthStoreSessionVaultTest.kt new file mode 100644 index 00000000..2980d7d8 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthStoreSessionVaultTest.kt @@ -0,0 +1,111 @@ +package com.fabledsword.minstrel.auth + +import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao +import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The session cookie moved out of the Room row into a Keystore-backed vault + * (M462 #4985). What matters is that nobody is signed out by it: an install + * upgrading with a cookie in the row keeps it, a device whose Keystore will + * not work keeps the old storage, and a sign-in or 401 that lands while the + * one-time move runs is never overwritten by the value it read. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AuthStoreSessionVaultTest { + /** A DAO whose single row holds [legacyCookie] in its sessionCookie column. */ + private class RowDao(var legacyCookie: String?) { + val dao: AuthSessionDao = mockk { + every { observe() } returns flowOf(null) + coEvery { get() } answers { + AuthSessionEntity(baseUrl = "http://music.local", sessionCookie = legacyCookie) + } + coEvery { upsert(any()) } answers { legacyCookie = firstArg().sessionCookie } + coEvery { setSessionCookie(any()) } answers { legacyCookie = firstArg() } + } + } + + @Test + fun `an upgrading install keeps its session, moved out of the row into the vault`() = runTest { + val row = RowDao(legacyCookie = "session=abc") + val vault = FakeSessionVault() + + val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler))) + store.awaitSessionHydrated() + + assertEquals("session=abc", store.sessionCookie.value) + assertEquals("session=abc", vault.stored) + assertNull(row.legacyCookie, "the plain-text copy must be cleared from the row") + } + + @Test + fun `a cookie already in the vault is loaded without touching the row`() = runTest { + val row = RowDao(legacyCookie = null) + val vault = FakeSessionVault(stored = "session=xyz") + + val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler))) + store.awaitSessionHydrated() + + assertEquals("session=xyz", store.sessionCookie.value) + assertEquals(0, vault.writes) + coVerify(exactly = 0) { row.dao.setSessionCookie(any()) } + } + + @Test + fun `when the Keystore will not work the cookie stays in the row instead of being lost`() = runTest { + val row = RowDao(legacyCookie = "session=abc") + val vault = FakeSessionVault(available = false) + + val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler))) + store.awaitSessionHydrated() + assertEquals("session=abc", store.sessionCookie.value) + assertEquals("session=abc", row.legacyCookie) + + store.setSessionCookie("session=new") + assertEquals("session=new", row.legacyCookie, "fallback writes go to the row") + } + + @Test + fun `sign-in and sign-out write the vault, and never the row`() = runTest { + val row = RowDao(legacyCookie = null) + val vault = FakeSessionVault() + val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler))) + store.awaitSessionHydrated() + + store.setSessionCookie("session=new") + assertEquals("session=new", vault.stored) + assertNull(row.legacyCookie) + + store.setSessionCookie(null) + assertNull(vault.stored) + assertNull(store.sessionCookie.value) + } + + @Test + fun `a sign-out that lands before hydration finishes is not undone by it`() = runTest { + val row = RowDao(legacyCookie = "session=stale") + val vault = FakeSessionVault() + val scope = TestScope(StandardTestDispatcher(testScheduler)) + + // Hydration is queued but has not run; a 401 clears the session first. + val store = AuthStore(row.dao, vault, scope) + store.setSessionCookie(null) + scope.advanceUntilIdle() + + assertNull(store.sessionCookie.value, "hydration must not resurrect the stale cookie") + assertNull(vault.stored) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/auth/FakeSessionVault.kt b/android/app/src/test/java/com/fabledsword/minstrel/auth/FakeSessionVault.kt new file mode 100644 index 00000000..58f2d1b5 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/auth/FakeSessionVault.kt @@ -0,0 +1,23 @@ +package com.fabledsword.minstrel.auth + +/** + * In-memory [SessionVault] for JVM tests: the Android Keystore has no JVM + * implementation. [available] = false stands in for a device whose Keystore + * refuses to work, so callers' fallback paths can be exercised. + */ +class FakeSessionVault( + var stored: String? = null, + var available: Boolean = true, +) : SessionVault { + var writes = 0 + private set + + override fun read(): String? = if (available) stored else null + + override fun write(value: String?): Boolean { + if (!available) return false + writes++ + stored = value + return true + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/auth/SealedBoxTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/auth/SealedBoxTest.kt new file mode 100644 index 00000000..3f6001f8 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/auth/SealedBoxTest.kt @@ -0,0 +1,48 @@ +package com.fabledsword.minstrel.auth + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.Base64 +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals + +/** + * The sealing half of the session vault, with an ordinary JVM AES key in + * place of the Keystore one (the Keystore has no JVM implementation). + */ +class SealedBoxTest { + private fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + @Test + fun `round-trips a cookie`() { + val key = newKey() + assertEquals("session=abc", SealedBox.open(key, SealedBox.seal(key, "session=abc"))) + } + + @Test + fun `the stored form does not contain the cookie, and differs every time`() { + val key = newKey() + val first = SealedBox.seal(key, "session=abc") + val second = SealedBox.seal(key, "session=abc") + assertNotEquals(first, second, "a fresh IV per seal") + val decoded = String(Base64.getDecoder().decode(first), Charsets.ISO_8859_1) + assertFalse(decoded.contains("session=abc"), "plaintext visible in the sealed value") + } + + @Test + fun `a tampered value does not open`() { + val key = newKey() + val bytes = Base64.getDecoder().decode(SealedBox.seal(key, "session=abc")) + bytes[bytes.size - 1] = (bytes[bytes.size - 1].toInt() xor 1).toByte() + assertThrows { SealedBox.open(key, Base64.getEncoder().encodeToString(bytes)) } + } + + @Test + fun `a value sealed under another key does not open`() { + val sealed = SealedBox.seal(newKey(), "session=abc") + assertThrows { SealedBox.open(newKey(), sealed) } + } +} diff --git a/ci-requirements.md b/ci-requirements.md index 2a579118..d933be14 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -12,8 +12,9 @@ git.fabledsword.com/bvandeusen/ci-go:1.26 git.fabledsword.com/bvandeusen/ci-android:36 ``` -- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job). -- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job). +- `ci-go:1.26` — the `go`, `integration` and `web` lanes, `release-assets`, and the container build (`image-release`). All CI lives in one workflow, `.gitea/workflows/release.yml`, so every publishing job can depend on every lane (rule 177). +- `golang:1.26-bookworm` (Docker Hub) — the `govulncheck` lane. Deliberately the same image as the Dockerfile's builder stage rather than `ci-go`, so the standard library it checks is the one in the shipped binary. Keep the two in step. +- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (the `android` lane), and the signed release APK (`android-release`). **`ci-flutter` is no longer consumed, and `ci-flutter` can now be retired.** The M8 rewrite replaced the Flutter client with the native Android app and @@ -30,9 +31,9 @@ split below. The label is a scheduling handle, not a toolchain assertion. ### From `ci-go:1.26` - **Go** (1.26 toolchain) — `go vet`, `go test -race`, `go build`, `go mod`. -- **Node + npm** — `npm ci` and `npm test` / `npm run check` in `test-web.yml`. -- **golangci-lint** — lint pass in `test-go.yml`. -- **docker CLI** — bridge-IP discovery of the per-job Postgres service container in `test-go.yml` integration job (via the runner's shared `/var/run/docker.sock`). +- **Node + npm** — `npm ci`, `npm audit`, `npm test` and `npm run check` in the `web` lane. +- **golangci-lint** — lint pass in the `go` lane. +- **docker CLI** — bridge-IP discovery of the per-job Postgres service container in the `integration` lane (via the runner's shared `/var/run/docker.sock`). - **docker buildx** — release container build + push in `release.yml`. - **curl** — release-asset polling / upload in `release.yml`. @@ -45,7 +46,7 @@ split below. The label is a scheduling handle, not a toolchain assertion. No NDK: the native client has no C/C++ sources (this is why it isn't on `ci-flutter`). - **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in - `android.yml`. Image pins track `android/gradle/libs.versions.toml` so local + the `android` lane. Image pins track `android/gradle/libs.versions.toml` so local and CI checks agree. - **git** — `actions/checkout@v4` baseline (and any shell git operations). - **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s @@ -58,10 +59,10 @@ None. ## Notes - **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way. -- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step. +- **Integration-job docker-socket dependency.** The `integration` lane uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step. - **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows. - **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved. -- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action. +- **Cache server reachability.** The `web` lane does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action. - **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.** ```yaml uses: actions/upload-artifact@v7 diff --git a/cmd/minstrel/admin.go b/cmd/minstrel/admin.go index 72248b40..c2dcd3eb 100644 --- a/cmd/minstrel/admin.go +++ b/cmd/minstrel/admin.go @@ -55,12 +55,14 @@ func runAdmin(args []string) error { } } -// adminResetPassword resets a user's credentials. It updates BOTH -// password_hash (bcrypt, for /api/auth/login) and subsonic_password -// (plaintext, required for Subsonic t+s token verification) so neither -// auth path is left stale. Recovers a locked-out operator when the -// bootstrap password was missed or the DB volume was recreated (Fable -// #321) without DB surgery. +// adminResetPassword resets a user's login password (password_hash). It +// recovers a locked-out operator when the bootstrap password was missed or +// the DB volume was recreated (Fable #321) without DB surgery. +// +// It deliberately leaves subsonic_password alone. That column is stored in +// plain text, and it used to receive the new login password here, so any +// account recovered this way had its login password readable in the database +// (#5026). The Subsonic password is generated separately in Settings. func adminResetPassword(args []string) error { fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError) configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") @@ -112,13 +114,6 @@ func adminResetPassword(args []string) error { }); err != nil { return fmt.Errorf("update password_hash: %w", err) } - sp := pw - if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{ - ID: user.ID, - SubsonicPassword: &sp, - }); err != nil { - return fmt.Errorf("update subsonic_password: %w", err) - } if generated { fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw) diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index d447a7bf..7652bd2f 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -132,6 +132,13 @@ func run() error { } scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings) + // Loudness analysis settings (M464 #4995): shared by the loudness backfill + // and the admin API, and falls back to the defaults like the above. + loudSettings, loudErr := library.NewLoudnessSettingsService(ctx, pool) + if loudErr != nil { + logger.Warn("loudness settings: using defaults", "err", loudErr) + } + contact := cfg.Library.ContactEmail if contact == "" { contact = "https://git.fabledsword.com/bvandeusen/minstrel" @@ -228,6 +235,11 @@ func run() error { // internal/library/fingerprint_backfill.go for why. go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx) + // Loudness backfill (M464 #4995): measures every track's loudness for + // normalization, new tracks included; the scan only drops a changed file's + // measurement. See internal/library/loudness_backfill.go. + go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx) + // Duplicate sweep (M400 #3910): proposes groups of tracks holding one // recording, from the fingerprints above. Sweeps only when fingerprints have // changed since the last sweep. @@ -377,6 +389,7 @@ func run() error { srv.RecSettings = recSettings srv.TagSettings = tagSettings srv.FingerprintSettings = fpSettings + srv.LoudnessSettings = loudSettings // The sweeper above holds this same instance, so a save from the admin // card changes what it does on its next tick (#3936). srv.ReacqSettings = reacqSettings @@ -385,6 +398,11 @@ func run() error { Addr: cfg.Server.Address, Handler: srv.Router(), ReadHeaderTimeout: 10 * time.Second, + // Closes keep-alive connections nobody is using. Deliberately no + // ReadTimeout or WriteTimeout: either would cut off audio streams and + // the SSE event stream. Request bodies get their own deadline in the + // server's limitRequestBody middleware instead. + IdleTimeout: 120 * time.Second, } errCh := make(chan error, 1) diff --git a/docker-compose.yml b/docker-compose.yml index 140294ab..a56998cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ version: "3.9" # tests at it: # make test-integration # (CI runs the same suite against its own ephemeral Postgres service — -# see .gitea/workflows/test-go.yml.) +# see the `integration` job in .gitea/workflows/release.yml.) # # Full stack (server + db): # docker compose up --build diff --git a/docs/hosting.md b/docs/hosting.md new file mode 100644 index 00000000..10ede42e --- /dev/null +++ b/docs/hosting.md @@ -0,0 +1,92 @@ +# Hosting Minstrel + +Minstrel serves plain HTTP on port 4533 and never terminates TLS itself. On a +home network or a VPN you trust, that is all you need. Once the server is +reachable from the internet, put it behind a reverse proxy that speaks HTTPS, +and tell Minstrel about that proxy. This page covers both. + +## On a LAN or VPN + +Publish the port to the network and use `http://:4533`: + +```yaml + ports: ['4533:4533'] +``` + +If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP +detection** to 0. It defaults to 1, which assumes one proxy (see below for +why that matters). + +## On the internet + +Three things, all required: + +1. **Don't publish 4533 to the world.** Bind it to the loopback interface so + only the proxy on the same host can reach it: + + ```yaml + ports: ['127.0.0.1:4533:4533'] + ``` + + If the proxy runs in another container on the same Docker network, drop + `ports:` entirely and point the proxy at `minstrel:4533`. + +2. **Terminate HTTPS at a reverse proxy.** Any proxy works. With Caddy, which + gets and renews certificates by itself: + + ``` + music.example.com { + reverse_proxy 127.0.0.1:4533 + } + ``` + + Two settings matter for every proxy: + - **Don't buffer responses.** Live updates use Server-Sent Events and audio + is streamed; a buffering proxy holds both back. Caddy streams by default. + For nginx, set `proxy_buffering off;`. + - **Allow long-lived responses.** An event stream stays open for as long as + the app is. Raise the proxy's read timeout well past a minute (nginx: + `proxy_read_timeout 1h;`). + +3. **Tell Minstrel where it lives**, in **Admin → Integrations**: + - **Client IP detection:** the number of proxies in front of Minstrel. One + proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2. + Minstrel uses this to find the real client address, for login rate + limits and the sessions list, and to tell whether a request arrived over + HTTPS. + - **Public address:** the URL people use, e.g. `https://music.example.com`. + Password-reset emails link here. **Until it is set, no reset email is + sent**, so a forged `Host` header can never put a reset token on a link + to someone else's server. + +### Why the proxy count matters + +Minstrel only believes `X-Forwarded-For` and `X-Forwarded-Proto` from the +number of proxies you configure, counted from the right. Set it too high, or +leave it at the default of 1 with no proxy in front, and a client can write +those headers itself: it could claim any address to slip past the per-address +login limit, or claim HTTPS. With no proxy, set it to 0. + +When the proxy reports HTTPS, Minstrel marks the session cookie `Secure` and +sends `Strict-Transport-Security`. Over plain HTTP it does neither, and it +never redirects to HTTPS, so LAN use keeps working unchanged. + +## First account + +A fresh server with no accounts prints a **setup token** in its log: + +``` +docker compose logs minstrel | grep setup_token +``` + +Creating the first account (which becomes the admin) requires that token, so +whoever reaches a newly exposed server first can't claim it. The token +changes on every restart until an account exists. + +## Android app + +The app connects over whatever URL you give it, HTTP or HTTPS. Once the +server has a public HTTPS address, give the app that address so the session +cookie never crosses the internet unencrypted. See +[security notes](./security.md#android-allows-plain-http) for why plain HTTP +is still allowed. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..7452b8df --- /dev/null +++ b/docs/security.md @@ -0,0 +1,105 @@ +# Security notes + +What Minstrel does to protect accounts, and the reasoning behind the +decisions that look odd at first. For deployment steps, see +[hosting](./hosting.md). + +## Accounts and sessions + +- **Passwords** are stored as bcrypt hashes. +- **Login, registration and password reset are rate-limited:** 10 failed + sign-ins per account and 50 per address per 15 minutes, with similar limits + on register, forgot-password and reset. The Subsonic `/rest` API shares the + login limits. An unknown username takes as long to reject as a wrong + password, so timing doesn't reveal which accounts exist. +- **Sessions** are random 256-bit tokens; the server stores only their + SHA-256. A session ends after 30 days unused or 365 days in total. Changing + your password signs out your other devices; a password reset, or an admin + resetting it, signs out all of them. +- **API keys** (the OpenSubsonic `apiKey`) are stored as SHA-256 too, so a + key is shown once, when you generate it in Settings, and can only be + replaced after that. +- **The first account** on an empty server needs the setup token from the + server log (see [hosting](./hosting.md#first-account)). +- **Password-reset links** are built only from the configured public address, + never from the request's `Host` header. + +## Requests + +- Request bodies are capped at 4 MiB and must arrive within 30 seconds. + Streams and the live-event connection are unaffected. +- Every response carries `X-Content-Type-Options: nosniff`, + `Referrer-Policy: strict-origin-when-cross-origin`, a restrictive + `Permissions-Policy` and `X-Frame-Options: DENY`. The web app also gets a + `Content-Security-Policy` that allows only its own scripts, by hash. +- Media responses are `Cache-Control: private`, so a shared cache never keeps + one user's audio or artwork for another. + +## CSRF: SameSite cookies plus JSON-only writes + +There are no CSRF tokens. The session cookie is `SameSite=Strict`, so +browsers don't send it with requests that start on another site. That leaves +one gap: SameSite treats every subdomain of the same registrable domain as the +same site, so a different app on `other.example.com` could still send a +request carrying the cookie. To close it, any state-changing `/api` request +authenticated by the cookie must have a JSON body (`Content-Type: +application/json`) or no body at all. An HTML form or a script on another +origin can't send JSON without a CORS preflight, and Minstrel answers no +cross-origin preflight. Requests authenticated with a bearer token or the +Subsonic query parameters carry nothing a browser attaches on its own, so +they aren't checked. + +## Subsonic sign-in, and the one password stored in plain text + +Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password +followed by a random salt. To check that, the server has to know the password +itself, so supporting this sign-in method means storing a password Minstrel +can read. That is the Subsonic password, and it is the only credential +Minstrel keeps unhashed. + +- **The recommended way in is the API key.** Clients that support the + OpenSubsonic `apiKey` should use it. The key is stored hashed and can be + replaced at any time in Settings. +- **The Subsonic password is never your login password.** Minstrel generates + it (**Settings → Subsonic password**), shows it once, and lets you replace + or turn it off. Because it is random, a copy of the database exposes access + to this server's Subsonic API and nothing else: it can't be a password you + also use somewhere else. +- **`t`/`s` and `p=` sign-in are off for an account until it has a Subsonic + password.** Plain `p=` sign-in is additionally off server-wide unless + `subsonic.allow_plaintext_password` is enabled. +- `minstrel admin reset-password` changes only the login password. Older + versions also copied it into the Subsonic password; upgrading clears every + Subsonic password once, so those copies are gone. An account that used + `t`/`s` sign-in needs a new Subsonic password generated in Settings. + +## Android allows plain HTTP + +The Android app permits cleartext connections, for two reasons that can't be +narrowed to a list of hosts. The server address is whatever the user types, +and many self-hosted servers run plain HTTP on a LAN. And UPnP, DLNA and +Sonos speakers are controlled over plain HTTP at addresses that are only +known once they're discovered. The reasoning lives in +`android/app/src/main/res/xml/network_security_config.xml`. + +This doesn't weaken app updates: an APK altered in transit fails the +platform's signature check. It does mean a server reached over plain HTTP +across the internet exposes the session cookie in transit, which is why the +[hosting guide](./hosting.md) puts public servers behind HTTPS. + +On the phone, the session cookie is encrypted with a key held in the Android +Keystore, so a copy of the app's files doesn't yield a usable session. + +## Build pipeline + +Nothing is published unless every check passes: the Go, integration, web and +Android test suites, `govulncheck` against the toolchain that builds the +image, and `npm audit` on the packages that ship to the browser. See +`.gitea/workflows/release.yml`. + +## Reporting a problem + +Open an issue on the +[repository](https://git.fabledsword.com/bvandeusen/minstrel/issues), or +contact the maintainer privately first if it's something that shouldn't be +public until fixed. diff --git a/go.mod b/go.mod index 28a4ff66..b78e9bb6 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - golang.org/x/sync v0.20.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index cae66c76..55daf7a7 100644 --- a/go.sum +++ b/go.sum @@ -95,12 +95,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/api/admin_loudness.go b/internal/api/admin_loudness.go new file mode 100644 index 00000000..13cf850d --- /dev/null +++ b/internal/api/admin_loudness.go @@ -0,0 +1,82 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +// loudnessCoverageResp is the wire shape for GET /api/admin/library/loudness +// (M464 #4995). measured + silent + unreadable + pending = total; missing +// tracks are not counted. Enabled travels with the counts because with +// analysis off, pending never shrinks, and a gauge implying progress would be +// promising work nothing is doing. +type loudnessCoverageResp struct { + Total int64 `json:"total"` + Measured int64 `json:"measured"` + Silent int64 `json:"silent"` + Unreadable int64 `json:"unreadable"` + Pending int64 `json:"pending"` + Enabled bool `json:"enabled"` +} + +// handleGetLoudnessCoverage implements GET /api/admin/library/loudness: how +// far the loudness backfill has got. Always 200; zeros on an empty library. +func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Request) { + row, err := library.LoudnessCoverage(r.Context(), h.pool) + if err != nil { + writeErrWithLog(w, h.logger, "admin: get loudness coverage", apierror.InternalMsg("lookup failed", err)) + return + } + writeJSON(w, http.StatusOK, loudnessCoverageResp{ + Total: row.Total, + Measured: row.Measured, + Silent: row.Silent, + Unreadable: row.Unreadable, + Pending: row.Pending, + Enabled: h.loudnessSettings.Get().Enabled, + }) +} + +// loudnessSettingsBody is the wire shape for GET and PUT +// /api/admin/library/loudness-settings. +type loudnessSettingsBody struct { + Enabled bool `json:"enabled"` + BackfillConcurrency int32 `json:"backfill_concurrency"` +} + +func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody { + return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency} +} + +// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings. +func (h *handlers) handleGetLoudnessSettings(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(h.loudnessSettings.Get())) +} + +// handleUpdateLoudnessSettings implements PUT /api/admin/library/loudness-settings. +// A whole-row write: a body that leaves out the concurrency decodes it as zero, +// which is refused rather than saved. +func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.Request) { + var req loudnessSettingsBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON")) + return + } + saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{ + Enabled: req.Enabled, + BackfillConcurrency: req.BackfillConcurrency, + }) + if err != nil { + if errors.Is(err, library.ErrLoudnessSettingOutOfRange) { + writeErr(w, apierror.BadRequest("invalid_setting", err.Error())) + return + } + writeErrWithLog(w, h.logger, "admin loudness settings: update failed", apierror.Internal(err)) + return + } + writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(saved)) +} diff --git a/internal/api/admin_network.go b/internal/api/admin_network.go index 4ab12d63..e2e2a4be 100644 --- a/internal/api/admin_network.go +++ b/internal/api/admin_network.go @@ -23,10 +23,16 @@ type networkSettingsResp struct { // arrived and count them rather than guess. ForwardedChain string `json:"forwarded_chain"` RemoteAddr string `json:"remote_addr"` + // PublicURL is where users reach Minstrel; reset emails link to it and + // are not sent while it is empty. + PublicURL string `json:"public_url"` } +// Both fields are optional so the proxy card and the public-address card can +// each save their own value without overwriting the other's. type updateNetworkSettingsReq struct { - TrustedProxyHops int `json:"trusted_proxy_hops"` + TrustedProxyHops *int `json:"trusted_proxy_hops"` + PublicURL *string `json:"public_url"` } func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) { @@ -39,13 +45,37 @@ func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Re writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON")) return } - if err := h.netSettings.SetHops(r.Context(), req.TrustedProxyHops); err != nil { - if errors.Is(err, netsettings.ErrHopsOutOfRange) { - writeErr(w, apierror.BadRequest("invalid_hops", err.Error())) + if req.TrustedProxyHops == nil && req.PublicURL == nil { + writeErr(w, apierror.BadRequest("invalid_body", "nothing to update")) + return + } + // Validate everything before writing anything, so a bad public URL can't + // leave the hops half-saved. + if req.TrustedProxyHops != nil && (*req.TrustedProxyHops < 0 || *req.TrustedProxyHops > netsettings.MaxTrustedProxyHops) { + writeErr(w, apierror.BadRequest("invalid_hops", netsettings.ErrHopsOutOfRange.Error())) + return + } + if req.PublicURL != nil { + if _, err := netsettings.NormalizePublicURL(*req.PublicURL); err != nil { + writeErr(w, apierror.BadRequest("invalid_public_url", err.Error())) + return + } + } + if req.TrustedProxyHops != nil { + if err := h.netSettings.SetHops(r.Context(), *req.TrustedProxyHops); err != nil { + if errors.Is(err, netsettings.ErrHopsOutOfRange) { + writeErr(w, apierror.BadRequest("invalid_hops", err.Error())) + return + } + writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err)) + return + } + } + if req.PublicURL != nil { + if err := h.netSettings.SetPublicURL(r.Context(), *req.PublicURL); err != nil { + writeErrWithLog(w, h.logger, "admin network: public URL update failed", apierror.Internal(err)) return } - writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err)) - return } // Echo the payload recomputed under the NEW value, so the card can show // immediately what the change did to this request's own address rather @@ -61,5 +91,6 @@ func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp { DetectedClientIP: auth.ClientIP(r, hops), ForwardedChain: r.Header.Get("X-Forwarded-For"), RemoteAddr: r.RemoteAddr, + PublicURL: h.netSettings.PublicURL(), } } diff --git a/internal/api/admin_users.go b/internal/api/admin_users.go index 57eb8010..9c729e79 100644 --- a/internal/api/admin_users.go +++ b/internal/api/admin_users.go @@ -169,7 +169,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{ Username: req.Username, PasswordHash: string(hash), - ApiToken: apiToken, + ApiTokenHash: auth.HashAPIToken(apiToken), IsAdmin: req.IsAdmin, DisplayName: req.DisplayName, }) @@ -285,6 +285,19 @@ func (h *handlers) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque return } + // The target's existing sessions end with the old password. An admin + // resetting their own password keeps the session they're using, the + // same as a self-service change. + sessID, ownSession := auth.SessionIDFromContext(r.Context()) + if targetID == caller.ID && ownSession { + _, err = q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{UserID: targetID, ID: sessID}) + } else { + _, err = q.DeleteSessionsForUser(r.Context(), targetID) + } + if err != nil { + h.logger.Error("admin reset password: revoke sessions failed", "err", err) + } + audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, audit.ActionPasswordResetAdmin, nil) w.WriteHeader(http.StatusNoContent) diff --git a/internal/api/api.go b/internal/api/api.go index 6f21467c..a495c2fa 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,6 +7,7 @@ package api import ( "log/slog" "math/rand" + "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -33,8 +34,15 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService) { rng := rand.New(rand.NewSource(rand.Int63())) + setupToken, err := auth.NewSetupToken() + if err != nil { + // crypto/rand failing means the platform can't make secrets at all; + // sessions would be minted from the same source. Nothing to degrade to. + panic("api: mint setup token: " + err.Error()) + } + logSetupTokenIfNeeded(pool, logger, setupToken) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, recSettings: recSettings, @@ -57,12 +65,21 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev netSettings: netSettings, reacqSettings: reacqSettings, fingerprintSettings: fpSettings, + loudnessSettings: loudSettings, librarySize: recommendation.NewLibrarySize(nil), + loginGuard: auth.NewLoginGuard(), + setupToken: setupToken, + requireSetupToken: true, + registerLimit: auth.NewAttemptLimiter(registerPerAddressMax, time.Hour), + forgotAddressLimit: auth.NewAttemptLimiter(forgotPerAddressMax, time.Hour), + forgotEmailLimit: auth.NewAttemptLimiter(forgotPerEmailMax, time.Hour), + resetLimit: auth.NewAttemptLimiter(resetFailuresPerAddressMax, 15*time.Minute), } r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) api.Post("/auth/register", h.handleRegister) + api.Get("/auth/setup-status", h.handleSetupStatus) api.Post("/auth/forgot-password", h.handleForgotPassword) api.Post("/auth/reset-password", h.handleResetPassword) @@ -92,8 +109,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Put("/me/password", h.handleChangePassword) authed.Put("/me/profile", h.handleUpdateMyProfile) authed.Put("/me/timezone", h.handlePutTimezone) - authed.Get("/me/api-token", h.handleGetMyAPIToken) authed.Post("/me/api-token", h.handleRegenerateMyAPIToken) + authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword) + authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword) + authed.Delete("/me/subsonic-password", h.handleClearMySubsonicPassword) authed.Get("/me/sessions", h.handleListMySessions) authed.Delete("/me/sessions/{id}", h.handleRevokeMySession) authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions) @@ -113,6 +132,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/library/genres", h.handleListGenres) authed.Get("/library/years", h.handleListAlbumYears) authed.Get("/library/sync", h.handleLibrarySync) + // Before /tracks/{id} for readability; chi prefers the static + // segment either way. + authed.Get("/tracks/replay-gain", h.handleGetReplayGain) authed.Get("/tracks/{id}", h.handleGetTrack) // /tracks/{id}/stream is mounted above with OptionalUser so // it can accept either a session or a signed token. @@ -219,6 +241,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage) admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings) admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings) + admin.Get("/library/loudness", h.handleGetLoudnessCoverage) + admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings) + admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings) // Duplicates report (#3912): proposals from the duplicate sweep, a // trigger to sweep now, dismissal, and the merge (#3911), which deletes // the removed copies' files after moving their history onto the kept one. @@ -313,6 +338,23 @@ type handlers struct { // instance the scanner and the fingerprint workers read, so a save from the // admin card reaches them without a restart. Nil serves the defaults. fingerprintSettings *library.FingerprintSettingsService + // loudnessSettings is the loudness analysis policy (M464 #4995), the same + // instance the loudness backfill reads. Nil serves the defaults. + loudnessSettings *library.LoudnessSettingsService + // setupToken must accompany the first registration while no users exist + // (see auth.SetupToken). requireSetupToken is set by Mount, the only + // production constructor; tests that build handlers directly leave it + // off unless they are testing it. + setupToken *auth.SetupToken + requireSetupToken bool + // loginGuard throttles failed logins per account and per address, and + // the limiters below cap the other unauthenticated auth routes. All are + // nil-safe, so tests that build handlers directly run unthrottled. + loginGuard *auth.LoginGuard + registerLimit *auth.AttemptLimiter + forgotAddressLimit *auth.AttemptLimiter + forgotEmailLimit *auth.AttemptLimiter + resetLimit *auth.AttemptLimiter // netSettings caches the trusted reverse-proxy depth read by the auth // middleware on every request and edited from the admin network card. netSettings *netsettings.Service diff --git a/internal/api/auth.go b/internal/api/auth.go index ac125463..a36dea87 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -19,6 +19,20 @@ import ( // so an abandoned laptop doesn't stay logged in forever. const sessionCookieMaxAge = 30 * 24 * time.Hour +// Limits on the unauthenticated auth routes other than login (which uses +// auth.LoginGuard). Per address, per window as wired in Mount. +const ( + // registerPerAddressMax: 10 registrations an hour. Every attempt counts, + // typos included, which is still far above a household's need. + registerPerAddressMax = 10 + // forgotPerAddressMax / forgotPerEmailMax: reset emails an hour. The + // per-email cap is what keeps one inbox from being mailbombed. + forgotPerAddressMax = 5 + forgotPerEmailMax = 3 + // resetFailuresPerAddressMax: wrong or expired reset tokens per 15 min. + resetFailuresPerAddressMax = 20 +) + 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. @@ -70,10 +84,22 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { return } + // Checked before any lookup or bcrypt, so a throttled guess costs the + // server nothing and learns nothing. + addr := auth.ClientIP(r, h.netSettings.Hops()) + if blocked, wait := h.loginGuard.Blocked(req.Username, addr); blocked { + writeRateLimited(w, wait) + return + } + q := dbq.New(h.pool) user, err := q.GetUserByUsername(r.Context(), req.Username) if err != nil { if errors.Is(err, pgx.ErrNoRows) { + // Same bcrypt time as a wrong password, so timing doesn't say + // which usernames exist. + auth.DummyVerify(req.Password) + h.loginGuard.Fail(req.Username, addr) writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password")) return } @@ -82,9 +108,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { return } if !auth.VerifyPassword(user.PasswordHash, req.Password) { + h.loginGuard.Fail(req.Username, addr) writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password")) return } + h.loginGuard.Succeed(req.Username) token, err := auth.MintSessionToken() if err != nil { @@ -100,7 +128,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { // the active-sessions surface: a session that was born somewhere the // user recognises but is being used from somewhere they don't is the // case this whole surface exists to surface. - Ip: auth.ClientIP(r, h.netSettings.Hops()), + Ip: addr, }); err != nil { h.logger.Error("api: insert session failed", "err", err) writeErr(w, apierror.InternalMsg("insert failed", err)) @@ -112,7 +140,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { Value: token, Path: "/", HttpOnly: true, - Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure + Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94) SameSite: http.SameSiteStrictMode, MaxAge: int(sessionCookieMaxAge.Seconds()), }) diff --git a/internal/api/auth_forgot.go b/internal/api/auth_forgot.go index 45aa26f0..6d652437 100644 --- a/internal/api/auth_forgot.go +++ b/internal/api/auth_forgot.go @@ -14,6 +14,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" ) @@ -47,6 +48,19 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) } email := strings.ToLower(strings.TrimSpace(req.Email)) + // Throttled per address (a spray) and per email (a mailbomb of one + // inbox). Applied whether or not the email matches, so a 429 says + // nothing about which addresses are registered. + addr := auth.ClientIP(r, h.netSettings.Hops()) + blockedAddr, waitAddr := h.forgotAddressLimit.Blocked(addr) + blockedEmail, waitEmail := h.forgotEmailLimit.Blocked(email) + if blockedAddr || blockedEmail { + writeRateLimited(w, max(waitAddr, waitEmail)) + return + } + h.forgotAddressLimit.Record(addr) + h.forgotEmailLimit.Record(email) + q := dbq.New(h.pool) var matched bool var auditTarget pgtype.UUID @@ -55,7 +69,7 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) if err == nil && user.Email != nil && *user.Email != "" { matched = true auditTarget = user.ID - if sendErr := h.sendResetEmail(r.Context(), r, user); sendErr != nil { + if sendErr := h.sendResetEmail(r.Context(), user); sendErr != nil { h.logger.Warn("forgot-password: send failed", "email", email, "err", sendErr) // fall through; response is still 200 @@ -75,7 +89,7 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) // sendResetEmail generates a token, persists it, renders + sends the // email. Returns the underlying error (caller logs it but doesn't // surface to the HTTP response). -func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq.User) error { +func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error { if h.mailer == nil { return errors.New("forgot-password: no mailer configured") } @@ -95,7 +109,10 @@ func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq return err } - resetURL := buildResetURL(r, token) + resetURL, err := buildResetURL(h.netSettings.PublicURL(), token) + if err != nil { + return err + } textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{ Username: user.Username, ResetURL: resetURL, @@ -113,14 +130,18 @@ func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody) } -func buildResetURL(r *http.Request, token string) string { - scheme := "http" - if r.TLS != nil { - scheme = "https" +// errNoPublicURL stops a reset email from going out before the operator has +// said where Minstrel lives. It surfaces in the log, not the response, which +// stays the same opaque 200 either way. +var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent") + +// buildResetURL builds the emailed link from the operator-set public URL. +// It deliberately ignores the request: the Host header is whatever the +// requester sent, and building from it let a forged Host plant a real reset +// token on a link to someone else's server. +func buildResetURL(publicURL, token string) (string, error) { + if publicURL == "" { + return "", errNoPublicURL } - host := r.Host - if host == "" { - host = "localhost" - } - return scheme + "://" + host + "/reset-password/" + token + return publicURL + "/reset-password/" + token, nil } diff --git a/internal/api/auth_forgot_test.go b/internal/api/auth_forgot_test.go index 09eb38fa..d5ce4d5a 100644 --- a/internal/api/auth_forgot_test.go +++ b/internal/api/auth_forgot_test.go @@ -7,11 +7,59 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" + "github.com/jackc/pgx/v5/pgxpool" + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" + "git.fabledsword.com/bvandeusen/minstrel/internal/netsettings" ) +// withPublicURL gives h a network-settings service holding url, restoring an +// empty value afterwards so other tests see the default. +func withPublicURL(t *testing.T, h *handlers, pool *pgxpool.Pool, url string) { + t.Helper() + ns, err := netsettings.New(context.Background(), pool, nil) + if err != nil { + t.Fatalf("netsettings: %v", err) + } + if err := ns.SetPublicURL(context.Background(), url); err != nil { + t.Fatalf("set public url: %v", err) + } + t.Cleanup(func() { _ = ns.SetPublicURL(context.Background(), "") }) + h.netSettings = ns +} + +// With no public URL set, a reset email is not sent at all, and the response +// is still the same opaque 200. +func TestForgotPassword_NoPublicURL_SendsNothing(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + fake := &mailer.FakeSender{} + h.mailer = fake + withPublicURL(t, h, pool, "") + + user := seedUser(t, pool, "nourl", "pw", false) + if _, err := pool.Exec(context.Background(), + "UPDATE users SET email = 'nourl@example.com' WHERE id = $1", user.ID); err != nil { + t.Fatalf("seed email: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", + bytes.NewReader([]byte(`{"email":"nourl@example.com"}`))) + rec := httptest.NewRecorder() + h.handleForgotPassword(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if len(fake.Sent) != 0 { + t.Errorf("sent %d emails with no public URL set, want 0", len(fake.Sent)) + } +} + func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") @@ -19,6 +67,7 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) { h, pool := testHandlers(t) fake := &mailer.FakeSender{} h.mailer = fake + withPublicURL(t, h, pool, "https://music.example.com") user := seedUser(t, pool, "forgotuser", "pw", false) if _, err := pool.Exec(context.Background(), @@ -29,7 +78,8 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) { body := `{"email":"forgot@example.com"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewReader([]byte(body))) - req.Host = "minstrel.example.com" + // A forged Host must not reach the link: it comes from the public URL. + req.Host = "attacker.example.net" rec := httptest.NewRecorder() h.handleForgotPassword(rec, req) @@ -43,6 +93,12 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) { if got.To != "forgot@example.com" { t.Errorf("To = %q, want forgot@example.com", got.To) } + if !strings.Contains(got.TextBody, "https://music.example.com/reset-password/") { + t.Errorf("reset link not built from the public URL:\n%s", got.TextBody) + } + if strings.Contains(got.TextBody+got.HTMLBody, "attacker.example.net") { + t.Error("the request's Host header reached the emailed link") + } // Token row was inserted. var tokenCount int if err := pool.QueryRow(context.Background(), diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index 6f098787..eec2fd72 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -1,8 +1,10 @@ package api import ( + "context" "encoding/json" "errors" + "log/slog" "net/http" "regexp" "time" @@ -10,6 +12,7 @@ import ( "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" @@ -26,9 +29,12 @@ var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`) const minPasswordLength = 8 type registerReq struct { - Username string `json:"username"` - Password string `json:"password"` - InviteToken string `json:"invite_token"` + Username string `json:"username"` + Password string `json:"password"` + InviteToken string `json:"invite_token"` + // SetupToken is required only for the very first account; see + // auth.SetupToken. + SetupToken string `json:"setup_token"` DisplayName *string `json:"display_name"` } @@ -55,6 +61,15 @@ type registerReq struct { // - 409 username_taken (PG unique violation on users.username) // - 500 server_error otherwise func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { + // Every attempt counts, not only failures: a successful registration is + // the thing being sprayed when the mode is open. + addr := auth.ClientIP(r, h.netSettings.Hops()) + if blocked, wait := h.registerLimit.Blocked(addr); blocked { + writeRateLimited(w, wait) + return + } + h.registerLimit.Record(addr) + var req registerReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body")) @@ -79,6 +94,19 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { return } + // The first account becomes admin, so it must prove it can read the + // server log. Checked before the invite logic, which the empty-users + // state skips. + if userCount == 0 && h.requireSetupToken && !h.setupToken.Matches(req.SetupToken) { + // Repeat the token in the log at the moment someone needs it: the + // boot line may have scrolled away, or users may have been deleted + // since boot. + h.logger.Warn("register: first-admin registration needs the setup token", + "setup_token", h.setupToken.Value()) + writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to create the first account")) + return + } + // Validate invite (skipped on empty-users state; skipped in 'open' mode). usedInviteToken := "" if userCount > 0 { @@ -133,7 +161,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{ Username: req.Username, PasswordHash: string(hash), - ApiToken: apiToken, + ApiTokenHash: auth.HashAPIToken(apiToken), DisplayName: req.DisplayName, }) if err != nil { @@ -175,7 +203,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { UserID: user.ID, TokenHash: auth.HashSessionToken(sessionToken), UserAgent: r.UserAgent(), - Ip: auth.ClientIP(r, h.netSettings.Hops()), + Ip: addr, }); err != nil { h.logger.Error("register: insert session failed", "err", err) writeErr(w, apierror.Internal(err)) @@ -186,7 +214,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { Value: sessionToken, Path: "/", HttpOnly: true, - Secure: r.TLS != nil, + Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94) SameSite: http.SameSiteStrictMode, MaxAge: int(sessionCookieMaxAge.Seconds()), }) @@ -223,3 +251,31 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { }, }) } + +// handleSetupStatus implements GET /api/auth/setup-status. It tells the +// register screen whether to ask for the setup token, i.e. whether no +// account exists yet. Public, since it is needed before anyone can sign in; +// "this server has no users" is not worth hiding from someone who could +// simply try to register. +func (h *handlers) handleSetupStatus(w http.ResponseWriter, r *http.Request) { + n, err := dbq.New(h.pool).CountUsers(r.Context()) + if err != nil { + writeErrWithLog(w, h.logger, "setup status: count users failed", apierror.Internal(err)) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"setup_required": n == 0}) +} + +// logSetupTokenIfNeeded writes the setup token to the log at boot when the +// instance has no accounts yet, with the instruction for using it. +func logSetupTokenIfNeeded(pool *pgxpool.Pool, logger *slog.Logger, token *auth.SetupToken) { + if pool == nil || logger == nil { + return + } + n, err := dbq.New(pool).CountUsers(context.Background()) + if err != nil || n > 0 { + return + } + logger.Warn("no accounts yet: open the web app, choose Create account, and enter this setup token to become the admin", + "setup_token", token.Value()) +} diff --git a/internal/api/auth_register_test.go b/internal/api/auth_register_test.go index 698caf5e..d64de07e 100644 --- a/internal/api/auth_register_test.go +++ b/internal/api/auth_register_test.go @@ -273,3 +273,75 @@ func TestRegister_FirstAdminRace(t *testing.T) { // not "exactly one" (which would require serializable isolation). t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount) } + +// With the gate on (as Mount sets it), the first account needs the setup +// token from the log; a missing or wrong one is refused and creates nothing. +func TestRegister_FirstUserNeedsSetupToken(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, _ := testHandlers(t) + resetUsers(t, h) + token, err := auth.NewSetupToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + h.setupToken = token + h.requireSetupToken = true + + register := func(body string) int { + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleRegister(rec, req) + return rec.Code + } + + if code := register(`{"username":"squatter","password":"abcd1234"}`); code != http.StatusForbidden { + t.Errorf("no token: status = %d, want 403", code) + } + if code := register(`{"username":"squatter","password":"abcd1234","setup_token":"wrong"}`); code != http.StatusForbidden { + t.Errorf("wrong token: status = %d, want 403", code) + } + var n int + if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatalf("a refused registration created %d account(s)", n) + } + + if code := register(`{"username":"operator","password":"abcd1234","setup_token":"` + token.Value() + `"}`); code != http.StatusOK { + t.Fatalf("right token: status = %d, want 200", code) + } + + // Once an account exists the token plays no part: the second user is + // governed by registration mode, not the setup token. + if _, err := h.pool.Exec(context.Background(), + `UPDATE registration_settings SET mode = 'open' WHERE id = true`); err != nil { + t.Fatalf("open mode: %v", err) + } + t.Cleanup(func() { + _, _ = h.pool.Exec(context.Background(), + `UPDATE registration_settings SET mode = 'invite_only' WHERE id = true`) + }) + if code := register(`{"username":"second","password":"abcd1234"}`); code != http.StatusOK { + t.Errorf("second user without token: status = %d, want 200", code) + } +} + +func TestSetupToken_MatchesOnlyItself(t *testing.T) { + tok, err := auth.NewSetupToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + if !tok.Matches(tok.Value()) { + t.Error("token does not match itself") + } + if tok.Matches("") || tok.Matches(tok.Value()+"x") { + t.Error("token matched something else") + } + var none *auth.SetupToken + if none.Matches("") || none.Matches("anything") { + t.Error("a nil token must fail closed") + } +} diff --git a/internal/api/auth_reset.go b/internal/api/auth_reset.go index 48a7b070..00196349 100644 --- a/internal/api/auth_reset.go +++ b/internal/api/auth_reset.go @@ -10,6 +10,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) @@ -42,6 +43,15 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { return } + // Tokens carry 256 bits, so guessing one is hopeless; the limit is on + // failed tries per address so that nobody gets to find that out at our + // expense. + addr := auth.ClientIP(r, h.netSettings.Hops()) + if blocked, wait := h.resetLimit.Blocked(addr); blocked { + writeRateLimited(w, wait) + return + } + q := dbq.New(h.pool) // Look up the reset record so we know which user to update before @@ -50,6 +60,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { reset, err := q.GetPasswordReset(r.Context(), req.Token) if err != nil { if errors.Is(err, pgx.ErrNoRows) { + h.resetLimit.Record(addr) writeErr(w, apierror.BadRequest("invalid_token", "")) return } @@ -69,6 +80,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { return } if rows == 0 { + h.resetLimit.Record(addr) writeErr(w, apierror.BadRequest("invalid_token", "")) return } @@ -92,6 +104,13 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { return } + // End every session the account has. Whoever needed a reset isn't signed + // in anywhere they rely on, and whoever may have learned the old password + // must not stay signed in on it. + if _, err := q.DeleteSessionsForUser(r.Context(), reset.UserID); err != nil { + h.logger.Error("reset password: revoke sessions failed", "err", err) + } + audit.WriteOrLog(r.Context(), h.pool, h.logger, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil) w.WriteHeader(http.StatusNoContent) diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 171c0f8a..4f04a6ef 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -91,7 +91,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdm u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: prefixed, PasswordHash: string(hash), - ApiToken: "test-api-token-" + prefixed, + ApiTokenHash: "test-api-token-" + prefixed, IsAdmin: isAdmin, }) if err != nil { @@ -179,6 +179,36 @@ func TestHandleLogin_UnknownUserReturns401(t *testing.T) { } } +// A guesser who reaches the account limit is refused with 429 before any +// password check, so even the right password is turned away until the window +// passes, and the response says when to come back. +func TestHandleLogin_ThrottledAfterRepeatedFailures(t *testing.T) { + h, pool := testHandlers(t) + h.loginGuard = auth.NewLoginGuard() + seedUser(t, pool, "alice", "hunter2", false) + + login := func(password string) *httptest.ResponseRecorder { + body := strings.NewReader(`{"username":"test-alice","password":"` + password + `"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleLogin(w, req) + return w + } + for i := 0; i < 10; i++ { + if w := login("wrong"); w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code) + } + } + w := login("hunter2") + if w.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429 once the account limit is reached", w.Code) + } + if w.Header().Get("Retry-After") == "" { + t.Error("429 without Retry-After") + } +} + func TestHandleLogin_MalformedBodyReturns400(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", diff --git a/internal/api/errors.go b/internal/api/errors.go index 58410641..be7c0427 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -3,7 +3,10 @@ package api import ( "encoding/json" "log/slog" + "math" "net/http" + "strconv" + "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" ) @@ -36,6 +39,17 @@ func writeErr(w http.ResponseWriter, err error) { }}) } +// writeRateLimited answers 429 with Retry-After in whole seconds, rounded +// up so a client that honours it never arrives a moment early. +func writeRateLimited(w http.ResponseWriter, wait time.Duration) { + secs := int(math.Ceil(wait.Seconds())) + if secs < 1 { + secs = 1 + } + w.Header().Set("Retry-After", strconv.Itoa(secs)) + writeErr(w, apierror.TooManyRequests("rate_limited", "too many attempts; try again later")) +} + // writeErrWithLog logs the error at Error level and writes the response. // Use for 500-class errors where the operator needs the cause in logs. func writeErrWithLog(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { diff --git a/internal/api/library_sync.go b/internal/api/library_sync.go index 96363ad1..932d7b93 100644 --- a/internal/api/library_sync.go +++ b/internal/api/library_sync.go @@ -10,6 +10,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) @@ -151,8 +152,12 @@ func (h *handlers) hydrateUpserts( if err != nil { return nil, err } + gains, err := library.ReplayGainForAlbums(ctx, q, uuids) + if err != nil { + return nil, err + } for _, a := range albums { - b, _ := json.Marshal(toAlbumSyncView(a)) + b, _ := json.Marshal(toAlbumSyncView(a, gains[a.ID])) out["album"] = append(out["album"], b) } } @@ -162,8 +167,12 @@ func (h *handlers) hydrateUpserts( if err != nil { return nil, err } + gains, err := library.ReplayGainForTracks(ctx, q, uuids) + if err != nil { + return nil, err + } for _, t := range tracks { - b, _ := json.Marshal(toTrackSyncView(t)) + b, _ := json.Marshal(toTrackSyncView(t, gains[t.ID])) out["track"] = append(out["track"], b) } } diff --git a/internal/api/library_sync_views.go b/internal/api/library_sync_views.go index 1e8520f9..c9038c3c 100644 --- a/internal/api/library_sync_views.go +++ b/internal/api/library_sync_views.go @@ -18,6 +18,7 @@ package api import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) @@ -49,9 +50,14 @@ type albumSyncView struct { ReleaseDate *string `json:"release_date"` CoverArtPath *string `json:"cover_art_path"` Mbid *string `json:"mbid"` + // AlbumGain and AlbumPeak are the album's ReplayGain 2.0 values (#4997): + // dB to the -18 LUFS reference, and a linear peak. Null until every track + // on the album is measured. + AlbumGain *float32 `json:"album_gain"` + AlbumPeak *float32 `json:"album_peak"` } -func toAlbumSyncView(a dbq.Album) albumSyncView { +func toAlbumSyncView(a dbq.Album, g library.ReplayGain) albumSyncView { var releaseDate *string if a.ReleaseDate.Valid { s := a.ReleaseDate.Time.Format("2006-01-02") @@ -65,6 +71,8 @@ func toAlbumSyncView(a dbq.Album) albumSyncView { ReleaseDate: releaseDate, CoverArtPath: a.CoverArtPath, Mbid: a.Mbid, + AlbumGain: g.AlbumGain, + AlbumPeak: g.AlbumPeak, } } @@ -92,9 +100,14 @@ type trackSyncView struct { // track is playable, which is a yes/no. The "gone since" clock is an // operator concern and lives on the admin surface. Missing bool `json:"missing"` + // TrackGain and TrackPeak are the track's ReplayGain 2.0 values (#4997). + // Null until the track is measured. The album's pair rides on the album + // view, since it changes when the album does, not when this track does. + TrackGain *float32 `json:"track_gain"` + TrackPeak *float32 `json:"track_peak"` } -func toTrackSyncView(t dbq.Track) trackSyncView { +func toTrackSyncView(t dbq.Track, g library.ReplayGain) trackSyncView { return trackSyncView{ ID: syncpkg.FormatUUID(t.ID), AlbumID: syncpkg.FormatUUID(t.AlbumID), @@ -107,6 +120,8 @@ func toTrackSyncView(t dbq.Track) trackSyncView { FileFormat: t.FileFormat, Genre: t.Genre, Missing: t.MissingSince.Valid, + TrackGain: g.TrackGain, + TrackPeak: g.TrackPeak, } } diff --git a/internal/api/library_sync_views_test.go b/internal/api/library_sync_views_test.go index bd322d23..fe627d45 100644 --- a/internal/api/library_sync_views_test.go +++ b/internal/api/library_sync_views_test.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" ) // These tests pin the wire-format keys for /api/library/sync upserts. @@ -53,13 +54,13 @@ func TestArtistSyncView_WireKeys(t *testing.T) { func TestAlbumSyncView_WireKeys(t *testing.T) { a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"} - b, err := json.Marshal(toAlbumSyncView(a)) + b, err := json.Marshal(toAlbumSyncView(a, library.ReplayGain{})) if err != nil { t.Fatal(err) } assertJSONKeys(t, "album", b, []string{ "id", "artist_id", "title", "sort_title", - "release_date", "cover_art_path", "mbid", + "release_date", "cover_art_path", "mbid", "album_gain", "album_peak", }) } @@ -69,14 +70,14 @@ func TestTrackSyncView_WireKeys(t *testing.T) { Title: "Avril 14th", DurationMs: 121_000, FilePath: "x", FileFormat: "flac", } - b, err := json.Marshal(toTrackSyncView(tr)) + b, err := json.Marshal(toTrackSyncView(tr, library.ReplayGain{})) if err != nil { t.Fatal(err) } assertJSONKeys(t, "track", b, []string{ "id", "album_id", "artist_id", "title", "duration_ms", "track_number", "disc_number", "file_path", "file_format", "genre", - "missing", + "missing", "track_gain", "track_peak", }) } @@ -85,17 +86,44 @@ func TestTrackSyncView_WireKeys(t *testing.T) { // client's library, a missing one stays playable and fails at the speaker. func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) { present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID} - if toTrackSyncView(present).Missing { + if toTrackSyncView(present, library.ReplayGain{}).Missing { t.Error("a track with no missing_since must not be marked missing") } gone := present gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true} - if !toTrackSyncView(gone).Missing { + if !toTrackSyncView(gone, library.ReplayGain{}).Missing { t.Error("a track with missing_since must be marked missing") } } +// The gains are what Android levels playback by, offline included (#4997): +// the track's pair rides on the track view and the album's on the album view. +func TestSyncViews_CarryReplayGain(t *testing.T) { + gain, peak := float32(-3.25), float32(0.9441) + g := library.ReplayGain{TrackGain: &gain, TrackPeak: &peak, AlbumGain: &gain, AlbumPeak: &peak} + tv := toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, g) + if tv.TrackGain == nil || *tv.TrackGain != gain || tv.TrackPeak == nil || *tv.TrackPeak != peak { + t.Errorf("track view gains = %v/%v, want %v/%v", tv.TrackGain, tv.TrackPeak, gain, peak) + } + av := toAlbumSyncView(dbq.Album{ID: validUUID, ArtistID: validUUID}, g) + if av.AlbumGain == nil || *av.AlbumGain != gain || av.AlbumPeak == nil || *av.AlbumPeak != peak { + t.Errorf("album view gains = %v/%v, want %v/%v", av.AlbumGain, av.AlbumPeak, gain, peak) + } + // Unmeasured: explicit nulls, like every other optional sync field. + b, err := json.Marshal(toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, library.ReplayGain{})) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if v, ok := m["track_gain"]; !ok || v != nil { + t.Errorf("unmeasured track_gain = %v (present %v), want an explicit null", v, ok) + } +} + func TestPlaylistSyncView_WireKeys(t *testing.T) { variant := "discover" p := dbq.Playlist{ diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 81cd1677..db74f2fc 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil) paths := []string{ "/api/artists", diff --git a/internal/api/me_password.go b/internal/api/me_password.go index 97ee42d7..22c9afe1 100644 --- a/internal/api/me_password.go +++ b/internal/api/me_password.go @@ -8,6 +8,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) @@ -61,6 +62,19 @@ func (h *handlers) handleChangePassword(w http.ResponseWriter, r *http.Request) return } + // Sign out every other device. A password change is often the response + // to suspecting someone else has it, and their session would otherwise + // outlive the password it was opened with. The device making the change + // stays signed in. + if sessID, ok := auth.SessionIDFromContext(r.Context()); ok { + if _, err := q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{ + UserID: user.ID, + ID: sessID, + }); err != nil { + h.logger.Error("change password: revoke other sessions failed", "err", err) + } + } + audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil) w.WriteHeader(http.StatusNoContent) diff --git a/internal/api/me_subsonic_password.go b/internal/api/me_subsonic_password.go new file mode 100644 index 00000000..62143d33 --- /dev/null +++ b/internal/api/me_subsonic_password.go @@ -0,0 +1,84 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// The Subsonic password is for clients that sign in with Subsonic's t/s +// scheme (md5 of the password plus a salt). Checking that needs the password +// itself, so it is stored readable (migration 0003). That is why Minstrel +// generates it rather than letting the user choose one: a generated value +// can never be a login password reused from somewhere else, so a leaked +// users table gives up access to this server's /rest API and nothing more +// (M462 #5026). + +const subsonicPasswordBytes = 18 // 24 base64url characters + +type subsonicPasswordStatusResp struct { + Enabled bool `json:"enabled"` +} + +type subsonicPasswordResp struct { + Password string `json:"password"` +} + +// handleGetMySubsonicPassword implements GET /api/me/subsonic-password. It +// reports only whether one is set; the value is shown once, when generated. +func (h *handlers) handleGetMySubsonicPassword(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, subsonicPasswordStatusResp{Enabled: user.SubsonicPassword != nil}) +} + +// handleGenerateMySubsonicPassword implements POST /api/me/subsonic-password: +// replaces any existing Subsonic password with a new random one and returns it. +func (h *handlers) handleGenerateMySubsonicPassword(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + b := make([]byte, subsonicPasswordBytes) + if _, err := rand.Read(b); err != nil { + h.logger.Error("generate subsonic password: rand failed", "err", err) + writeErr(w, apierror.Internal(err)) + return + } + pw := base64.RawURLEncoding.EncodeToString(b) + if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{ + ID: user.ID, + SubsonicPassword: &pw, + }); err != nil { + h.logger.Error("generate subsonic password: update failed", "err", err) + writeErr(w, apierror.Internal(err)) + return + } + audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSubsonicPasswordSet, nil) + writeJSON(w, http.StatusOK, subsonicPasswordResp{Password: pw}) +} + +// handleClearMySubsonicPassword implements DELETE /api/me/subsonic-password, +// which turns t/s and p= sign-in off for the account. +func (h *handlers) handleClearMySubsonicPassword(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{ + ID: user.ID, + SubsonicPassword: nil, + }); err != nil { + h.logger.Error("clear subsonic password: update failed", "err", err) + writeErr(w, apierror.Internal(err)) + return + } + audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSubsonicPasswordClear, nil) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/me_subsonic_password_test.go b/internal/api/me_subsonic_password_test.go new file mode 100644 index 00000000..f908cab1 --- /dev/null +++ b/internal/api/me_subsonic_password_test.go @@ -0,0 +1,132 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newMeSubsonicPasswordRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/me/subsonic-password", h.handleGetMySubsonicPassword) + r.Post("/api/me/subsonic-password", h.handleGenerateMySubsonicPassword) + r.Delete("/api/me/subsonic-password", h.handleClearMySubsonicPassword) + return r +} + +func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string { + t.Helper() + var pw *string + if err := h.pool.QueryRow(context.Background(), + "SELECT subsonic_password FROM users WHERE id = $1", user.ID).Scan(&pw); err != nil { + t.Fatalf("read subsonic_password: %v", err) + } + return pw +} + +func TestSubsonicPassword_GenerateIsRandomAndNotTheLoginPassword(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "sspw1", "login-pw", false) + router := newMeSubsonicPasswordRouter(h) + + generate := func() string { + req := withUser(httptest.NewRequest(http.MethodPost, "/api/me/subsonic-password", nil), user) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp subsonicPasswordResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + return resp.Password + } + + first := generate() + if len(first) != 24 { + t.Errorf("password length = %d, want 24", len(first)) + } + if first == "login-pw" { + t.Errorf("generated password equals the login password") + } + if got := readSubsonicPassword(t, h, user); got == nil || *got != first { + t.Errorf("stored = %v, want the returned password", got) + } + + second := generate() + if second == first { + t.Errorf("regenerate returned the same password") + } + if got := readSubsonicPassword(t, h, user); got == nil || *got != second { + t.Errorf("stored after regenerate = %v, want the new password", got) + } +} + +func TestSubsonicPassword_StatusAndClear(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "sspw2", "login-pw", false) + router := newMeSubsonicPasswordRouter(h) + + status := func(u dbq.User) bool { + req := withUser(httptest.NewRequest(http.MethodGet, "/api/me/subsonic-password", nil), u) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp subsonicPasswordStatusResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + return resp.Enabled + } + + if status(user) { + t.Errorf("enabled = true for a new account, want false") + } + pw := "set-by-test" + user.SubsonicPassword = &pw + if !status(user) { + t.Errorf("enabled = false with a password set, want true") + } + // The status response never carries the value itself. + req := withUser(httptest.NewRequest(http.MethodGet, "/api/me/subsonic-password", nil), user) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + if _, has := raw["password"]; has { + t.Errorf("GET response includes the password: %s", rec.Body.String()) + } + + if err := dbq.New(pool).SetSubsonicPassword(context.Background(), dbq.SetSubsonicPasswordParams{ + ID: user.ID, SubsonicPassword: &pw, + }); err != nil { + t.Fatalf("seed subsonic_password: %v", err) + } + req = withUser(httptest.NewRequest(http.MethodDelete, "/api/me/subsonic-password", nil), user) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + if got := readSubsonicPassword(t, h, user); got != nil { + t.Errorf("stored after clear = %q, want NULL", *got) + } +} diff --git a/internal/api/me_token.go b/internal/api/me_token.go index a9ddf1d4..37d5e9a6 100644 --- a/internal/api/me_token.go +++ b/internal/api/me_token.go @@ -13,23 +13,11 @@ type apiTokenResp struct { APIToken string `json:"api_token"` } -// handleGetMyAPIToken implements GET /api/me/api-token. -func (h *handlers) handleGetMyAPIToken(w http.ResponseWriter, r *http.Request) { - user, ok := requireUser(w, r) - if !ok { - return - } - q := dbq.New(h.pool) - current, err := q.GetUserByID(r.Context(), user.ID) - if err != nil { - h.logger.Error("get api token: lookup failed", "err", err) - writeErr(w, apierror.Internal(err)) - return - } - writeJSON(w, http.StatusOK, apiTokenResp{APIToken: current.ApiToken}) -} - // handleRegenerateMyAPIToken implements POST /api/me/api-token. +// +// Only the key's hash is stored, so this response is the one time the key +// can be read. There is deliberately no GET: a key that has been shown and +// lost is replaced, not looked up. func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { @@ -42,11 +30,10 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req return } q := dbq.New(h.pool) - updated, err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{ - ID: user.ID, - ApiToken: newToken, - }) - if err != nil { + if err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{ + ID: user.ID, + ApiTokenHash: auth.HashAPIToken(newToken), + }); err != nil { h.logger.Error("regenerate api token: update failed", "err", err) writeErr(w, apierror.Internal(err)) return @@ -54,5 +41,5 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionTokenRegenerate, nil) - writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken}) + writeJSON(w, http.StatusOK, apiTokenResp{APIToken: newToken}) } diff --git a/internal/api/me_token_test.go b/internal/api/me_token_test.go index b3d26118..a7ba9e1d 100644 --- a/internal/api/me_token_test.go +++ b/internal/api/me_token_test.go @@ -8,47 +8,23 @@ import ( "os" "testing" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "github.com/go-chi/chi/v5" ) func newMeTokenRouter(h *handlers) chi.Router { r := chi.NewRouter() - r.Get("/api/me/api-token", h.handleGetMyAPIToken) r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken) return r } -func TestGetAPIToken_ReturnsCurrentToken(t *testing.T) { - if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - h, pool := testHandlers(t) - user := seedUser(t, pool, "tok1", "pw", false) - - req := httptest.NewRequest(http.MethodGet, "/api/me/api-token", nil) - req = withUser(req, user) - rec := httptest.NewRecorder() - newMeTokenRouter(h).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - var resp apiTokenResp - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.APIToken != user.ApiToken { - t.Errorf("api_token = %q, want %q", resp.APIToken, user.ApiToken) - } -} - func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } h, pool := testHandlers(t) user := seedUser(t, pool, "tok2", "pw", false) - oldToken := user.ApiToken + oldHash := user.ApiTokenHash req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil) req = withUser(req, user) @@ -65,20 +41,20 @@ func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) { if resp.APIToken == "" { t.Fatalf("api_token is empty") } - if resp.APIToken == oldToken { - t.Errorf("api_token unchanged after regenerate") - } - - // Verify DB row has the new token and old token no longer matches. - var dbToken string + // The DB holds the new key's hash, never the key, and the old key no + // longer matches. + var dbHash string if err := pool.QueryRow(context.Background(), - "SELECT api_token FROM users WHERE id = $1", user.ID).Scan(&dbToken); err != nil { - t.Fatalf("read token: %v", err) + "SELECT api_token_hash FROM users WHERE id = $1", user.ID).Scan(&dbHash); err != nil { + t.Fatalf("read token hash: %v", err) } - if dbToken != resp.APIToken { - t.Errorf("DB api_token = %q, want %q", dbToken, resp.APIToken) + if dbHash != auth.HashAPIToken(resp.APIToken) { + t.Errorf("DB api_token_hash = %q, want hash of the returned key", dbHash) } - if dbToken == oldToken { - t.Errorf("old token still in DB after regenerate") + if dbHash == oldHash { + t.Errorf("old key hash still in DB after regenerate") + } + if dbHash == resp.APIToken { + t.Errorf("DB holds the raw key") } } diff --git a/internal/api/media.go b/internal/api/media.go index b2be8ed1..a4009a44 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -117,7 +117,7 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) { // clients skip the conditional GET for the bulk of a session, but // stale art clears within 24h after a re-scan. ServeContent below // still emits Last-Modified for the conditional path when needed. - w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate") + w.Header().Set("Cache-Control", "private, max-age=86400, must-revalidate") http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f) } @@ -197,6 +197,6 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) { // max-age + immutable lets the client cache (LockCachingAudioSource // on the Flutter side, browser cache on web) skip even the // conditional GET on repeat plays. - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f) } diff --git a/internal/api/playlists.go b/internal/api/playlists.go index af262bd7..eab70042 100644 --- a/internal/api/playlists.go +++ b/internal/api/playlists.go @@ -406,7 +406,7 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request // (system playlists re-rendered on rebuild, user playlists when // modified). 5 minutes is short enough for normal edits to feel // fresh, long enough to skip repeat fetches during a session. - w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") + w.Header().Set("Cache-Control", "private, max-age=300, must-revalidate") http.ServeFile(w, r, full) } diff --git a/internal/api/replay_gain.go b/internal/api/replay_gain.go new file mode 100644 index 00000000..38009db1 --- /dev/null +++ b/internal/api/replay_gain.go @@ -0,0 +1,54 @@ +package api + +import ( + "net/http" + "strings" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +// maxReplayGainIDs caps one lookup. A player asks for the tracks around the +// one it is about to play, never a whole library. +const maxReplayGainIDs = 200 + +// replayGainResp is the wire shape for GET /api/tracks/replay-gain. Tracks +// with no gain known are absent from items: no adjustment. +type replayGainResp struct { + Items map[string]library.ReplayGain `json:"items"` +} + +// handleGetReplayGain implements GET /api/tracks/replay-gain?ids=a,b,c (M464 +// #4997): ReplayGain 2.0 values (dB to -18 LUFS, linear peaks) for each track, +// and for its album when the whole album is measured. +// +// A lookup rather than fields on TrackRef. Tracks reach the player through +// about fifteen surfaces in two wire shapes (TrackRef and playlist entries), +// and only the player uses the gain. One call from the player, made for the +// tracks it is about to play, cannot be forgotten by a surface added later, +// and costs nothing on the pages that never play anything. Android takes the +// same values from the sync feed instead, so they are there offline. +func (h *handlers) handleGetReplayGain(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimSpace(r.URL.Query().Get("ids")) + if raw == "" { + writeJSON(w, http.StatusOK, replayGainResp{Items: map[string]library.ReplayGain{}}) + return + } + parts := strings.Split(raw, ",") + if len(parts) > maxReplayGainIDs { + writeErr(w, apierror.BadRequest("too_many_ids", "at most 200 ids per request")) + return + } + ids := stringsToUUIDs(parts) + gains, err := library.ReplayGainForTracks(r.Context(), dbq.New(h.pool), ids) + if err != nil { + writeErrWithLog(w, h.logger, "replay gain: lookup", apierror.InternalMsg("lookup failed", err)) + return + } + items := make(map[string]library.ReplayGain, len(gains)) + for id, g := range gains { + items[uuidToString(id)] = g + } + writeJSON(w, http.StatusOK, replayGainResp{Items: items}) +} diff --git a/internal/api/replay_gain_test.go b/internal/api/replay_gain_test.go new file mode 100644 index 00000000..150997bf --- /dev/null +++ b/internal/api/replay_gain_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func TestGetReplayGain(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "rg1", "pw", false) + measured, _ := seedTrackForRemoveTest(t, h, "rg-measured", "", "") + unmeasured, _ := seedTrackForRemoveTest(t, h, "rg-unmeasured", "", "") + lufs, peak := float32(-12.5), float32(-1) + if err := dbq.New(pool).UpsertTrackLoudness(context.Background(), dbq.UpsertTrackLoudnessParams{ + TrackID: measured.ID, IntegratedLufs: &lufs, TruePeakDbtp: &peak, AnalysisVersion: 1, + }); err != nil { + t.Fatalf("seed loudness: %v", err) + } + r := chi.NewRouter() + r.Get("/api/tracks/replay-gain", h.handleGetReplayGain) + get := func(query string) *httptest.ResponseRecorder { + req := withUser(httptest.NewRequest(http.MethodGet, "/api/tracks/replay-gain"+query, nil), user) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec + } + + rec := get("?ids=" + uuidToString(measured.ID) + "," + uuidToString(unmeasured.ID) + ",not-a-uuid") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Items map[string]struct { + TrackGain *float32 `json:"track_gain"` + TrackPeak *float32 `json:"track_peak"` + AlbumGain *float32 `json:"album_gain"` + } `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + g, ok := resp.Items[uuidToString(measured.ID)] + if !ok || g.TrackGain == nil || *g.TrackGain != -5.5 || g.TrackPeak == nil { + t.Errorf("measured track = %+v (present %v), want track_gain -5.5 with a peak", g, ok) + } + if g.AlbumGain != nil { + t.Errorf("album_gain present with no album loudness: %v", *g.AlbumGain) + } + if _, ok := resp.Items[uuidToString(unmeasured.ID)]; ok { + t.Errorf("unmeasured track listed; absent means no adjustment") + } + if len(resp.Items) != 1 { + t.Errorf("items = %v, want only the measured track", resp.Items) + } + + if rec := get(""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"items":{}`) { + t.Errorf("no ids: %d %s, want 200 with empty items", rec.Code, rec.Body.String()) + } + if rec := get("?ids=" + strings.Repeat("x,", maxReplayGainIDs)); rec.Code != http.StatusBadRequest { + t.Errorf("%d ids: status %d, want 400", maxReplayGainIDs+1, rec.Code) + } +} diff --git a/internal/api/session_lifecycle_test.go b/internal/api/session_lifecycle_test.go new file mode 100644 index 00000000..ceb4ab69 --- /dev/null +++ b/internal/api/session_lifecycle_test.go @@ -0,0 +1,139 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func sessionExists(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) bool { + t.Helper() + var exists bool + if err := pool.QueryRow(context.Background(), + `SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, id, + ).Scan(&exists); err != nil { + t.Fatalf("exists check: %v", err) + } + return exists +} + +// Sessions expire server-side on two limits, and an expired one stops +// authenticating at once rather than when the GC sweep gets to it. +func TestSessionExpiry_IdleAndAbsoluteLimits(t *testing.T) { + _, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "hunter2", false) + q := dbq.New(pool) + + mint := func(setup string) []byte { + token, err := auth.MintSessionToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + hash := auth.HashSessionToken(token) + sess, err := q.InsertSession(context.Background(), dbq.InsertSessionParams{ + UserID: user.ID, TokenHash: hash, UserAgent: "test", Ip: "192.0.2.1", + }) + if err != nil { + t.Fatalf("insert: %v", err) + } + if setup != "" { + if _, err := pool.Exec(context.Background(), `UPDATE sessions SET `+setup+` WHERE id = $1`, sess.ID); err != nil { + t.Fatalf("age session: %v", err) + } + } + return hash + } + + fresh := mint("") + idle := mint(`last_seen_at = now() - interval '31 days'`) + old := mint(`created_at = now() - interval '366 days'`) + + if _, err := q.GetSessionByTokenHash(context.Background(), fresh); err != nil { + t.Errorf("fresh session: %v, want found", err) + } + for name, hash := range map[string][]byte{"idle": idle, "absolute": old} { + if _, err := q.GetSessionByTokenHash(context.Background(), hash); !errors.Is(err, pgx.ErrNoRows) { + t.Errorf("%s-expired session: err = %v, want ErrNoRows", name, err) + } + } + + listed, err := q.ListSessionsForUser(context.Background(), user.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(listed) != 1 { + t.Errorf("listed %d sessions, want only the live one", len(listed)) + } + + swept, err := q.GcDeleteExpiredSessions(context.Background()) + if err != nil { + t.Fatalf("gc: %v", err) + } + if swept != 2 { + t.Errorf("gc swept %d, want the 2 expired rows", swept) + } +} + +// Changing your password signs out every other device and keeps this one. +func TestHandleChangePassword_RevokesOtherSessions(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "hunter2", false) + current := seedSession(t, pool, user.ID, "192.0.2.1") + other := seedSession(t, pool, user.ID, "198.51.100.7") + + body := strings.NewReader(`{"current_password":"hunter2","new_password":"correct-horse"}`) + req := httptest.NewRequest(http.MethodPut, "/api/me/password", body) + req.Header.Set("Content-Type", "application/json") + req = withSession(req, user, current) + w := httptest.NewRecorder() + h.handleChangePassword(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if !sessionExists(t, pool, current) { + t.Error("the device that changed the password was signed out") + } + if sessionExists(t, pool, other) { + t.Error("another device's session survived the password change") + } +} + +// A reset by email ends every session the account has. +func TestHandleResetPassword_RevokesAllSessions(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "hunter2", false) + s1 := seedSession(t, pool, user.ID, "192.0.2.1") + s2 := seedSession(t, pool, user.ID, "198.51.100.7") + + const token = "reset-token-for-session-lifecycle-test" + if _, err := pool.Exec(context.Background(), + `INSERT INTO password_resets (token, user_id, expires_at) VALUES ($1, $2, now() + interval '1 hour')`, + token, user.ID, + ); err != nil { + t.Fatalf("seed reset: %v", err) + } + + body := strings.NewReader(`{"token":"` + token + `","new_password":"correct-horse"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleResetPassword(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if sessionExists(t, pool, s1) || sessionExists(t, pool, s2) { + t.Error("a session survived the password reset") + } +} diff --git a/internal/apierror/apierror.go b/internal/apierror/apierror.go index 3b876499..7d776809 100644 --- a/internal/apierror/apierror.go +++ b/internal/apierror/apierror.go @@ -54,6 +54,10 @@ func Unauthorized(code, message string) *Error { return &Error{Status: 401, Code: code, Message: message} } +func TooManyRequests(code, message string) *Error { + return &Error{Status: 429, Code: code, Message: message} +} + func Internal(cause error) *Error { return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause} } diff --git a/internal/audit/audit.go b/internal/audit/audit.go index ef2c5f60..b9e92c5d 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -60,6 +60,10 @@ const ( // and its history moved onto the copy kept. The metadata names both, so the // log can answer "where did that file go" long after the report is gone. ActionDuplicateMerge Action = "duplicate_merge" + + // Subsonic password (#5026): generated in Settings for t/s-only clients. + ActionSubsonicPasswordSet Action = "subsonic_password_set" + ActionSubsonicPasswordClear Action = "subsonic_password_clear" ) // Write inserts one audit_log row. metadata is marshaled as JSON; diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 84faacd7..46998384 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -169,6 +169,8 @@ func TestWrite_AllActionConstantsArePersisted(t *testing.T) { audit.ActionForgotPasswordInit, audit.ActionPasswordResetByEmail, audit.ActionDuplicateMerge, + audit.ActionSubsonicPasswordSet, + audit.ActionSubsonicPasswordClear, } for _, a := range actions { if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, a, nil); err != nil { diff --git a/internal/auth/clientip.go b/internal/auth/clientip.go index 2be913cb..3b741dc4 100644 --- a/internal/auth/clientip.go +++ b/internal/auth/clientip.go @@ -65,6 +65,36 @@ func ClientIP(r *http.Request, trustedProxyHops int) string { return remote } +// IsHTTPS reports whether the client reached us over HTTPS, reading through +// trustedProxyHops reverse proxies by the same rule as ClientIP. +// +// TLS terminates at the operator's proxy (rule 94), so r.TLS is nil in every +// real deployment and only X-Forwarded-Proto can say what the client used. +// That header is believed only when hops >= 1: with no trusted proxy it is +// just something any client can send. Proxies that append rather than +// overwrite produce a comma list, read positionally like X-Forwarded-For. +// +// This decides only HTTPS-only behaviour (the cookie Secure flag, HSTS). It +// must never drive a redirect: plain-HTTP and LAN use keep working. +func IsHTTPS(r *http.Request, trustedProxyHops int) bool { + if r.TLS != nil { + return true + } + if trustedProxyHops <= 0 { + return false + } + raw := r.Header.Get("X-Forwarded-Proto") + if strings.TrimSpace(raw) == "" { + return false + } + parts := strings.Split(raw, ",") + idx := len(parts) - trustedProxyHops + if idx < 0 { + idx = 0 + } + return strings.EqualFold(strings.TrimSpace(parts[idx]), "https") +} + // forwardedChain returns the X-Forwarded-For entries in wire order, or the // single X-Real-IP value when XFF is absent. // diff --git a/internal/auth/clientip_test.go b/internal/auth/clientip_test.go index fba50fb9..50a330d5 100644 --- a/internal/auth/clientip_test.go +++ b/internal/auth/clientip_test.go @@ -1,6 +1,7 @@ package auth import ( + "crypto/tls" "net/http" "testing" ) @@ -168,3 +169,45 @@ func TestClientIP(t *testing.T) { }) } } + +func TestIsHTTPS(t *testing.T) { + tests := []struct { + name string + hops int + proto string + tls bool + want bool + }{ + // The three cases rule 94 names. + {name: "hops 0 ignores a forwarded https", hops: 0, proto: "https", want: false}, + {name: "hops 1 believes a forwarded https", hops: 1, proto: "https", want: true}, + {name: "plain request is not https", hops: 1, want: false}, + + {name: "forwarded http is not https", hops: 1, proto: "http", want: false}, + {name: "case-insensitive", hops: 1, proto: "HTTPS", want: true}, + {name: "direct TLS is https whatever the hops", hops: 0, tls: true, want: true}, + // An appending chain is read positionally: with one trusted hop the + // last entry is our proxy's word, and a client-supplied first entry + // cannot flip it. + {name: "appended chain reads our proxy's entry", hops: 1, proto: "https, http", want: false}, + {name: "two hops read the CDN's entry", hops: 2, proto: "https, http", want: true}, + {name: "over-counted hops clamp to the first entry", hops: 5, proto: "https", want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r, err := http.NewRequest(http.MethodGet, "/api/auth/login", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + if tc.proto != "" { + r.Header.Set("X-Forwarded-Proto", tc.proto) + } + if tc.tls { + r.TLS = &tls.ConnectionState{} + } + if got := IsHTTPS(r, tc.hops); got != tc.want { + t.Errorf("IsHTTPS(hops=%d, proto=%q) = %v, want %v", tc.hops, tc.proto, got, tc.want) + } + }) + } +} diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 00000000..849aad73 --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,205 @@ +package auth + +import ( + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// AttemptLimiter caps how many attempts a key may make inside a fixed +// window. It is the throttle in front of every password-shaped check: +// native login, register, forgot/reset password and Subsonic /rest auth. +// +// In memory on purpose. Minstrel is a single process, the counts only need +// to outlive a guessing run rather than a restart, and a table would put a +// write on every failed login. Each key costs one small struct, and expired +// keys are swept as the map grows, so a spray across many addresses cannot +// hold memory past one window. +type AttemptLimiter struct { + max int + window time.Duration + now func() time.Time + + mu sync.Mutex + buckets map[string]*attemptBucket + nextSweep int +} + +type attemptBucket struct { + count int + start time.Time +} + +// sweepFloor is the map size below which expired keys are left in place; +// past it, a sweep runs whenever the map doubles from its last swept size. +const sweepFloor = 1024 + +// NewAttemptLimiter returns a limiter allowing max attempts per key per +// window. +func NewAttemptLimiter(max int, window time.Duration) *AttemptLimiter { + return &AttemptLimiter{ + max: max, + window: window, + now: time.Now, + buckets: map[string]*attemptBucket{}, + nextSweep: sweepFloor, + } +} + +// Blocked reports whether key has used its attempts for the current window, +// and if so how long until the window resets. It records nothing, so a check +// can run before the expensive work and the outcome be recorded after. +func (l *AttemptLimiter) Blocked(key string) (bool, time.Duration) { + if l == nil || key == "" { + return false, 0 + } + l.mu.Lock() + defer l.mu.Unlock() + b, ok := l.buckets[key] + if !ok { + return false, 0 + } + now := l.now() + if now.Sub(b.start) >= l.window { + delete(l.buckets, key) + return false, 0 + } + if b.count < l.max { + return false, 0 + } + return true, b.start.Add(l.window).Sub(now) +} + +// Record counts one attempt against key. +func (l *AttemptLimiter) Record(key string) { + if l == nil || key == "" { + return + } + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + b, ok := l.buckets[key] + if !ok || now.Sub(b.start) >= l.window { + l.buckets[key] = &attemptBucket{count: 1, start: now} + l.maybeSweep(now) + return + } + b.count++ +} + +// Reset forgets key, as after a successful login: the user who finally got +// their password right should not carry their typos into the next window. +func (l *AttemptLimiter) Reset(key string) { + if l == nil || key == "" { + return + } + l.mu.Lock() + defer l.mu.Unlock() + delete(l.buckets, key) +} + +// maybeSweep drops expired buckets once the map has doubled since the last +// sweep. Callers hold l.mu. +func (l *AttemptLimiter) maybeSweep(now time.Time) { + if len(l.buckets) < l.nextSweep { + return + } + for k, b := range l.buckets { + if now.Sub(b.start) >= l.window { + delete(l.buckets, k) + } + } + l.nextSweep = max(sweepFloor, 2*len(l.buckets)) +} + +// LoginGuard pairs a per-account and a per-address limiter, the shape every +// password check uses. The account limit stops a slow guess at one user from +// many addresses; the address limit stops one address spraying many users. +// Only failures are recorded, so a user who signs in correctly is never +// counted at all. +type LoginGuard struct { + account *AttemptLimiter + address *AttemptLimiter +} + +// Login limits: 10 failures per account and 50 per address per 15 minutes, +// the same numbers ThoughtSync settled on. Generous enough that a user +// fumbling a password manager never meets them, tight enough that an online +// guess against bcrypt gets ~1,000 tries a day per account. +const ( + loginWindow = 15 * time.Minute + loginAccountMax = 10 + loginAddressMax = 50 +) + +// NewLoginGuard returns a guard with the default login limits. +func NewLoginGuard() *LoginGuard { + return &LoginGuard{ + account: NewAttemptLimiter(loginAccountMax, loginWindow), + address: NewAttemptLimiter(loginAddressMax, loginWindow), + } +} + +// Blocked reports whether either the account or the address is over its +// limit, with the longer of the two waits. Check it BEFORE verifying the +// password, so a blocked guess costs no bcrypt. +func (g *LoginGuard) Blocked(account, address string) (bool, time.Duration) { + if g == nil { + return false, 0 + } + aBlocked, aWait := g.account.Blocked(accountKey(account)) + ipBlocked, ipWait := g.address.Blocked(address) + return aBlocked || ipBlocked, max(aWait, ipWait) +} + +// Fail records a failed attempt against both the account and the address. +// An unknown username counts against its name all the same, so the limit +// gives away nothing about which accounts exist. +func (g *LoginGuard) Fail(account, address string) { + if g == nil { + return + } + g.account.Record(accountKey(account)) + g.address.Record(address) +} + +// Succeed clears the account's failures. The address keeps its count: one +// address that guessed fifty accounts and got one right is still spraying. +func (g *LoginGuard) Succeed(account string) { + if g == nil { + return + } + g.account.Reset(accountKey(account)) +} + +// accountKey folds case so "Admin" and "admin" share one budget. Usernames +// are compared exactly by the lookup, but the guesser shouldn't get a fresh +// allowance per capitalisation. +func accountKey(account string) string { + return strings.ToLower(strings.TrimSpace(account)) +} + +var ( + dummyHashOnce sync.Once + dummyHash []byte +) + +// DummyVerify spends the same bcrypt time a real password check would, for +// the path where the username doesn't exist. Without it, an unknown user +// answers in microseconds and a known one in ~50ms, and the uniform error +// message hides nothing. +func DummyVerify(plaintext string) { + dummyHashOnce.Do(func() { + // What the hash is of doesn't matter — no account carries it. Its + // cost does: DefaultCost, the same as every stored password. + h, err := bcrypt.GenerateFromPassword([]byte("minstrel-dummy-password"), bcrypt.DefaultCost) + if err == nil { + dummyHash = h + } + }) + if dummyHash != nil { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(plaintext)) + } +} diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go new file mode 100644 index 00000000..6560fb74 --- /dev/null +++ b/internal/auth/ratelimit_test.go @@ -0,0 +1,114 @@ +package auth + +import ( + "testing" + "time" +) + +// fakeClock lets a test step through a window without sleeping. +type fakeClock struct{ t time.Time } + +func (c *fakeClock) now() time.Time { return c.t } + +func newTestLimiter(max int, window time.Duration) (*AttemptLimiter, *fakeClock) { + c := &fakeClock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} + l := NewAttemptLimiter(max, window) + l.now = c.now + return l, c +} + +func TestAttemptLimiter_BlocksAtMaxAndReportsWait(t *testing.T) { + l, c := newTestLimiter(3, 15*time.Minute) + for i := 0; i < 3; i++ { + if blocked, _ := l.Blocked("k"); blocked { + t.Fatalf("blocked after %d attempts, want allowed below max", i) + } + l.Record("k") + } + c.t = c.t.Add(5 * time.Minute) + blocked, wait := l.Blocked("k") + if !blocked { + t.Fatal("not blocked after max attempts") + } + if wait != 10*time.Minute { + t.Errorf("wait = %v, want the 10m left in the window", wait) + } +} + +func TestAttemptLimiter_WindowExpiryClears(t *testing.T) { + l, c := newTestLimiter(1, time.Minute) + l.Record("k") + if blocked, _ := l.Blocked("k"); !blocked { + t.Fatal("want blocked inside the window") + } + c.t = c.t.Add(time.Minute) + if blocked, _ := l.Blocked("k"); blocked { + t.Fatal("still blocked once the window has passed") + } +} + +func TestAttemptLimiter_KeysAreIndependentAndResetClears(t *testing.T) { + l, _ := newTestLimiter(1, time.Minute) + l.Record("a") + if blocked, _ := l.Blocked("b"); blocked { + t.Fatal("one key's attempts blocked another") + } + l.Reset("a") + if blocked, _ := l.Blocked("a"); blocked { + t.Fatal("Reset did not clear the key") + } +} + +func TestAttemptLimiter_SweepDropsExpiredKeys(t *testing.T) { + l, c := newTestLimiter(5, time.Minute) + // One short of the floor: no sweep yet, however stale these become. + for i := 0; i < sweepFloor-1; i++ { + l.Record("k" + time.Duration(i).String()) + } + c.t = c.t.Add(2 * time.Minute) + l.Record("fresh") // reaches the floor and triggers a sweep + if n := len(l.buckets); n != 1 { + t.Errorf("buckets after sweep = %d, want only the fresh key", n) + } +} + +func TestAttemptLimiter_NilAndEmptyKeyAreNoOps(t *testing.T) { + var l *AttemptLimiter + l.Record("k") + if blocked, _ := l.Blocked("k"); blocked { + t.Error("nil limiter blocked") + } + real, _ := newTestLimiter(1, time.Minute) + real.Record("") + if blocked, _ := real.Blocked(""); blocked { + t.Error("empty key was counted") + } +} + +func TestLoginGuard_AccountLimitSpansAddressesAndFoldsCase(t *testing.T) { + g := NewLoginGuard() + for i := 0; i < loginAccountMax; i++ { + g.Fail("Alice", "10.0.0."+string(rune('0'+i%10))) + } + if blocked, _ := g.Blocked("alice", "192.0.2.1"); !blocked { + t.Fatal("account limit should hold from a fresh address and any capitalisation") + } + g.Succeed("ALICE") + if blocked, _ := g.Blocked("alice", "192.0.2.1"); blocked { + t.Fatal("Succeed should clear the account's failures") + } +} + +func TestLoginGuard_AddressLimitSpansAccounts(t *testing.T) { + g := NewLoginGuard() + for i := 0; i < loginAddressMax; i++ { + g.Fail("user"+time.Duration(i).String(), "203.0.113.9") + } + if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked { + t.Fatal("address that sprayed many accounts should be blocked") + } + g.Succeed("someone-new") + if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked { + t.Fatal("a success must not clear the address count") + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go index 49eda982..8f2ecdfa 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/hex" "errors" "log/slog" "net/http" @@ -41,6 +42,15 @@ func HashSessionToken(token string) []byte { return sum[:] } +// HashAPIToken maps a raw API key (the OpenSubsonic apiKey) to the +// `users.api_token_hash` column: sha256, hex. The key is minted with +// MintSessionToken, so it carries the same 256 bits and the same reasoning +// as HashSessionToken applies. Hex rather than bytes so the column stays +// text and a migration can compute it in SQL from the old plaintext. +func HashAPIToken(token string) string { + return hex.EncodeToString(HashSessionToken(token)) +} + // 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. diff --git a/internal/auth/setup.go b/internal/auth/setup.go new file mode 100644 index 00000000..9d576be7 --- /dev/null +++ b/internal/auth/setup.go @@ -0,0 +1,46 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" +) + +// SetupToken guards the first-admin registration. Until the first account +// exists, register makes whoever calls it the admin, so a fresh instance on a +// public address belonged to whoever found it first. Now that call also has +// to carry this token, which is generated at startup and written only to the +// server log: proof that the caller can read the operator's logs. +// +// The token lives in memory. A restart mints a new one and logs it again, +// which is the behaviour wanted: an old token from a log line someone else +// saw stops working. +type SetupToken struct { + value string +} + +// NewSetupToken mints a 128-bit token. +func NewSetupToken() (*SetupToken, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return &SetupToken{value: hex.EncodeToString(b)}, nil +} + +// Value returns the token for logging. +func (t *SetupToken) Value() string { + if t == nil { + return "" + } + return t.value +} + +// Matches reports whether supplied is the token, in constant time. A nil +// token never matches anything, so a missing token fails closed. +func (t *SetupToken) Matches(supplied string) bool { + if t == nil || t.value == "" || supplied == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(t.value), []byte(supplied)) == 1 +} diff --git a/internal/coplay/worker_integration_test.go b/internal/coplay/worker_integration_test.go index 72737eaf..e38475f0 100644 --- a/internal/coplay/worker_integration_test.go +++ b/internal/coplay/worker_integration_test.go @@ -42,7 +42,7 @@ func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Disca func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID { t.Helper() u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false, + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-tok", IsAdmin: false, }) if err != nil { t.Fatalf("seed user %s: %v", name, err) diff --git a/internal/db/dbq/gc.sql.go b/internal/db/dbq/gc.sql.go index cb150262..957a6ef5 100644 --- a/internal/db/dbq/gc.sql.go +++ b/internal/db/dbq/gc.sql.go @@ -84,6 +84,22 @@ func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, err return result.RowsAffected(), nil } +const gcDeleteExpiredSessions = `-- name: GcDeleteExpiredSessions :execrows +DELETE FROM sessions +WHERE last_seen_at <= now() - interval '30 days' + OR created_at <= now() - interval '365 days' +` + +// Sessions past their idle (30 days) or absolute (1 year) limit. They already +// fail auth through GetSessionByTokenHash's filter; this only clears the rows. +func (q *Queries) GcDeleteExpiredSessions(ctx context.Context) (int64, error) { + result, err := q.db.Exec(ctx, gcDeleteExpiredSessions) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows DELETE FROM scrobble_queue WHERE status = 'failed' diff --git a/internal/db/dbq/loudness.sql.go b/internal/db/dbq/loudness.sql.go new file mode 100644 index 00000000..59d3c143 --- /dev/null +++ b/internal/db/dbq/loudness.sql.go @@ -0,0 +1,498 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: loudness.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteAlbumLoudness = `-- name: DeleteAlbumLoudness :exec +DELETE FROM album_loudness WHERE album_id = ANY($1::uuid[]) +` + +func (q *Queries) DeleteAlbumLoudness(ctx context.Context, albumIds []pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteAlbumLoudness, albumIds) + return err +} + +const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec +DELETE FROM track_loudness WHERE track_id = $1 +` + +// The scan saw new bytes at this path. The stored measurement describes the old +// ones, so it goes, and the backfill measures the file again. +func (q *Queries) DeleteTrackLoudness(ctx context.Context, trackID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTrackLoudness, trackID) + return err +} + +const getAlbumLoudness = `-- name: GetAlbumLoudness :one +SELECT integrated_lufs, true_peak_dbtp FROM album_loudness WHERE album_id = $1 +` + +type GetAlbumLoudnessRow struct { + IntegratedLufs *float32 + TruePeakDbtp *float32 +} + +// The stored album values, read before a recompute so a sync change is +// logged only when they actually move. +func (q *Queries) GetAlbumLoudness(ctx context.Context, albumID pgtype.UUID) (GetAlbumLoudnessRow, error) { + row := q.db.QueryRow(ctx, getAlbumLoudness, albumID) + var i GetAlbumLoudnessRow + err := row.Scan(&i.IntegratedLufs, &i.TruePeakDbtp) + return i, err +} + +const getAlbumLoudnessByIDs = `-- name: GetAlbumLoudnessByIDs :many +SELECT album_id, integrated_lufs, true_peak_dbtp + FROM album_loudness + WHERE album_id = ANY($1::uuid[]) +` + +type GetAlbumLoudnessByIDsRow struct { + AlbumID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 +} + +func (q *Queries) GetAlbumLoudnessByIDs(ctx context.Context, ids []pgtype.UUID) ([]GetAlbumLoudnessByIDsRow, error) { + rows, err := q.db.Query(ctx, getAlbumLoudnessByIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAlbumLoudnessByIDsRow + for rows.Next() { + var i GetAlbumLoudnessByIDsRow + if err := rows.Scan(&i.AlbumID, &i.IntegratedLufs, &i.TruePeakDbtp); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLoudnessCoverage = `-- name: GetLoudnessCoverage :one +SELECT count(*)::bigint AS total, + count(*) FILTER ( + WHERE l.analysis_version >= $1 + AND l.integrated_lufs IS NOT NULL + )::bigint AS measured, + count(*) FILTER ( + WHERE l.analysis_version >= $1 + AND l.integrated_lufs IS NULL AND NOT l.unreadable + )::bigint AS silent, + count(*) FILTER ( + WHERE l.analysis_version >= $1 + AND l.unreadable + )::bigint AS unreadable, + count(*) FILTER ( + WHERE l.track_id IS NULL OR l.analysis_version < $1 + )::bigint AS pending + FROM tracks t + LEFT JOIN track_loudness l ON l.track_id = t.id + WHERE t.missing_since IS NULL +` + +type GetLoudnessCoverageRow struct { + Total int64 + Measured int64 + Silent int64 + Unreadable int64 + Pending int64 +} + +// The admin gauge. measured + silent + unreadable + pending = total. Missing +// tracks are excluded, or the gauge could never reach the end. +func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16) (GetLoudnessCoverageRow, error) { + row := q.db.QueryRow(ctx, getLoudnessCoverage, currentVersion) + var i GetLoudnessCoverageRow + err := row.Scan( + &i.Total, + &i.Measured, + &i.Silent, + &i.Unreadable, + &i.Pending, + ) + return i, err +} + +const getLoudnessSettings = `-- name: GetLoudnessSettings :one +SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true +` + +func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) { + row := q.db.QueryRow(ctx, getLoudnessSettings) + var i LoudnessSetting + err := row.Scan( + &i.ID, + &i.Enabled, + &i.BackfillConcurrency, + &i.UpdatedAt, + ) + return i, err +} + +const getReplayGainByTrackIDs = `-- name: GetReplayGainByTrackIDs :many +SELECT t.id, + tl.integrated_lufs AS track_lufs, + tl.true_peak_dbtp AS track_peak_dbtp, + al.integrated_lufs AS album_lufs, + al.true_peak_dbtp AS album_peak_dbtp + FROM tracks t + LEFT JOIN track_loudness tl ON tl.track_id = t.id + LEFT JOIN album_loudness al ON al.album_id = t.album_id + WHERE t.id = ANY($1::uuid[]) +` + +type GetReplayGainByTrackIDsRow struct { + ID pgtype.UUID + TrackLufs *float32 + TrackPeakDbtp *float32 + AlbumLufs *float32 + AlbumPeakDbtp *float32 +} + +// Track and album loudness for a set of tracks, for every surface that hands +// gains to a client (#4997). A measurement from an older analysis version is +// still delivered: it is a better gain than none until the backfill redoes it. +func (q *Queries) GetReplayGainByTrackIDs(ctx context.Context, ids []pgtype.UUID) ([]GetReplayGainByTrackIDsRow, error) { + rows, err := q.db.Query(ctx, getReplayGainByTrackIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetReplayGainByTrackIDsRow + for rows.Next() { + var i GetReplayGainByTrackIDsRow + if err := rows.Scan( + &i.ID, + &i.TrackLufs, + &i.TrackPeakDbtp, + &i.AlbumLufs, + &i.AlbumPeakDbtp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumLoudnessInputs = `-- name: ListAlbumLoudnessInputs :many +SELECT t.id, + (l.track_id IS NOT NULL)::boolean AS settled, + l.true_peak_dbtp, + l.block_hist_start, + l.block_hist + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= $1 + WHERE t.album_id = $2 + AND t.missing_since IS NULL +` + +type ListAlbumLoudnessInputsParams struct { + CurrentVersion int16 + AlbumID pgtype.UUID +} + +type ListAlbumLoudnessInputsRow struct { + ID pgtype.UUID + Settled bool + TruePeakDbtp *float32 + BlockHistStart *int16 + BlockHist []int32 +} + +// Every present track on one album with its current measurement, if any. +// settled is false for a track not yet measured at the current version. +func (q *Queries) ListAlbumLoudnessInputs(ctx context.Context, arg ListAlbumLoudnessInputsParams) ([]ListAlbumLoudnessInputsRow, error) { + rows, err := q.db.Query(ctx, listAlbumLoudnessInputs, arg.CurrentVersion, arg.AlbumID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumLoudnessInputsRow + for rows.Next() { + var i ListAlbumLoudnessInputsRow + if err := rows.Scan( + &i.ID, + &i.Settled, + &i.TruePeakDbtp, + &i.BlockHistStart, + &i.BlockHist, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumsNeedingLoudness = `-- name: ListAlbumsNeedingLoudness :many +WITH present AS ( + SELECT t.album_id, + md5(string_agg( + t.id::text || ':' || coalesce( + l.analysis_version::text || '@' || l.analyzed_at::text, '-'), + ',' ORDER BY t.id)) AS digest + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= $3::smallint + WHERE t.missing_since IS NULL + GROUP BY t.album_id +) +SELECT c.album_id, c.digest::text AS digest + FROM present c + LEFT JOIN album_loudness a ON a.album_id = c.album_id + WHERE (a.album_id IS NULL OR a.inputs_digest <> c.digest) + -- Casts: sqlc cannot infer a parameter's type through a CTE alias. + AND c.album_id > $1::uuid + ORDER BY c.album_id + LIMIT $2::integer +` + +type ListAlbumsNeedingLoudnessParams struct { + AfterID pgtype.UUID + BatchLimit int32 + CurrentVersion int16 +} + +type ListAlbumsNeedingLoudnessRow struct { + AlbumID pgtype.UUID + Digest string +} + +// The album pass's work queue (#4996): albums whose present tracks or their +// measurements have changed since album loudness was last computed, or that +// never had it. The digest is over every present track's id and the +// measurement it holds at the current version ('-' for none), so a track +// joining, leaving or being re-measured changes it. Keyset-paged on album id +// so a pass ends even if storing one album keeps failing. +func (q *Queries) ListAlbumsNeedingLoudness(ctx context.Context, arg ListAlbumsNeedingLoudnessParams) ([]ListAlbumsNeedingLoudnessRow, error) { + rows, err := q.db.Query(ctx, listAlbumsNeedingLoudness, arg.AfterID, arg.BatchLimit, arg.CurrentVersion) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumsNeedingLoudnessRow + for rows.Next() { + var i ListAlbumsNeedingLoudnessRow + if err := rows.Scan(&i.AlbumID, &i.Digest); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listOrphanAlbumLoudness = `-- name: ListOrphanAlbumLoudness :many +SELECT a.album_id + FROM album_loudness a + WHERE NOT EXISTS ( + SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL + ) +` + +// Album rows whose album has no present track left (every file missing). The +// album row itself survives a missing file, so its loudness would otherwise +// stay behind describing tracks that are gone; it is recomputed if they return. +func (q *Queries) ListOrphanAlbumLoudness(ctx context.Context) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listOrphanAlbumLoudness) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var album_id pgtype.UUID + if err := rows.Scan(&album_id); err != nil { + return nil, err + } + items = append(items, album_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTracksNeedingLoudness = `-- name: ListTracksNeedingLoudness :many +SELECT t.id, t.file_path, t.duration_ms + FROM tracks t + LEFT JOIN track_loudness l ON l.track_id = t.id + WHERE t.missing_since IS NULL + AND (l.track_id IS NULL OR l.analysis_version < $1) + AND t.id > $2 + ORDER BY t.id + LIMIT $3 +` + +type ListTracksNeedingLoudnessParams struct { + CurrentVersion int16 + AfterID pgtype.UUID + BatchLimit int32 +} + +type ListTracksNeedingLoudnessRow struct { + ID pgtype.UUID + FilePath string + DurationMs int32 +} + +// The backfill's work queue: tracks with no measurement, or one taken by an +// older method. Keyset-paged on id so a pass visits each track at most once; +// an inconclusive attempt writes no row, and without the cursor a file that +// keeps timing out would be listed again straight away. Missing tracks are +// skipped: there is no file to read. +func (q *Queries) ListTracksNeedingLoudness(ctx context.Context, arg ListTracksNeedingLoudnessParams) ([]ListTracksNeedingLoudnessRow, error) { + rows, err := q.db.Query(ctx, listTracksNeedingLoudness, arg.CurrentVersion, arg.AfterID, arg.BatchLimit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTracksNeedingLoudnessRow + for rows.Next() { + var i ListTracksNeedingLoudnessRow + if err := rows.Scan(&i.ID, &i.FilePath, &i.DurationMs); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one +UPDATE loudness_settings + SET enabled = $1, + backfill_concurrency = $2, + updated_at = now() + WHERE id = true +RETURNING id, enabled, backfill_concurrency, updated_at +` + +type UpdateLoudnessSettingsParams struct { + Enabled bool + BackfillConcurrency int32 +} + +// Whole-row write from the admin card; migration 0065's CHECK is the backstop +// behind the service's own validation. +func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) { + row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency) + var i LoudnessSetting + err := row.Scan( + &i.ID, + &i.Enabled, + &i.BackfillConcurrency, + &i.UpdatedAt, + ) + return i, err +} + +const upsertAlbumLoudness = `-- name: UpsertAlbumLoudness :exec +INSERT INTO album_loudness ( + album_id, integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled, inputs_digest +) VALUES ( + $1, $2, $3, + $4, $5, $6 +) +ON CONFLICT (album_id) DO UPDATE SET + integrated_lufs = EXCLUDED.integrated_lufs, + true_peak_dbtp = EXCLUDED.true_peak_dbtp, + tracks_total = EXCLUDED.tracks_total, + tracks_settled = EXCLUDED.tracks_settled, + inputs_digest = EXCLUDED.inputs_digest, + computed_at = now() +` + +type UpsertAlbumLoudnessParams struct { + AlbumID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 + TracksTotal int32 + TracksSettled int32 + InputsDigest string +} + +func (q *Queries) UpsertAlbumLoudness(ctx context.Context, arg UpsertAlbumLoudnessParams) error { + _, err := q.db.Exec(ctx, upsertAlbumLoudness, + arg.AlbumID, + arg.IntegratedLufs, + arg.TruePeakDbtp, + arg.TracksTotal, + arg.TracksSettled, + arg.InputsDigest, + ) + return err +} + +const upsertTrackLoudness = `-- name: UpsertTrackLoudness :exec +INSERT INTO track_loudness ( + track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu, + block_hist_start, block_hist, unreadable, analysis_version +) VALUES ( + $1, $2, $3, + $4, $5, $6, + $7, $8 +) +ON CONFLICT (track_id) DO UPDATE SET + integrated_lufs = EXCLUDED.integrated_lufs, + true_peak_dbtp = EXCLUDED.true_peak_dbtp, + loudness_range_lu = EXCLUDED.loudness_range_lu, + block_hist_start = EXCLUDED.block_hist_start, + block_hist = EXCLUDED.block_hist, + unreadable = EXCLUDED.unreadable, + analysis_version = EXCLUDED.analysis_version, + analyzed_at = now() +` + +type UpsertTrackLoudnessParams struct { + TrackID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 + LoudnessRangeLu *float32 + BlockHistStart *int16 + BlockHist []int32 + Unreadable bool + AnalysisVersion int16 +} + +// Written when the backfill measures a track (#4995). Replaces the row +// wholesale: a measurement of the old bytes has no standing once the file has +// changed. +func (q *Queries) UpsertTrackLoudness(ctx context.Context, arg UpsertTrackLoudnessParams) error { + _, err := q.db.Exec(ctx, upsertTrackLoudness, + arg.TrackID, + arg.IntegratedLufs, + arg.TruePeakDbtp, + arg.LoudnessRangeLu, + arg.BlockHistStart, + arg.BlockHist, + arg.Unreadable, + arg.AnalysisVersion, + ) + return err +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 17a9c89d..fd55ee9c 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -201,6 +201,16 @@ type Album struct { CoverArtSourcesVersion int32 } +type AlbumLoudness struct { + AlbumID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 + TracksTotal int32 + TracksSettled int32 + InputsDigest string + ComputedAt pgtype.Timestamptz +} + type Artist struct { ID pgtype.UUID Name string @@ -417,6 +427,13 @@ type LidarrRequest struct { LidarrAddConfirmedAt pgtype.Timestamptz } +type LoudnessSetting struct { + ID bool + Enabled bool + BackfillConcurrency int32 + UpdatedAt pgtype.Timestamptz +} + type MissingReacquisition struct { AlbumID pgtype.UUID Attempts int32 @@ -430,6 +447,7 @@ type MissingReacquisition struct { type NetworkSetting struct { ID bool TrustedProxyHops int32 + PublicUrl string } type PasswordReset struct { @@ -712,6 +730,18 @@ type TrackFingerprint struct { ChromaprintLengthSec int32 } +type TrackLoudness struct { + TrackID pgtype.UUID + IntegratedLufs *float32 + TruePeakDbtp *float32 + LoudnessRangeLu *float32 + BlockHistStart *int16 + BlockHist []int32 + Unreadable bool + AnalysisVersion int16 + AnalyzedAt pgtype.Timestamptz +} + type TrackSimilarity struct { TrackAID pgtype.UUID TrackBID pgtype.UUID @@ -730,7 +760,6 @@ type User struct { ID pgtype.UUID Username string PasswordHash string - ApiToken string IsAdmin bool CreatedAt pgtype.Timestamptz SubsonicPassword *string @@ -742,6 +771,7 @@ type User struct { Timezone string TimezoneUpdatedAt pgtype.Timestamptz DebugModeEnabled bool + ApiTokenHash string } type UserInvite struct { diff --git a/internal/db/dbq/network_settings.sql.go b/internal/db/dbq/network_settings.sql.go index 89573cfd..3d31f0f5 100644 --- a/internal/db/dbq/network_settings.sql.go +++ b/internal/db/dbq/network_settings.sql.go @@ -10,23 +10,34 @@ import ( ) const getNetworkSettings = `-- name: GetNetworkSettings :one -SELECT id, trusted_proxy_hops FROM network_settings WHERE id = true +SELECT id, trusted_proxy_hops, public_url FROM network_settings WHERE id = true ` func (q *Queries) GetNetworkSettings(ctx context.Context) (NetworkSetting, error) { row := q.db.QueryRow(ctx, getNetworkSettings) var i NetworkSetting - err := row.Scan(&i.ID, &i.TrustedProxyHops) + err := row.Scan(&i.ID, &i.TrustedProxyHops, &i.PublicUrl) + return i, err +} + +const updatePublicURL = `-- name: UpdatePublicURL :one +UPDATE network_settings SET public_url = $1 WHERE id = true RETURNING id, trusted_proxy_hops, public_url +` + +func (q *Queries) UpdatePublicURL(ctx context.Context, publicUrl string) (NetworkSetting, error) { + row := q.db.QueryRow(ctx, updatePublicURL, publicUrl) + var i NetworkSetting + err := row.Scan(&i.ID, &i.TrustedProxyHops, &i.PublicUrl) return i, err } const updateTrustedProxyHops = `-- name: UpdateTrustedProxyHops :one -UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING id, trusted_proxy_hops +UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING id, trusted_proxy_hops, public_url ` func (q *Queries) UpdateTrustedProxyHops(ctx context.Context, trustedProxyHops int32) (NetworkSetting, error) { row := q.db.QueryRow(ctx, updateTrustedProxyHops, trustedProxyHops) var i NetworkSetting - err := row.Scan(&i.ID, &i.TrustedProxyHops) + err := row.Scan(&i.ID, &i.TrustedProxyHops, &i.PublicUrl) return i, err } diff --git a/internal/db/dbq/sessions.sql.go b/internal/db/dbq/sessions.sql.go index 95c05d84..307af3e7 100644 --- a/internal/db/dbq/sessions.sql.go +++ b/internal/db/dbq/sessions.sql.go @@ -69,10 +69,38 @@ func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionFor return result.RowsAffected(), nil } -const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one -SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1 +const deleteSessionsForUser = `-- name: DeleteSessionsForUser :execrows +DELETE FROM sessions WHERE user_id = $1 ` +// Every session the user has, the caller's included. A password reset uses +// it: whoever forgot the password is not signed in anywhere they trust, and +// whoever may have learned it must be signed out everywhere. +func (q *Queries) DeleteSessionsForUser(ctx context.Context, userID pgtype.UUID) (int64, error) { + result, err := q.db.Exec(ctx, deleteSessionsForUser, userID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one +SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions +WHERE token_hash = $1 + AND last_seen_at > now() - interval '30 days' + AND created_at > now() - interval '365 days' +` + +// Expired sessions are invisible here, so they fail auth the moment they +// lapse rather than whenever the GC sweep next runs. Two limits: +// +// idle 30 days — a token nobody has used in a month is abandoned, and +// matches the web cookie's lifetime; +// absolute 1 year — even a token in daily use is re-issued yearly, so a +// stolen one that is being used quietly does not live +// forever. +// +// Keep in step with ListSessionsForUser and GcDeleteExpiredSessions. func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) { row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash) var i Session @@ -127,11 +155,17 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S } const listSessionsForUser = `-- name: ListSessionsForUser :many -SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC +SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions +WHERE user_id = $1 + AND last_seen_at > now() - interval '30 days' + AND created_at > now() - interval '365 days' +ORDER BY last_seen_at DESC ` // Most-recently-active first: the row a user is most likely to act on is the // one that moved last, and an unfamiliar entry at the top is the alarm. +// Expired rows are left out: they no longer authenticate, so listing them +// as active would be wrong. Same limits as GetSessionByTokenHash. func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) { rows, err := q.db.Query(ctx, listSessionsForUser, userID) if err != nil { diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 3ffe3499..0c1173c5 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -52,15 +52,15 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { } const createUser = `-- name: CreateUser :one -INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash ` type CreateUserParams struct { Username string PasswordHash string - ApiToken string + ApiTokenHash string IsAdmin bool DisplayName *string } @@ -69,7 +69,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e row := q.db.QueryRow(ctx, createUser, arg.Username, arg.PasswordHash, - arg.ApiToken, + arg.ApiTokenHash, arg.IsAdmin, arg.DisplayName, ) @@ -78,7 +78,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -90,20 +89,21 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } const createUserAdmin = `-- name: CreateUserAdmin :one -INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash ` type CreateUserAdminParams struct { Username string PasswordHash string - ApiToken string + ApiTokenHash string IsAdmin bool DisplayName *string } @@ -115,7 +115,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams row := q.db.QueryRow(ctx, createUserAdmin, arg.Username, arg.PasswordHash, - arg.ApiToken, + arg.ApiTokenHash, arg.IsAdmin, arg.DisplayName, ) @@ -124,7 +124,6 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -136,24 +135,25 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } const createUserFirstAdminRace = `-- name: CreateUserFirstAdminRace :one -INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name) VALUES ( $1, $2, $3, (SELECT NOT EXISTS (SELECT 1 FROM users)), $4 ) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash ` type CreateUserFirstAdminRaceParams struct { Username string PasswordHash string - ApiToken string + ApiTokenHash string DisplayName *string } @@ -174,7 +174,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi row := q.db.QueryRow(ctx, createUserFirstAdminRace, arg.Username, arg.PasswordHash, - arg.ApiToken, + arg.ApiTokenHash, arg.DisplayName, ) var i User @@ -182,7 +182,6 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -194,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } @@ -240,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge } const getOldestAdmin = `-- name: GetOldestAdmin :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users +SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE is_admin = true ORDER BY created_at, id LIMIT 1 @@ -260,7 +260,6 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) { &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -272,22 +271,22 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) { &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } -const getUserByAPIToken = `-- name: GetUserByAPIToken :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1 +const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one +SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE api_token_hash = $1 ` -func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { - row := q.db.QueryRow(ctx, getUserByAPIToken, apiToken) +func (q *Queries) GetUserByAPITokenHash(ctx context.Context, apiTokenHash string) (User, error) { + row := q.db.QueryRow(ctx, getUserByAPITokenHash, apiTokenHash) var i User err := row.Scan( &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -299,12 +298,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1) +SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE lower(email) = lower($1) ` // Used by forgot-password lookup. Lowercase comparison both sides @@ -317,7 +317,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -329,12 +328,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1 +SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -344,7 +344,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -356,12 +355,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1 +SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -371,7 +371,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -383,6 +382,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } @@ -471,39 +471,21 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { return items, nil } -const regenerateApiToken = `-- name: RegenerateApiToken :one -UPDATE users SET api_token = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +const regenerateApiToken = `-- name: RegenerateApiToken :exec +UPDATE users SET api_token_hash = $2 WHERE id = $1 ` type RegenerateApiTokenParams struct { - ID pgtype.UUID - ApiToken string + ID pgtype.UUID + ApiTokenHash string } // Self-service: caller wants a new API token. Used by the /settings -// API Token card's "Regenerate" button. -func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) (User, error) { - row := q.db.QueryRow(ctx, regenerateApiToken, arg.ID, arg.ApiToken) - var i User - err := row.Scan( - &i.ID, - &i.Username, - &i.PasswordHash, - &i.ApiToken, - &i.IsAdmin, - &i.CreatedAt, - &i.SubsonicPassword, - &i.ListenbrainzToken, - &i.ListenbrainzEnabled, - &i.DisplayName, - &i.AutoApproveRequests, - &i.Email, - &i.Timezone, - &i.TimezoneUpdatedAt, - &i.DebugModeEnabled, - ) - return i, err +// API Token card's "Regenerate" button. Only the hash is stored; the +// handler returns the raw key once. +func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) error { + _, err := q.db.Exec(ctx, regenerateApiToken, arg.ID, arg.ApiTokenHash) + return err } const resetUserPassword = `-- name: ResetUserPassword :exec @@ -530,7 +512,7 @@ const setDebugMode = `-- name: SetDebugMode :one UPDATE users SET debug_mode_enabled = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash ` type SetDebugModeParams struct { @@ -548,7 +530,6 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -560,6 +541,7 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } @@ -607,7 +589,8 @@ type SetSubsonicPasswordParams struct { } // 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. +// for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only +// ever a server-generated value, never the login password (#5026). func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error { _, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword) return err @@ -617,7 +600,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one UPDATE users SET is_admin = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash ` type UpdateUserAdminParams struct { @@ -634,7 +617,6 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -646,6 +628,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } @@ -654,7 +637,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one UPDATE users SET auto_approve_requests = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash ` type UpdateUserAutoApproveParams struct { @@ -670,7 +653,6 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -682,6 +664,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } @@ -691,7 +674,7 @@ UPDATE users SET display_name = $2, email = $3 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled +RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash ` type UpdateUserProfileParams struct { @@ -709,7 +692,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.ID, &i.Username, &i.PasswordHash, - &i.ApiToken, &i.IsAdmin, &i.CreatedAt, &i.SubsonicPassword, @@ -721,6 +703,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.Timezone, &i.TimezoneUpdatedAt, &i.DebugModeEnabled, + &i.ApiTokenHash, ) return i, err } diff --git a/internal/db/migrations/0062_network_public_url.down.sql b/internal/db/migrations/0062_network_public_url.down.sql new file mode 100644 index 00000000..418a7cd6 --- /dev/null +++ b/internal/db/migrations/0062_network_public_url.down.sql @@ -0,0 +1 @@ +ALTER TABLE network_settings DROP COLUMN IF EXISTS public_url; diff --git a/internal/db/migrations/0062_network_public_url.up.sql b/internal/db/migrations/0062_network_public_url.up.sql new file mode 100644 index 00000000..0d1f6dac --- /dev/null +++ b/internal/db/migrations/0062_network_public_url.up.sql @@ -0,0 +1,8 @@ +-- The address users reach Minstrel at, e.g. https://music.example.com. +-- +-- Password-reset links used to be built from the request's Host header, +-- which the requester controls: a forgot-password call with a forged Host +-- would email the victim a real reset token on a link to the attacker's +-- server. Links are now built only from this operator-set value, and none +-- are sent while it is empty (M462 #4981). +ALTER TABLE network_settings ADD COLUMN public_url text NOT NULL DEFAULT ''; diff --git a/internal/db/migrations/0063_api_token_hash.down.sql b/internal/db/migrations/0063_api_token_hash.down.sql new file mode 100644 index 00000000..8b80a9f4 --- /dev/null +++ b/internal/db/migrations/0063_api_token_hash.down.sql @@ -0,0 +1,7 @@ +-- The keys cannot be recovered from their hashes. Rolling back gives every +-- user a new random key; Subsonic clients using apiKey need the new one. +ALTER TABLE users ADD COLUMN api_token text; +UPDATE users SET api_token = md5(random()::text || id::text || clock_timestamp()::text); +ALTER TABLE users ALTER COLUMN api_token SET NOT NULL; +ALTER TABLE users ADD CONSTRAINT users_api_token_key UNIQUE (api_token); +ALTER TABLE users DROP COLUMN api_token_hash; diff --git a/internal/db/migrations/0063_api_token_hash.up.sql b/internal/db/migrations/0063_api_token_hash.up.sql new file mode 100644 index 00000000..fd161a19 --- /dev/null +++ b/internal/db/migrations/0063_api_token_hash.up.sql @@ -0,0 +1,10 @@ +-- API keys (the OpenSubsonic apiKey) are stored as their sha256, hex, the +-- same way session tokens are. A leaked database row or backup no longer +-- hands out working keys. Existing keys are hashed in place, so every +-- Subsonic client keeps working; the key itself can no longer be shown +-- again, only replaced (M462 #4983). +ALTER TABLE users ADD COLUMN api_token_hash text; +UPDATE users SET api_token_hash = encode(sha256(convert_to(api_token, 'UTF8')), 'hex'); +ALTER TABLE users ALTER COLUMN api_token_hash SET NOT NULL; +ALTER TABLE users ADD CONSTRAINT users_api_token_hash_key UNIQUE (api_token_hash); +ALTER TABLE users DROP COLUMN api_token; diff --git a/internal/db/migrations/0064_clear_subsonic_password.down.sql b/internal/db/migrations/0064_clear_subsonic_password.down.sql new file mode 100644 index 00000000..98a094aa --- /dev/null +++ b/internal/db/migrations/0064_clear_subsonic_password.down.sql @@ -0,0 +1,2 @@ +-- The cleared values are gone and were never meant to be kept; nothing to undo. +SELECT 1; diff --git a/internal/db/migrations/0064_clear_subsonic_password.up.sql b/internal/db/migrations/0064_clear_subsonic_password.up.sql new file mode 100644 index 00000000..ab83c2b4 --- /dev/null +++ b/internal/db/migrations/0064_clear_subsonic_password.up.sql @@ -0,0 +1,10 @@ +-- `minstrel admin reset-password` used to copy the new login password into +-- subsonic_password, so every account recovered through the CLI had its login +-- password stored in plain text, and a later password change in Settings left +-- that copy behind (M462 #5026). The CLI no longer writes this column; a +-- Subsonic password is now generated separately in Settings and is never the +-- login password. Clearing every value here removes the copies already made. +-- +-- Accounts whose Subsonic client signs in with t/s stop working until the user +-- generates a Subsonic password (or switches the client to an API key). +UPDATE users SET subsonic_password = NULL WHERE subsonic_password IS NOT NULL; diff --git a/internal/db/migrations/0065_track_loudness.down.sql b/internal/db/migrations/0065_track_loudness.down.sql new file mode 100644 index 00000000..0bb9eced --- /dev/null +++ b/internal/db/migrations/0065_track_loudness.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS loudness_settings; +DROP TABLE IF EXISTS track_loudness; diff --git a/internal/db/migrations/0065_track_loudness.up.sql b/internal/db/migrations/0065_track_loudness.up.sql new file mode 100644 index 00000000..ae039dc6 --- /dev/null +++ b/internal/db/migrations/0065_track_loudness.up.sql @@ -0,0 +1,65 @@ +-- 0065_track_loudness.up.sql — measured loudness per track, for loudness +-- normalization (Scribe milestone #464, #4995). +-- +-- Measured with ffmpeg's EBU R128 filter rather than read from ReplayGain tags: +-- tags in the wild are written against four different reference levels, and +-- most files have none. internal/library/loudness.go says how it is measured. +-- +-- A table of its own rather than columns on tracks, for the reason +-- track_fingerprints is (0058): tracks is read with SELECT * on the hot path, +-- and the block histogram is a few hundred integers only album loudness reads. +-- +-- What a row means, which the backfill depends on: +-- no row never analyzed, or the file changed since +-- analysis_version < current measured by an older method; measure again +-- analysis_version = current settled until the file changes: +-- integrated_lufs NOT NULL measured +-- integrated_lufs NULL, unreadable false +-- read fine, but no 400 ms block was above +-- the -70 LUFS gate: silence, or too short +-- unreadable true ffmpeg could not decode the file +-- A failure that says nothing about the file (a timeout, a cancelled pass, a +-- missing ffmpeg) writes no row, so the backfill tries again. +CREATE TABLE track_loudness ( + track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE, + -- Gated integrated loudness (ITU-R BS.1770), mono measured as dual mono. + integrated_lufs real, + -- Highest inter-sample peak, from 4x oversampling, in dB relative to full + -- scale. NULL for digital silence, whose peak is -inf. + true_peak_dbtp real, + -- Loudness range (EBU Tech 3342): how much the loudness moves within the + -- track. Not used for gain; kept because it costs nothing here. + loudness_range_lu real, + -- How many 400 ms gating blocks fell in each 0.1 LU bin. Bin i holds blocks + -- measuring -70.0 + (block_hist_start + i) / 10 LUFS; the array is trimmed + -- to the first and last non-empty bins. Album loudness is the gated loudness + -- of every block on the album, so it is computed from these exactly, with no + -- second decode (#4996). + block_hist_start smallint, + block_hist integer[], + unreadable boolean NOT NULL DEFAULT false, + analysis_version smallint NOT NULL, + analyzed_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT track_loudness_hist_pair + CHECK ((block_hist IS NULL) = (block_hist_start IS NULL)) +); + +-- Loudness analysis's knobs, in admin Settings. Rule 25: an operator setting is +-- a database row, changed without a restart. Singleton in the style of +-- fingerprint_settings (0061). +CREATE TABLE loudness_settings ( + id boolean PRIMARY KEY DEFAULT true, + -- Off stops the background analysis. Tracks already measured keep their + -- values, so normalization keeps working for them. + enabled boolean NOT NULL DEFAULT true, + -- Files analyzed at once. Each is a full decode, competing with playback + -- transcoding for CPU and with streaming for the mount. + backfill_concurrency integer NOT NULL DEFAULT 2, + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT loudness_settings_singleton CHECK (id = true), + CONSTRAINT loudness_settings_concurrency_range + CHECK (backfill_concurrency >= 1 AND backfill_concurrency <= 8) +); +INSERT INTO loudness_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; diff --git a/internal/db/migrations/0066_album_loudness.down.sql b/internal/db/migrations/0066_album_loudness.down.sql new file mode 100644 index 00000000..7e0a1306 --- /dev/null +++ b/internal/db/migrations/0066_album_loudness.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS album_loudness; diff --git a/internal/db/migrations/0066_album_loudness.up.sql b/internal/db/migrations/0066_album_loudness.up.sql new file mode 100644 index 00000000..9243dcb9 --- /dev/null +++ b/internal/db/migrations/0066_album_loudness.up.sql @@ -0,0 +1,31 @@ +-- 0066_album_loudness.up.sql — album loudness for album-mode normalization +-- (Scribe milestone #464, #4996). +-- +-- An album's loudness is the gated loudness of every 400 ms block on the album, +-- not an average of its tracks' values: a quiet interlude and a loud single +-- should keep their difference when the album plays in order. It is computed +-- from the per-track block histograms in track_loudness (0065), summed, so no +-- audio is decoded again. +-- +-- A derived value, recomputed by the loudness worker whenever its inputs +-- change. inputs_digest is an md5 over the album's present tracks and the +-- measurement each holds; the worker recomputes every album whose stored digest +-- no longer matches. That one comparison covers every way membership changes +-- (a scan moving a track between albums, a duplicate merge, a delete, a file +-- going missing or coming back) without hooking each of them. +CREATE TABLE album_loudness ( + album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE, + -- NULL until every present track has a settled measurement: an album + -- leveled from half its tracks would jump when the rest arrived. Also NULL + -- for an album with no block above the gate. Clients fall back to track + -- gain while it is NULL. + integrated_lufs real, + -- The loudest true peak of any track on the album, so album gain is held + -- to the headroom of its loudest track. + true_peak_dbtp real, + tracks_total integer NOT NULL, + -- Tracks with a settled measurement (measured, silent or unreadable). + tracks_settled integer NOT NULL, + inputs_digest text NOT NULL, + computed_at timestamptz NOT NULL DEFAULT now() +); diff --git a/internal/db/queries/gc.sql b/internal/db/queries/gc.sql index 26c0c202..6934ca41 100644 --- a/internal/db/queries/gc.sql +++ b/internal/db/queries/gc.sql @@ -68,3 +68,10 @@ UPDATE system_playlist_runs DELETE FROM password_resets WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days') OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour'); + +-- name: GcDeleteExpiredSessions :execrows +-- Sessions past their idle (30 days) or absolute (1 year) limit. They already +-- fail auth through GetSessionByTokenHash's filter; this only clears the rows. +DELETE FROM sessions +WHERE last_seen_at <= now() - interval '30 days' + OR created_at <= now() - interval '365 days'; diff --git a/internal/db/queries/loudness.sql b/internal/db/queries/loudness.sql new file mode 100644 index 00000000..af33fb1b --- /dev/null +++ b/internal/db/queries/loudness.sql @@ -0,0 +1,171 @@ +-- name: UpsertTrackLoudness :exec +-- Written when the backfill measures a track (#4995). Replaces the row +-- wholesale: a measurement of the old bytes has no standing once the file has +-- changed. +INSERT INTO track_loudness ( + track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu, + block_hist_start, block_hist, unreadable, analysis_version +) VALUES ( + sqlc.arg(track_id), sqlc.narg(integrated_lufs), sqlc.narg(true_peak_dbtp), + sqlc.narg(loudness_range_lu), sqlc.narg(block_hist_start), sqlc.narg(block_hist), + sqlc.arg(unreadable), sqlc.arg(analysis_version) +) +ON CONFLICT (track_id) DO UPDATE SET + integrated_lufs = EXCLUDED.integrated_lufs, + true_peak_dbtp = EXCLUDED.true_peak_dbtp, + loudness_range_lu = EXCLUDED.loudness_range_lu, + block_hist_start = EXCLUDED.block_hist_start, + block_hist = EXCLUDED.block_hist, + unreadable = EXCLUDED.unreadable, + analysis_version = EXCLUDED.analysis_version, + analyzed_at = now(); + +-- name: DeleteTrackLoudness :exec +-- The scan saw new bytes at this path. The stored measurement describes the old +-- ones, so it goes, and the backfill measures the file again. +DELETE FROM track_loudness WHERE track_id = $1; + +-- name: ListTracksNeedingLoudness :many +-- The backfill's work queue: tracks with no measurement, or one taken by an +-- older method. Keyset-paged on id so a pass visits each track at most once; +-- an inconclusive attempt writes no row, and without the cursor a file that +-- keeps timing out would be listed again straight away. Missing tracks are +-- skipped: there is no file to read. +SELECT t.id, t.file_path, t.duration_ms + FROM tracks t + LEFT JOIN track_loudness l ON l.track_id = t.id + WHERE t.missing_since IS NULL + AND (l.track_id IS NULL OR l.analysis_version < sqlc.arg(current_version)) + AND t.id > sqlc.arg(after_id) + ORDER BY t.id + LIMIT sqlc.arg(batch_limit); + +-- name: GetLoudnessCoverage :one +-- The admin gauge. measured + silent + unreadable + pending = total. Missing +-- tracks are excluded, or the gauge could never reach the end. +SELECT count(*)::bigint AS total, + count(*) FILTER ( + WHERE l.analysis_version >= sqlc.arg(current_version) + AND l.integrated_lufs IS NOT NULL + )::bigint AS measured, + count(*) FILTER ( + WHERE l.analysis_version >= sqlc.arg(current_version) + AND l.integrated_lufs IS NULL AND NOT l.unreadable + )::bigint AS silent, + count(*) FILTER ( + WHERE l.analysis_version >= sqlc.arg(current_version) + AND l.unreadable + )::bigint AS unreadable, + count(*) FILTER ( + WHERE l.track_id IS NULL OR l.analysis_version < sqlc.arg(current_version) + )::bigint AS pending + FROM tracks t + LEFT JOIN track_loudness l ON l.track_id = t.id + WHERE t.missing_since IS NULL; + +-- name: GetLoudnessSettings :one +SELECT * FROM loudness_settings WHERE id = true; + +-- name: UpdateLoudnessSettings :one +-- Whole-row write from the admin card; migration 0065's CHECK is the backstop +-- behind the service's own validation. +UPDATE loudness_settings + SET enabled = sqlc.arg(enabled), + backfill_concurrency = sqlc.arg(backfill_concurrency), + updated_at = now() + WHERE id = true +RETURNING *; + +-- name: ListAlbumsNeedingLoudness :many +-- The album pass's work queue (#4996): albums whose present tracks or their +-- measurements have changed since album loudness was last computed, or that +-- never had it. The digest is over every present track's id and the +-- measurement it holds at the current version ('-' for none), so a track +-- joining, leaving or being re-measured changes it. Keyset-paged on album id +-- so a pass ends even if storing one album keeps failing. +WITH present AS ( + SELECT t.album_id, + md5(string_agg( + t.id::text || ':' || coalesce( + l.analysis_version::text || '@' || l.analyzed_at::text, '-'), + ',' ORDER BY t.id)) AS digest + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= sqlc.arg(current_version)::smallint + WHERE t.missing_since IS NULL + GROUP BY t.album_id +) +SELECT c.album_id, c.digest::text AS digest + FROM present c + LEFT JOIN album_loudness a ON a.album_id = c.album_id + WHERE (a.album_id IS NULL OR a.inputs_digest <> c.digest) + -- Casts: sqlc cannot infer a parameter's type through a CTE alias. + AND c.album_id > sqlc.arg(after_id)::uuid + ORDER BY c.album_id + LIMIT sqlc.arg(batch_limit)::integer; + +-- name: ListAlbumLoudnessInputs :many +-- Every present track on one album with its current measurement, if any. +-- settled is false for a track not yet measured at the current version. +SELECT t.id, + (l.track_id IS NOT NULL)::boolean AS settled, + l.true_peak_dbtp, + l.block_hist_start, + l.block_hist + FROM tracks t + LEFT JOIN track_loudness l + ON l.track_id = t.id AND l.analysis_version >= sqlc.arg(current_version) + WHERE t.album_id = sqlc.arg(album_id) + AND t.missing_since IS NULL; + +-- name: UpsertAlbumLoudness :exec +INSERT INTO album_loudness ( + album_id, integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled, inputs_digest +) VALUES ( + sqlc.arg(album_id), sqlc.narg(integrated_lufs), sqlc.narg(true_peak_dbtp), + sqlc.arg(tracks_total), sqlc.arg(tracks_settled), sqlc.arg(inputs_digest) +) +ON CONFLICT (album_id) DO UPDATE SET + integrated_lufs = EXCLUDED.integrated_lufs, + true_peak_dbtp = EXCLUDED.true_peak_dbtp, + tracks_total = EXCLUDED.tracks_total, + tracks_settled = EXCLUDED.tracks_settled, + inputs_digest = EXCLUDED.inputs_digest, + computed_at = now(); + +-- name: ListOrphanAlbumLoudness :many +-- Album rows whose album has no present track left (every file missing). The +-- album row itself survives a missing file, so its loudness would otherwise +-- stay behind describing tracks that are gone; it is recomputed if they return. +SELECT a.album_id + FROM album_loudness a + WHERE NOT EXISTS ( + SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL + ); + +-- name: DeleteAlbumLoudness :exec +DELETE FROM album_loudness WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]); + +-- name: GetAlbumLoudness :one +-- The stored album values, read before a recompute so a sync change is +-- logged only when they actually move. +SELECT integrated_lufs, true_peak_dbtp FROM album_loudness WHERE album_id = $1; + +-- name: GetReplayGainByTrackIDs :many +-- Track and album loudness for a set of tracks, for every surface that hands +-- gains to a client (#4997). A measurement from an older analysis version is +-- still delivered: it is a better gain than none until the backfill redoes it. +SELECT t.id, + tl.integrated_lufs AS track_lufs, + tl.true_peak_dbtp AS track_peak_dbtp, + al.integrated_lufs AS album_lufs, + al.true_peak_dbtp AS album_peak_dbtp + FROM tracks t + LEFT JOIN track_loudness tl ON tl.track_id = t.id + LEFT JOIN album_loudness al ON al.album_id = t.album_id + WHERE t.id = ANY(sqlc.arg(ids)::uuid[]); + +-- name: GetAlbumLoudnessByIDs :many +SELECT album_id, integrated_lufs, true_peak_dbtp + FROM album_loudness + WHERE album_id = ANY(sqlc.arg(ids)::uuid[]); diff --git a/internal/db/queries/network_settings.sql b/internal/db/queries/network_settings.sql index 9527590e..4a6998e7 100644 --- a/internal/db/queries/network_settings.sql +++ b/internal/db/queries/network_settings.sql @@ -3,3 +3,6 @@ SELECT * FROM network_settings WHERE id = true; -- name: UpdateTrustedProxyHops :one UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING *; + +-- name: UpdatePublicURL :one +UPDATE network_settings SET public_url = $1 WHERE id = true RETURNING *; diff --git a/internal/db/queries/sessions.sql b/internal/db/queries/sessions.sql index 9c809053..a17245fd 100644 --- a/internal/db/queries/sessions.sql +++ b/internal/db/queries/sessions.sql @@ -7,7 +7,18 @@ VALUES ($1, $2, $3, sqlc.arg(ip), sqlc.arg(ip)) RETURNING *; -- name: GetSessionByTokenHash :one -SELECT * FROM sessions WHERE token_hash = $1; +-- Expired sessions are invisible here, so they fail auth the moment they +-- lapse rather than whenever the GC sweep next runs. Two limits: +-- idle 30 days — a token nobody has used in a month is abandoned, and +-- matches the web cookie's lifetime; +-- absolute 1 year — even a token in daily use is re-issued yearly, so a +-- stolen one that is being used quietly does not live +-- forever. +-- Keep in step with ListSessionsForUser and GcDeleteExpiredSessions. +SELECT * FROM sessions +WHERE token_hash = $1 + AND last_seen_at > now() - interval '30 days' + AND created_at > now() - interval '365 days'; -- name: TouchSessionLastSeen :exec UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1; @@ -15,7 +26,13 @@ UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1; -- name: ListSessionsForUser :many -- Most-recently-active first: the row a user is most likely to act on is the -- one that moved last, and an unfamiliar entry at the top is the alarm. -SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC; +-- Expired rows are left out: they no longer authenticate, so listing them +-- as active would be wrong. Same limits as GetSessionByTokenHash. +SELECT * FROM sessions +WHERE user_id = $1 + AND last_seen_at > now() - interval '30 days' + AND created_at > now() - interval '365 days' +ORDER BY last_seen_at DESC; -- name: DeleteSession :exec DELETE FROM sessions WHERE id = $1; @@ -34,3 +51,9 @@ DELETE FROM sessions WHERE id = $1 AND user_id = $2; -- "Log out everywhere else." Excludes the caller's own session so the action -- doesn't log them out of the page they just used to invoke it. DELETE FROM sessions WHERE user_id = $1 AND id <> $2; + +-- name: DeleteSessionsForUser :execrows +-- Every session the user has, the caller's included. A password reset uses +-- it: whoever forgot the password is not signed in anywhere they trust, and +-- whoever may have learned it must be signed out everywhere. +DELETE FROM sessions WHERE user_id = $1; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index bbdaac3e..22f0ef88 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -1,5 +1,5 @@ -- name: CreateUser :one -INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) RETURNING *; @@ -17,7 +17,7 @@ RETURNING *; -- and the second caller's INSERT fails with a unique violation. The -- caller (registration handler) can retry as a regular non-admin in -- that case (or surface a "username taken" error to the user). -INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name) VALUES ( $1, $2, $3, (SELECT NOT EXISTS (SELECT 1 FROM users)), @@ -28,15 +28,16 @@ RETURNING *; -- name: GetUserByUsername :one SELECT * FROM users WHERE username = $1; --- name: GetUserByAPIToken :one -SELECT * FROM users WHERE api_token = $1; +-- name: GetUserByAPITokenHash :one +SELECT * FROM users WHERE api_token_hash = $1; -- name: CountUsers :one SELECT count(*) FROM users; -- name: SetSubsonicPassword :exec -- 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. +-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only +-- ever a server-generated value, never the login password (#5026). UPDATE users SET subsonic_password = $2 WHERE id = $1; -- name: GetUserByID :one @@ -87,7 +88,7 @@ WHERE u.id = $1; -- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace: -- the caller (an admin) supplies all five fields explicitly including is_admin, -- so an admin can promote on creation. Used by POST /api/admin/users. -INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) RETURNING *; @@ -141,11 +142,11 @@ UPDATE users WHERE id = $1 RETURNING *; --- name: RegenerateApiToken :one +-- name: RegenerateApiToken :exec -- Self-service: caller wants a new API token. Used by the /settings --- API Token card's "Regenerate" button. -UPDATE users SET api_token = $2 WHERE id = $1 -RETURNING *; +-- API Token card's "Regenerate" button. Only the hash is stored; the +-- handler returns the raw key once. +UPDATE users SET api_token_hash = $2 WHERE id = $1; -- name: GetUserByEmail :one -- Used by forgot-password lookup. Lowercase comparison both sides diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index a5b2380e..24507d92 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -91,6 +91,8 @@ var dataTables = []string{ "duplicate_groups", "duplicate_sweeps", "track_fingerprints", // M400 + "track_loudness", // M464 + "album_loudness", // M464 "tracks", "albums", "artists", @@ -141,4 +143,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) { ); err != nil { t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err) } + // Loudness analysis settings (M464 #4995), reset the same way. + if _, err := pool.Exec(ctx, ` + UPDATE loudness_settings + SET enabled = DEFAULT, backfill_concurrency = DEFAULT, updated_at = DEFAULT`, + ); err != nil { + t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err) + } } diff --git a/internal/gc/worker.go b/internal/gc/worker.go index 39ca9a3c..e0d31068 100644 --- a/internal/gc/worker.go +++ b/internal/gc/worker.go @@ -16,6 +16,7 @@ // - GcExpireScrobbleQueueFailedRows (#567) // - GcResetStuckSystemPlaylistRuns (#574) // - GcDeleteExpiredPasswordResets (#575) +// - GcDeleteExpiredSessions (M462 #4978 — idle 30d / absolute 1y) // - GcPruneDiagnostics (M9 — diagnostics 30d retention) // - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go) // - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity @@ -86,6 +87,7 @@ func (w *Worker) tickOnce(ctx context.Context) { w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows) w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns) w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets) + w.runSweep(ctx, "delete_expired_sessions", q.GcDeleteExpiredSessions) w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics) w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes) // Tags before state: if the process dies between the two, a candidate left diff --git a/internal/gc/worker_test.go b/internal/gc/worker_test.go index 9cfd24c9..19868cb7 100644 --- a/internal/gc/worker_test.go +++ b/internal/gc/worker_test.go @@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID { u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "test-hash", - ApiToken: "test-token-" + name, + ApiTokenHash: "test-token-" + name, IsAdmin: false, }) if err != nil { diff --git a/internal/library/album_loudness.go b/internal/library/album_loudness.go new file mode 100644 index 00000000..7d029e0c --- /dev/null +++ b/internal/library/album_loudness.go @@ -0,0 +1,212 @@ +package library + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" +) + +// Album loudness (M464 #4996). +// +// Album-mode normalization plays a whole album at one gain, so the quiet +// interlude stays quieter than the single it sits between. That gain comes +// from the album's loudness: BS.1770's gated loudness over every block on the +// album, which is what summing the tracks' block histograms and gating the sum +// computes. An average of the tracks' values is not the same thing: gating +// over the whole album drops a near-silent hidden track, where averaging +// would let it drag the album quieter. +// +// The values are derived, so they are recomputed rather than maintained: each +// worker tick lists the albums whose inputs digest has moved (see +// ListAlbumsNeedingLoudness) and recomputes those from the stored histograms. + +// albumLoudnessBatch is how many albums one query hands the album pass. +// Recomputing an album is a few small reads and arithmetic, no decode. +const albumLoudnessBatch = 200 + +// albumLoudness is one album's computed values. +type albumLoudness struct { + // integratedLUFS is nil until every track is settled, or when no block on + // the album passed the gate. + integratedLUFS *float32 + truePeakDBTP *float32 + total, settled int32 +} + +// computeAlbumLoudness sums the present tracks' histograms and gates the sum. +func computeAlbumLoudness(inputs []dbq.ListAlbumLoudnessInputsRow) albumLoudness { + a := albumLoudness{total: int32(len(inputs))} + hists := make([]blockHistogram, 0, len(inputs)) + for _, in := range inputs { + if !in.Settled { + continue + } + a.settled++ + if in.TruePeakDbtp != nil && (a.truePeakDBTP == nil || *in.TruePeakDbtp > *a.truePeakDBTP) { + peak := *in.TruePeakDbtp + a.truePeakDBTP = &peak + } + if in.BlockHistStart != nil && len(in.BlockHist) > 0 { + hists = append(hists, blockHistogram{start: *in.BlockHistStart, counts: in.BlockHist}) + } + } + // Leveling from part of an album would change its gain as the rest is + // measured, audibly, mid-listen. Wait for all of it. + if a.total == 0 || a.settled < a.total { + return a + } + if lufs, ok := mergeHistograms(hists).gatedLoudness(); ok { + v := float32(lufs) + a.integratedLUFS = &v + } + return a +} + +// mergeHistograms sums histograms that may cover different bin ranges into +// one, trimmed to the occupied range. A histogram reaching past the bin range +// (only a corrupt row could) is clipped rather than trusted. +func mergeHistograms(hs []blockHistogram) blockHistogram { + var bins [loudnessHistBins]int32 + for _, h := range hs { + for i, c := range h.counts { + if b := int(h.start) + i; b >= 0 && b < loudnessHistBins { + bins[b] += c + } + } + } + return trimBins(&bins) +} + +// AlbumLoudnessResult tallies one album pass. +type AlbumLoudnessResult struct { + Recomputed int + Leveled int // stored with an album loudness + Waiting int // stored without one: a track is not yet measured + Failed int + Orphans int64 // rows dropped because the album has no present track +} + +// albumPass recomputes every album whose inputs changed, keyset-paged on album +// id so a pass ends even when one album keeps failing to store. +// +// The digest stored is the one the list query computed. If a track changes +// between that query and the read of its inputs, the stored digest is already +// stale and the next pass recomputes the album again, so the values always +// converge on the inputs. +func (w *LoudnessBackfillWorker) albumPass(ctx context.Context) (AlbumLoudnessResult, error) { + q := dbq.New(w.pool) + var res AlbumLoudnessResult + after := pgtype.UUID{Valid: true} + for { + if err := ctx.Err(); err != nil { + return res, err + } + rows, err := q.ListAlbumsNeedingLoudness(ctx, dbq.ListAlbumsNeedingLoudnessParams{ + CurrentVersion: loudnessVersion, + AfterID: after, + BatchLimit: w.albumBatch, + }) + if err != nil { + return res, fmt.Errorf("list albums needing loudness: %w", err) + } + if len(rows) == 0 { + break + } + for _, row := range rows { + res.Recomputed++ + if err := w.storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil { + res.Failed++ + w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err) + } + } + after = rows[len(rows)-1].AlbumID + } + // Listed, logged, then deleted: the same log-first order as above. + orphans, err := q.ListOrphanAlbumLoudness(ctx) + if err != nil { + return res, fmt.Errorf("list orphan album loudness: %w", err) + } + if len(orphans) == 0 { + return res, nil + } + ids := make([]string, len(orphans)) + for i, id := range orphans { + ids[i] = syncpkg.FormatUUID(id) + } + if err := syncpkg.LogChanges(ctx, w.pool, syncpkg.EntityAlbum, ids, syncpkg.OpUpsert); err != nil { + return res, fmt.Errorf("log orphan album changes: %w", err) + } + if err := q.DeleteAlbumLoudness(ctx, orphans); err != nil { + return res, fmt.Errorf("drop orphan album loudness: %w", err) + } + res.Orphans = int64(len(orphans)) + return res, nil +} + +// storeAlbumLoudness recomputes one album. The sync feed hears about it only +// when the values clients see move: during the backfill an album's digest +// changes with every track measured, and most of those recomputes still end +// with no album value. +func (w *LoudnessBackfillWorker) storeAlbumLoudness( + ctx context.Context, q *dbq.Queries, albumID pgtype.UUID, digest string, res *AlbumLoudnessResult, +) error { + before, err := q.GetAlbumLoudness(ctx, albumID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("read stored values: %w", err) + } + inputs, err := q.ListAlbumLoudnessInputs(ctx, dbq.ListAlbumLoudnessInputsParams{ + CurrentVersion: loudnessVersion, + AlbumID: albumID, + }) + if err != nil { + return fmt.Errorf("read inputs: %w", err) + } + a := computeAlbumLoudness(inputs) + // Logged before the write, for the reason storeLoudness gives (#2704). A + // failed log stores nothing, so the digest still differs and the next + // pass tries again. + if albumVisibleChange(before, a) { + if err := syncpkg.LogChange(ctx, w.pool, syncpkg.EntityAlbum, syncpkg.FormatUUID(albumID), syncpkg.OpUpsert); err != nil { + return fmt.Errorf("log album change: %w", err) + } + } + if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{ + AlbumID: albumID, + IntegratedLufs: a.integratedLUFS, + TruePeakDbtp: a.truePeakDBTP, + TracksTotal: a.total, + TracksSettled: a.settled, + InputsDigest: digest, + }); err != nil { + return fmt.Errorf("store: %w", err) + } + if a.integratedLUFS != nil { + res.Leveled++ + } else { + res.Waiting++ + } + return nil +} + +// albumVisibleChange reports whether clients would see different album gains. +// The peak is only delivered beside a loudness, so a waiting album whose peak +// moves as tracks are measured has nothing new to tell anyone. +func albumVisibleChange(before dbq.GetAlbumLoudnessRow, after albumLoudness) bool { + if !sameFloat(before.IntegratedLufs, after.integratedLUFS) { + return true + } + return after.integratedLUFS != nil && !sameFloat(before.TruePeakDbtp, after.truePeakDBTP) +} + +func sameFloat(a, b *float32) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} diff --git a/internal/library/album_loudness_test.go b/internal/library/album_loudness_test.go new file mode 100644 index 00000000..a943e024 --- /dev/null +++ b/internal/library/album_loudness_test.go @@ -0,0 +1,275 @@ +package library + +import ( + "context" + "io" + "log/slog" + "math" + "path/filepath" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" +) + +// histAt builds a histogram of n blocks all at one loudness. +func histAt(lufs float64, n int32) blockHistogram { + return blockHistogram{ + start: int16(math.Round((lufs - loudnessHistFloor) * loudnessHistPerLU)), + counts: []int32{n}, + } +} + +func TestMergeHistograms_SumsAcrossDifferentRanges(t *testing.T) { + a := blockHistogram{start: 500, counts: []int32{1, 0, 2}} // bins 500..502 + b := blockHistogram{start: 501, counts: []int32{4, 0, 0, 5}} // bins 501..504 + got := mergeHistograms([]blockHistogram{a, b}) + want := blockHistogram{start: 500, counts: []int32{1, 4, 2, 0, 5}} + if got.start != want.start || len(got.counts) != len(want.counts) { + t.Fatalf("merged = %+v, want %+v", got, want) + } + for i := range want.counts { + if got.counts[i] != want.counts[i] { + t.Fatalf("merged = %+v, want %+v", got, want) + } + } + // Bins past the range come only from a corrupt row; they are dropped, not + // allowed to index out of the array. + bad := blockHistogram{start: loudnessHistBins - 1, counts: []int32{1, 9}} + if got := mergeHistograms([]blockHistogram{bad}); len(got.counts) != 1 || got.counts[0] != 1 { + t.Errorf("out-of-range bin not dropped: %+v", got) + } + if !mergeHistograms(nil).empty() { + t.Errorf("merging nothing gave a non-empty histogram") + } +} + +func input(settled bool, peak *float32, h blockHistogram) dbq.ListAlbumLoudnessInputsRow { + row := dbq.ListAlbumLoudnessInputsRow{Settled: settled, TruePeakDbtp: peak} + if !h.empty() { + start := h.start + row.BlockHistStart = &start + row.BlockHist = h.counts + } + return row +} + +func TestComputeAlbumLoudness(t *testing.T) { + f := func(v float32) *float32 { return &v } + + // Album loudness gates over the whole album. A near-silent hidden track is + // dropped by the relative gate, where averaging the tracks' values would + // have let it pull the album 15 LU quieter. + a := computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ + input(true, f(-1.0), histAt(-10, 1800)), + input(true, f(-0.2), histAt(-10, 2400)), + input(true, f(-30), histAt(-40, 600)), + }) + if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-10)) > 0.01 { + t.Errorf("album loudness = %v, want -10 (the hidden track gated out)", a.integratedLUFS) + } + if a.truePeakDBTP == nil || *a.truePeakDBTP != -0.2 { + t.Errorf("album peak = %v, want the loudest track's -0.2", a.truePeakDBTP) + } + if a.total != 3 || a.settled != 3 { + t.Errorf("total/settled = %d/%d, want 3/3", a.total, a.settled) + } + + // Energy, not an average of LUFS: two equally long tracks at -8 and -14 + // make an album nearer the louder one than the midpoint (-11): -10.037. + a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ + input(true, nil, histAt(-8, 1000)), + input(true, nil, histAt(-14, 1000)), + }) + if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-10.037)) > 0.01 { + t.Errorf("album loudness = %v, want -10.037", a.integratedLUFS) + } + + // One track not yet measured: no album value until it is. + a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ + input(true, f(-1), histAt(-10, 100)), + input(false, nil, blockHistogram{}), + }) + if a.integratedLUFS != nil || a.settled != 1 || a.total != 2 { + t.Errorf("partly measured album = %+v, want no loudness, 1 of 2 settled", a) + } + + // Settled without blocks (silent, or unreadable): counted as settled and + // leveled from the rest. + a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{ + input(true, f(-3), histAt(-12, 100)), + input(true, nil, blockHistogram{}), + }) + if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-12)) > 0.01 { + t.Errorf("album with a silent track = %v, want -12 from the other track", a.integratedLUFS) + } + + // Every track silent: settled, but nothing to level by. + a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{input(true, nil, blockHistogram{})}) + if a.integratedLUFS != nil || a.settled != 1 { + t.Errorf("all-silent album = %+v, want no loudness", a) + } +} + +// TestAlbumLoudness_Integration pins that the album pass computes from the +// stored histograms, does nothing when nothing changed, and recomputes on +// each kind of membership change. +func TestAlbumLoudness_Integration(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + q := dbq.New(pool) + dir := t.TempDir() + + first, album, artist := seedTrack(t, pool, filepath.Join(dir, "a.mp3")) + addTrack := func(name string, albumID dbq.Album) dbq.Track { + t.Helper() + tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: name, AlbumID: albumID.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track %s: %v", name, err) + } + return tr + } + measure := func(tr dbq.Track, lufs float64, peak float32) { + t.Helper() + h := histAt(lufs, 100) + l := float32(lufs) + if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{ + TrackID: tr.ID, IntegratedLufs: &l, TruePeakDbtp: &peak, + BlockHistStart: &h.start, BlockHist: h.counts, AnalysisVersion: loudnessVersion, + }); err != nil { + t.Fatalf("measure %s: %v", tr.Title, err) + } + } + read := func() (lufs, peak *float32, total, settled int32) { + t.Helper() + if err := pool.QueryRow(ctx, + "SELECT integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled FROM album_loudness WHERE album_id = $1", + album.ID).Scan(&lufs, &peak, &total, &settled); err != nil { + t.Fatalf("read album loudness: %v", err) + } + return + } + w := NewLoudnessBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + w.albumBatch = 1 // forces the keyset cursor across queries + pass := func() AlbumLoudnessResult { + t.Helper() + res, err := w.albumPass(ctx) + if err != nil { + t.Fatalf("album pass: %v", err) + } + return res + } + + albumChanges := func() int { + t.Helper() + var n int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes + WHERE entity_type = 'album' AND entity_id = $1`, + syncpkg.FormatUUID(album.ID)).Scan(&n); err != nil { + t.Fatalf("count album changes: %v", err) + } + return n + } + + second := addTrack("b", album) + measure(first, -10, -1) + + // 1. One track unmeasured: the album is stored, waiting, without loudness. + if res := pass(); res.Recomputed != 1 || res.Waiting != 1 { + t.Fatalf("first pass = %+v, want 1 recomputed, waiting", res) + } + if lufs, _, total, settled := read(); lufs != nil || total != 2 || settled != 1 { + t.Fatalf("waiting album = lufs %v, %d/%d settled; want nil, 1/2", lufs, settled, total) + } + + // Still no album value: clients have nothing new to read (#4997). + if n := albumChanges(); n != 0 { + t.Fatalf("a waiting album logged %d sync changes, want 0", n) + } + + // 2. Measuring the second track changes the digest; the album is leveled. + measure(second, -10, -0.5) + if res := pass(); res.Recomputed != 1 || res.Leveled != 1 { + t.Fatalf("second pass = %+v, want 1 leveled", res) + } + lufs, peak, _, _ := read() + if lufs == nil || math.Abs(float64(*lufs)-(-10)) > 0.01 || peak == nil || *peak != -0.5 { + t.Fatalf("leveled album = lufs %v peak %v, want -10 and -0.5", lufs, peak) + } + + // Leveled: clients are told, once. + if n := albumChanges(); n != 1 { + t.Fatalf("leveling the album logged %d sync changes, want 1", n) + } + + // 3. Nothing changed: nothing recomputed, nothing logged. + if res := pass(); res.Recomputed != 0 { + t.Fatalf("idle pass = %+v, want nothing recomputed", res) + } + if n := albumChanges(); n != 1 { + t.Fatalf("an idle pass logged album changes (now %d), want still 1", n) + } + + // 4. A track joins (here, retagged onto this album): recomputed. + other, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Other", SortTitle: "Other", ArtistID: artist.ID}) + if err != nil { + t.Fatalf("other album: %v", err) + } + loud := addTrack("loud", other) + measure(loud, -4, 0.3) + pass() + if _, err := pool.Exec(ctx, "UPDATE tracks SET album_id = $1 WHERE id = $2", album.ID, loud.ID); err != nil { + t.Fatalf("move track: %v", err) + } + if res := pass(); res.Recomputed < 1 { + t.Fatalf("pass after a track joined = %+v, want a recompute", res) + } + // Two tracks at -10 and one at -4, equally long: -7.003 by energy. + if lufs, peak, total, _ := read(); total != 3 || lufs == nil || math.Abs(float64(*lufs)-(-7.003)) > 0.01 || + peak == nil || *peak != 0.3 { + t.Fatalf("album after a loud track joined = lufs %v peak %v total %d", lufs, peak, total) + } + + // 5. A track's file goes missing: recomputed without it. + if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", loud.ID); err != nil { + t.Fatalf("mark missing: %v", err) + } + pass() + if lufs, peak, total, _ := read(); total != 2 || lufs == nil || math.Abs(float64(*lufs)-(-10)) > 0.01 || + peak == nil || *peak != -0.5 { + t.Fatalf("album after the loud track went missing = lufs %v peak %v total %d", lufs, peak, total) + } + + // 6. A track is deleted (as a duplicate merge does): recomputed. + if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", second.ID); err != nil { + t.Fatalf("delete track: %v", err) + } + pass() + if _, _, total, _ := read(); total != 1 { + t.Fatalf("album after a delete has %d tracks, want 1", total) + } + + // 7. Every remaining track missing: the album row is dropped, not left + // describing tracks that are gone. + if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", first.ID); err != nil { + t.Fatalf("mark missing: %v", err) + } + before := albumChanges() + if res := pass(); res.Orphans != 1 { + t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res) + } + // Dropping the row takes the album gain away, so clients are told. + if n := albumChanges(); n != before+1 { + t.Fatalf("dropping the orphan logged %d album changes, want 1", n-before) + } + var n int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM album_loudness WHERE album_id = $1", album.ID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatalf("album loudness row survived every track going missing") + } +} diff --git a/internal/library/duplicate_merge_test.go b/internal/library/duplicate_merge_test.go index 729cc942..5651e7c8 100644 --- a/internal/library/duplicate_merge_test.go +++ b/internal/library/duplicate_merge_test.go @@ -74,7 +74,7 @@ func newMergeFixture(t *testing.T) mergeFixture { user := func(name string) dbq.User { t.Helper() u, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-merge-token", + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-merge-token", }) if err != nil { t.Fatalf("user %s: %v", name, err) diff --git a/internal/library/loudness.go b/internal/library/loudness.go new file mode 100644 index 00000000..78893553 --- /dev/null +++ b/internal/library/loudness.go @@ -0,0 +1,410 @@ +package library + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "math" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" +) + +// Loudness analysis (M464 #4995). +// +// Every track is measured with ffmpeg's EBU R128 filter, which reports the +// ITU-R BS.1770 values loudness normalization works from: +// +// integrated loudness the gated, K-weighted average loudness of the whole +// track, in LUFS. A client levels a track by playing it +// at (target - integrated) dB. +// true peak the highest inter-sample peak, in dBTP. It bounds how +// far a quiet track can be raised before it clips. +// loudness range how much the loudness moves within the track. +// +// Tags are not trusted: ReplayGain values in the wild are written against four +// different reference levels, and most files have none. +// +// The filter also logs the loudness of every 400 ms gating block (one every +// 100 ms). Those are kept as a histogram, because an album's loudness is the +// gated loudness of every block on the album, not an average of its tracks' +// values. With the histograms stored, album loudness is exact and needs no +// second decode (#4996). + +// loudnessVersion stamps how a track_loudness row was measured. Bump it when +// the measurement changes (the filter's options, the histogram's bins) and the +// backfill measures every row below it again. +const loudnessVersion int16 = 1 + +// Unlike a fingerprint, the analysis decodes the whole file, so a fixed +// deadline would either cut off a long mix or be useless for a three-minute +// song. The deadline is a base plus the track's length at loudnessMinSpeed: +// a decode slower than that is a stall, not a big file. +const ( + loudnessBaseTimeout = 2 * time.Minute + loudnessMinSpeed = 4 +) + +// errLoudnessTimeout marks an analysis that ran out of time: a fact about the +// mount, not about the file. +var errLoudnessTimeout = errors.New("loudness analysis timed out") + +// The block histogram's bins: 0.1 LU wide (the precision ffmpeg prints) from +// the -70 LUFS absolute gate up to +10 LUFS. Blocks below the gate do not take +// part in gating at all, so they are not counted; anything above the top bin, +// which only clipped noise reaches, is counted in it. +const ( + loudnessHistFloor = -70.0 + loudnessHistPerLU = 10 + loudnessHistBins = 800 + loudnessStderrTail = 20 // lines kept for an error message +) + +// histogramCrossCheckLU is how far the loudness recomputed from the histogram +// may stray from ffmpeg's own figure before it is logged. They agree to a few +// hundredths when the log means what the parser assumes, so a larger gap says +// ffmpeg's output has changed underneath us. +const histogramCrossCheckLU = 0.3 + +// ebur128Args measures the file's first audio stream. +// +// peak=true asks for true peak (4x oversampled) rather than sample peak, which +// misses the inter-sample overs a boosted track would clip on. dualmono=true +// measures a mono file as if played on both speakers of a stereo pair, which is +// how it is heard; measured as one channel it would read 3 LU quiet and be +// boosted too far. framelog=info makes the per-block lines print at the log +// level asked for here, independent of ffmpeg's default. +func ebur128Args(path string) []string { + return []string{ + "-hide_banner", "-nostdin", "-nostats", + "-loglevel", "info", + "-i", path, + "-map", "0:a:0", + "-af", "ebur128=peak=true:dualmono=true:framelog=info", + "-f", "null", "-", + } +} + +// loudnessTimeout is the deadline for a track of durationMs. An unknown length +// (0) gets the base alone, which covers an ordinary song. +func loudnessTimeout(durationMs int32) time.Duration { + return loudnessBaseTimeout + time.Duration(max(durationMs, 0))*time.Millisecond/loudnessMinSpeed +} + +// loudnessResult is one attempt at measuring a track. +type loudnessResult struct { + // integratedLUFS is nil when no block passed the gates: silence, or a + // file shorter than one 400 ms block. + integratedLUFS *float32 + truePeakDBTP *float32 + rangeLU *float32 + hist blockHistogram + err error +} + +// inconclusive reports whether the attempt failed for a reason that says +// nothing about the file. Such a result is never stored: stamped at the current +// version it would read as "this file cannot be measured", and the backfill +// would never try it again. +func (r loudnessResult) inconclusive() bool { + return isInconclusive(r.err) || errors.Is(r.err, errLoudnessTimeout) +} + +// blockHistogram counts gating blocks per 0.1 LU bin, trimmed to the occupied +// range: counts[i] is the number of blocks measuring +// loudnessHistFloor + (start+i)/loudnessHistPerLU LUFS. +type blockHistogram struct { + start int16 + counts []int32 +} + +func (h blockHistogram) empty() bool { return len(h.counts) == 0 } + +// gatedLoudness applies BS.1770's two gates to the histogram and returns the +// integrated loudness of what passes. Every counted block is already above the +// absolute gate; the relative gate drops blocks more than 10 LU below the +// loudness of the blocks that passed it. ok is false when nothing passes. +// +// The same function measures an album, over the sum of its tracks' +// histograms (#4996). +func (h blockHistogram) gatedLoudness() (lufs float64, ok bool) { + energy := func(i int) float64 { + l := loudnessHistFloor + float64(int(h.start)+i)/loudnessHistPerLU + return math.Pow(10, (l+0.691)/10) + } + mean := func(threshold float64) (float64, bool) { + var sum, n float64 + for i, c := range h.counts { + if c == 0 { + continue + } + if loudnessHistFloor+float64(int(h.start)+i)/loudnessHistPerLU < threshold { + continue + } + sum += float64(c) * energy(i) + n += float64(c) + } + if n == 0 { + return 0, false + } + return sum / n, true + } + ungated, ok := mean(math.Inf(-1)) + if !ok { + return 0, false + } + relative := -0.691 + 10*math.Log10(ungated) - 10 + gated, ok := mean(relative) + if !ok { + return 0, false + } + return -0.691 + 10*math.Log10(gated), true +} + +// computeLoudness measures the file at path. durationMs sets the deadline. +func computeLoudness(ctx context.Context, path string, durationMs int32) loudnessResult { + timeout := loudnessTimeout(durationMs) + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(runCtx, "ffmpeg", ebur128Args(path)...) + cmd.WaitDelay = fingerprintWaitDelay + stderr, err := cmd.StderrPipe() + if err != nil { + return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)} + } + if err := cmd.Start(); err != nil { + return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)} + } + // The per-block log runs to ten lines a second of audio, about 12 MB for a + // two-hour mix, so it is parsed as it streams rather than buffered. + p := newEbur128Parser() + p.consume(stderr) + waitErr := cmd.Wait() + + switch { + case ctx.Err() != nil: + // The caller gave up. Report that rather than the kill it caused, so it + // is never mistaken for a verdict on the file. + return loudnessResult{err: fmt.Errorf("ffmpeg: %w", ctx.Err())} + case errors.Is(runCtx.Err(), context.DeadlineExceeded): + return loudnessResult{err: fmt.Errorf("ffmpeg: no result within %s: %w", timeout, errLoudnessTimeout)} + case waitErr != nil: + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + return loudnessResult{err: fmt.Errorf("ffmpeg exited %d: %s", exitErr.ExitCode(), p.tail())} + } + return loudnessResult{err: fmt.Errorf("ffmpeg: %w", waitErr)} + } + return p.result() +} + +var ( + // A per-block line: "[Parsed_ebur128_0 @ 0x…] t: 2.49998 TARGET:-23 LUFS + // M: -31.1 S:-120.7 I: -31.1 LUFS ...". M is the 400 ms block just ended. + ebur128BlockRe = regexp.MustCompile(`\bt:\s*\S+\s+TARGET:.*?\bM:\s*(-?(?:\d+(?:\.\d+)?|inf))`) + // The summary's values, each on a line of its own after "Summary:". + ebur128SummaryRe = regexp.MustCompile(`^(I|LRA|Peak):\s+(-?(?:\d+(?:\.\d+)?|inf))\s+(?:LUFS|LU|dBFS)$`) +) + +// ebur128Parser reads the filter's log: the per-block lines into the +// histogram, and the closing summary. +type ebur128Parser struct { + bins [loudnessHistBins]int32 + inSummary bool + summary map[string]string + lastLines []string +} + +func newEbur128Parser() *ebur128Parser { + return &ebur128Parser{summary: map[string]string{}} +} + +func (p *ebur128Parser) consume(r io.Reader) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 4096), 64*1024) + for sc.Scan() { + p.line(sc.Text()) + } + // A line past the buffer (not something ffmpeg prints) stops the scanner; + // drain the rest so ffmpeg is never blocked writing to a full pipe. + _, _ = io.Copy(io.Discard, r) +} + +func (p *ebur128Parser) line(raw string) { + line := strings.TrimSpace(strings.TrimRight(raw, "\r")) + if line == "" { + return + } + if len(p.lastLines) == loudnessStderrTail { + p.lastLines = p.lastLines[1:] + } + p.lastLines = append(p.lastLines, line) + + if strings.HasSuffix(line, "Summary:") { + p.inSummary = true + return + } + if p.inSummary { + if m := ebur128SummaryRe.FindStringSubmatch(line); m != nil { + p.summary[m[1]] = m[2] + } + return + } + m := ebur128BlockRe.FindStringSubmatch(line) + if m == nil { + return + } + v, err := strconv.ParseFloat(m[1], 64) + if err != nil || v < loudnessHistFloor { + // -inf, or below the absolute gate: such a block takes no part in + // gating. + return + } + bin := int(math.Round((v - loudnessHistFloor) * loudnessHistPerLU)) + p.bins[min(bin, loudnessHistBins-1)]++ +} + +func (p *ebur128Parser) tail() string { + return strings.Join(p.lastLines, " | ") +} + +func (p *ebur128Parser) histogram() blockHistogram { + return trimBins(&p.bins) +} + +// trimBins turns a full-range bin array into a histogram trimmed to its +// occupied bins; an empty array gives an empty histogram. +func trimBins(bins *[loudnessHistBins]int32) blockHistogram { + first, last := 0, loudnessHistBins-1 + for first <= last && bins[first] == 0 { + first++ + } + if first > last { + return blockHistogram{} + } + for bins[last] == 0 { + last-- + } + counts := make([]int32, last-first+1) + copy(counts, bins[first:last+1]) + return blockHistogram{start: int16(first), counts: counts} +} + +// result turns a clean exit into a measurement. A run with no summary means +// ffmpeg decoded nothing it could measure, which is a verdict on the file. +func (p *ebur128Parser) result() loudnessResult { + integrated, ok := p.summary["I"] + if !ok { + return loudnessResult{err: fmt.Errorf("ffmpeg printed no loudness summary: %s", p.tail())} + } + r := loudnessResult{ + hist: p.histogram(), + truePeakDBTP: parseLoudnessValue(p.summary["Peak"]), + rangeLU: parseLoudnessValue(p.summary["LRA"]), + } + // ffmpeg reports -70.0 when no block passed the gate. The empty histogram + // says the same thing directly. + if !r.hist.empty() { + r.integratedLUFS = parseLoudnessValue(integrated) + } + if r.integratedLUFS == nil { + r.rangeLU = nil + } + return r +} + +// parseLoudnessValue reads one summary figure; -inf and anything unreadable +// come back nil. +func parseLoudnessValue(s string) *float32 { + v, err := strconv.ParseFloat(s, 32) + if err != nil || math.IsInf(v, 0) || math.IsNaN(v) { + return nil + } + f := float32(v) + return &f +} + +// loudnessOutcome is what storeLoudness did with one attempt. +type loudnessOutcome int + +const ( + loudnessMeasured loudnessOutcome = iota // integrated loudness stored + loudnessSilent // read fine; no block above the gate + loudnessUnreadable // ffmpeg could not decode the file + loudnessInconclusive // nothing stored; worth trying again + loudnessStoreFailed // the write itself failed +) + +// storeLoudness records one attempt. It never fails its caller: an unmeasured +// track simply plays without a gain adjustment. +// +// An inconclusive attempt leaves any existing row alone. The scan deletes a +// row when its file changes, so a row still here describes these bytes, and a +// value from an older method is a better gain than none until it is redone. +// +// A stored measurement is logged to the sync feed as a track upsert, so +// clients that cache the library (Android) pick up the new gain (#4997). If +// the log fails nothing is stored, and the backfill tries the track again. +func storeLoudness( + ctx context.Context, db dbq.DBTX, logger *slog.Logger, + trackID pgtype.UUID, path string, r loudnessResult, +) loudnessOutcome { + q := dbq.New(db) + if r.err != nil { + logger.Warn("loudness: analysis failed", "path", path, "err", r.err) + if r.inconclusive() { + return loudnessInconclusive + } + } + params := dbq.UpsertTrackLoudnessParams{ + TrackID: trackID, + Unreadable: r.err != nil, + AnalysisVersion: loudnessVersion, + } + outcome := loudnessUnreadable + if r.err == nil { + outcome = loudnessSilent + params.IntegratedLufs = r.integratedLUFS + params.TruePeakDbtp = r.truePeakDBTP + params.LoudnessRangeLu = r.rangeLU + if !r.hist.empty() { + start := r.hist.start + params.BlockHistStart = &start + params.BlockHist = r.hist.counts + } + if r.integratedLUFS != nil { + outcome = loudnessMeasured + if got, ok := r.hist.gatedLoudness(); ok && math.Abs(got-float64(*r.integratedLUFS)) > histogramCrossCheckLU { + // Stored regardless: ffmpeg's own figure is the track's + // loudness. But album loudness is computed from the + // histogram, and this says it would be wrong. + logger.Warn("loudness: block histogram disagrees with ffmpeg's integrated loudness", + "path", path, "ffmpeg_lufs", *r.integratedLUFS, "histogram_lufs", got) + } + } + } + // Logged before the write, as reconcile does (#2704): a log that then + // finds the write failed costs clients one wasted re-read, but a write + // that lands with no log is never retried, since the track is settled, and + // clients would never learn its gain. + if err := syncpkg.LogChange(ctx, db, syncpkg.EntityTrack, syncpkg.FormatUUID(trackID), syncpkg.OpUpsert); err != nil { + logger.Warn("loudness: LogChange track upsert failed; not storing", "path", path, "err", err) + return loudnessStoreFailed + } + if err := q.UpsertTrackLoudness(ctx, params); err != nil { + logger.Warn("loudness: storing measurement failed", "path", path, "err", err) + return loudnessStoreFailed + } + return outcome +} diff --git a/internal/library/loudness_backfill.go b/internal/library/loudness_backfill.go new file mode 100644 index 00000000..3379c973 --- /dev/null +++ b/internal/library/loudness_backfill.go @@ -0,0 +1,214 @@ +package library + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Loudness backfill (M464 #4995). +// +// Every track is measured here, the new ones included: the scan only deletes a +// changed file's measurement (see scanFile), and this worker measures it again. +// Measuring inline in the scan was the first plan and was dropped, because the +// analysis decodes the whole file. Added to the scan, a large import would run +// several times longer and could pass StuckScanThreshold (1h), at which point +// the run is reaped and a second scan started beside it. The fingerprint +// backfill is a worker of its own for the same reason. +// +// Until a track is measured it plays with no gain adjustment, which is how +// every track played before normalization existed. + +// loudnessBackfillTick is how often the worker looks for work. Shorter than +// the fingerprint backfill's hour, because a new track plays unleveled until it +// is measured; once the library has caught up, a tick is one indexed query. +const loudnessBackfillTick = 10 * time.Minute + +// loudnessBackfillBatch is how many tracks one query hands the worker. +const loudnessBackfillBatch = 50 + +// loudnessBackfillConcurrency is the shipped value of the concurrency setting. +// Low for the reason fingerprinting's is: each analysis is a full decode, +// competing with playback transcoding and streaming. +const loudnessBackfillConcurrency = 2 + +// BackfillLoudnessResult tallies one pass. +type BackfillLoudnessResult struct { + Processed int + Measured int + Silent int // read fine, no block above the gate (settled) + Unreadable int // ffmpeg could not decode the file (settled) + Inconclusive int // nothing stored; tried again on a later pass +} + +func (r *BackfillLoudnessResult) add(o loudnessOutcome) { + r.Processed++ + switch o { + case loudnessMeasured: + r.Measured++ + case loudnessSilent: + r.Silent++ + case loudnessUnreadable: + r.Unreadable++ + default: + r.Inconclusive++ + } +} + +// LoudnessBackfillWorker measures every track that has no current measurement. +type LoudnessBackfillWorker struct { + pool *pgxpool.Pool + logger *slog.Logger + settings *LoudnessSettingsService + tick time.Duration + batch int32 + albumBatch int32 + // analyze is a field so an integration test pins which tracks a pass + // touches, not what ffmpeg prints. + analyze func(ctx context.Context, path string, durationMs int32) loudnessResult +} + +// NewLoudnessBackfillWorker builds a worker with the production cadence. +// settings is shared with the admin API; nil runs on defaults. +func NewLoudnessBackfillWorker( + pool *pgxpool.Pool, logger *slog.Logger, settings *LoudnessSettingsService, +) *LoudnessBackfillWorker { + return &LoudnessBackfillWorker{ + pool: pool, + logger: logger, + settings: settings, + tick: loudnessBackfillTick, + batch: loudnessBackfillBatch, + albumBatch: albumLoudnessBatch, + analyze: computeLoudness, + } +} + +// Run blocks until ctx is cancelled: one pass at start, then one per tick. +func (w *LoudnessBackfillWorker) Run(ctx context.Context) { + w.runOnce(ctx) + t := time.NewTicker(w.tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.runOnce(ctx) + } + } +} + +// runOnce contains a pass so that nothing it does (an error, a panic) can stop +// the next tick from firing (rule 157). +func (w *LoudnessBackfillWorker) runOnce(ctx context.Context) { + defer func() { + if r := recover(); r != nil { + w.logger.Error("loudness backfill: pass panicked", "panic", r) + } + }() + res, err := w.pass(ctx) + if err != nil && ctx.Err() == nil { + w.logger.Warn("loudness backfill: pass failed", "err", err, "processed", res.Processed) + } + if res.Processed > 0 { + w.logger.Info("loudness backfill: pass complete", + "processed", res.Processed, "measured", res.Measured, "silent", res.Silent, + "unreadable", res.Unreadable, "inconclusive", res.Inconclusive) + } + // Album loudness follows the tracks (#4996). It runs even with analysis + // switched off: it decodes nothing, and membership still changes as the + // library does. + albums, err := w.albumPass(ctx) + if err != nil && ctx.Err() == nil { + w.logger.Warn("album loudness: pass failed", "err", err, "recomputed", albums.Recomputed) + } + if albums.Recomputed > 0 || albums.Orphans > 0 { + w.logger.Info("album loudness: pass complete", + "recomputed", albums.Recomputed, "leveled", albums.Leveled, + "waiting", albums.Waiting, "failed", albums.Failed, "orphans", albums.Orphans) + } +} + +// pass walks every track needing a measurement once, keyset-paged on id. The +// cursor is what lets a pass end: an inconclusive attempt writes no row, so a +// file that keeps timing out would otherwise be listed again immediately. +// Settings are read before every batch, so switching analysis off ends the +// pass and a new concurrency applies to the next batch. +func (w *LoudnessBackfillWorker) pass(ctx context.Context) (BackfillLoudnessResult, error) { + q := dbq.New(w.pool) + var ( + res BackfillLoudnessResult + mu sync.Mutex + ) + // The all-zero uuid sorts before every real id. Valid must be true: a NULL + // cursor would make "id > NULL" match nothing and every pass a no-op. + after := pgtype.UUID{Valid: true} + for { + if err := ctx.Err(); err != nil { + return res, err + } + cfg := w.settings.Get() + if !cfg.Enabled { + return res, nil + } + rows, err := q.ListTracksNeedingLoudness(ctx, dbq.ListTracksNeedingLoudnessParams{ + CurrentVersion: loudnessVersion, + AfterID: after, + BatchLimit: w.batch, + }) + if err != nil { + return res, fmt.Errorf("list tracks needing loudness: %w", err) + } + if len(rows) == 0 { + return res, nil + } + + sem := make(chan struct{}, max(1, int(cfg.BackfillConcurrency))) + var wg sync.WaitGroup + for _, row := range rows { + if ctx.Err() != nil { + break + } + sem <- struct{}{} + wg.Add(1) + go func(row dbq.ListTracksNeedingLoudnessRow) { + defer wg.Done() + defer func() { <-sem }() + defer func() { + if r := recover(); r != nil { + w.logger.Error("loudness backfill: track panicked", "path", row.FilePath, "panic", r) + } + }() + outcome := storeLoudness(ctx, w.pool, w.logger, row.ID, row.FilePath, + w.analyzeFile(ctx, row.FilePath, row.DurationMs)) + mu.Lock() + res.add(outcome) + mu.Unlock() + }(row) + } + wg.Wait() + after = rows[len(rows)-1].ID + } +} + +func (w *LoudnessBackfillWorker) analyzeFile(ctx context.Context, path string, durationMs int32) loudnessResult { + if w.analyze == nil { + return computeLoudness(ctx, path, durationMs) + } + return w.analyze(ctx, path, durationMs) +} + +// LoudnessCoverage reports how much of the library carries a current +// measurement, for the admin gauge. It lives beside the backfill so the +// version it counts against is the one the backfill writes. +func LoudnessCoverage(ctx context.Context, pool *pgxpool.Pool) (dbq.GetLoudnessCoverageRow, error) { + return dbq.New(pool).GetLoudnessCoverage(ctx, loudnessVersion) +} diff --git a/internal/library/loudness_backfill_test.go b/internal/library/loudness_backfill_test.go new file mode 100644 index 00000000..88451475 --- /dev/null +++ b/internal/library/loudness_backfill_test.go @@ -0,0 +1,243 @@ +package library + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "path/filepath" + "sync" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" +) + +// TestLoudnessBackfill_Integration pins which tracks a pass measures, what it +// stores for each kind of result, that a pass ends, and that the gauge counts +// what the passes wrote. +func TestLoudnessBackfill_Integration(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + q := dbq.New(pool) + dir := t.TempDir() + + _, album, artist := seedTrack(t, pool, filepath.Join(dir, "unmeasured.mp3")) + addTrack := func(name string) dbq.Track { + t.Helper() + tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: name, AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 180000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track %s: %v", name, err) + } + return tr + } + lufs := func(v float32) *float32 { return &v } + current := addTrack("current") + stale := addTrack("stale") + missing := addTrack("missing") + for _, seed := range []struct { + track dbq.Track + version int16 + }{ + {current, loudnessVersion}, + {stale, loudnessVersion - 1}, + } { + if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{ + TrackID: seed.track.ID, IntegratedLufs: lufs(-9), AnalysisVersion: seed.version, + }); err != nil { + t.Fatalf("seed loudness: %v", err) + } + } + if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", missing.ID); err != nil { + t.Fatalf("mark missing: %v", err) + } + + settings, err := NewLoudnessSettingsService(ctx, pool) + if err != nil { + t.Fatalf("loudness settings: %v", err) + } + w := NewLoudnessBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), settings) + // A batch of one forces the keyset cursor across several queries in a pass. + w.batch = 1 + var mu sync.Mutex + calls := map[string]int{} + durations := map[string]int32{} + start := int16(500) + w.analyze = func(_ context.Context, path string, durationMs int32) loudnessResult { + name := filepath.Base(path) + mu.Lock() + calls[name]++ + durations[name] = durationMs + mu.Unlock() + switch name { + case "stall.mp3": + return loudnessResult{err: fmt.Errorf("ffmpeg: %w", errLoudnessTimeout)} + case "corrupt.mp3": + return loudnessResult{err: errors.New("ffmpeg exited 1: invalid data")} + case "silent.mp3": + return loudnessResult{} + default: + return loudnessResult{ + integratedLUFS: lufs(-12.5), truePeakDBTP: lufs(-0.4), rangeLU: lufs(6), + hist: blockHistogram{start: start, counts: []int32{3, 0, 7}}, + } + } + } + callCount := func(name string) int { + mu.Lock() + defer mu.Unlock() + return calls[name] + } + + // 1. Only the unmeasured track and the stale one are measured, never the + // current one or the missing one, and the track's length reaches the + // analyzer (it sets the deadline). + res, err := w.pass(ctx) + if err != nil { + t.Fatalf("first pass: %v", err) + } + if res.Processed != 2 || res.Measured != 2 { + t.Fatalf("first pass = %+v, want 2 processed, 2 measured", res) + } + for name, want := range map[string]int{ + "unmeasured.mp3": 1, "stale.mp3": 1, "current.mp3": 0, "missing.mp3": 0, + } { + if got := callCount(name); got != want { + t.Errorf("%s measured %d times, want %d", name, got, want) + } + } + if durations["stale.mp3"] != 180000 { + t.Errorf("analyzer got duration %d for stale.mp3, want 180000", durations["stale.mp3"]) + } + var ( + gotLUFS, gotPeak *float32 + gotStart *int16 + gotHist []int32 + gotVersion int16 + ) + if err := pool.QueryRow(ctx, `SELECT integrated_lufs, true_peak_dbtp, block_hist_start, block_hist, analysis_version + FROM track_loudness WHERE track_id = $1`, stale.ID). + Scan(&gotLUFS, &gotPeak, &gotStart, &gotHist, &gotVersion); err != nil { + t.Fatalf("read stale row back: %v", err) + } + if gotLUFS == nil || *gotLUFS != -12.5 || gotPeak == nil || *gotPeak != -0.4 || + gotStart == nil || *gotStart != start || len(gotHist) != 3 || gotHist[2] != 7 || + gotVersion != loudnessVersion { + t.Errorf("stale row after re-measuring = lufs %v peak %v hist %v@%v version %d", + gotLUFS, gotPeak, gotHist, gotStart, gotVersion) + } + + // Each stored measurement told the sync feed, so caching clients re-read + // the track and pick up its gain (#4997). + var logged int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes + WHERE entity_type = 'track' AND op = 'upsert' AND entity_id = $1`, + syncpkg.FormatUUID(stale.ID)).Scan(&logged); err != nil { + t.Fatalf("count sync changes: %v", err) + } + if logged != 1 { + t.Errorf("re-measuring stale.mp3 logged %d track changes, want 1", logged) + } + + // 2. A pass after a complete one is a no-op. + res, err = w.pass(ctx) + if err != nil { + t.Fatalf("second pass: %v", err) + } + if res.Processed != 0 { + t.Fatalf("second pass processed %d tracks, want 0", res.Processed) + } + + // 3. A stall is tried once and the pass ends; silence and a corrupt file are + // verdicts, stored and not tried again. + addTrack("stall") + addTrack("corrupt") + silent := addTrack("silent") + res, err = w.pass(ctx) + if err != nil { + t.Fatalf("third pass: %v", err) + } + if res.Processed != 3 || res.Inconclusive != 1 || res.Unreadable != 1 || res.Silent != 1 { + t.Fatalf("third pass = %+v, want 3 processed: 1 inconclusive, 1 unreadable, 1 silent", res) + } + if got := callCount("stall.mp3"); got != 1 { + t.Fatalf("stalling file tried %d times in one pass, want exactly 1", got) + } + var silentHist []int32 + var silentUnreadable bool + if err := pool.QueryRow(ctx, "SELECT block_hist, unreadable FROM track_loudness WHERE track_id = $1", + silent.ID).Scan(&silentHist, &silentUnreadable); err != nil { + t.Fatalf("read silent row: %v", err) + } + if silentHist != nil || silentUnreadable { + t.Errorf("silent row = hist %v unreadable %v, want no histogram and readable", silentHist, silentUnreadable) + } + res, err = w.pass(ctx) + if err != nil { + t.Fatalf("fourth pass: %v", err) + } + if res.Processed != 1 || callCount("stall.mp3") != 2 || callCount("corrupt.mp3") != 1 { + t.Fatalf("fourth pass = %+v; want only the stalled file retried", res) + } + + // 4. The gauge counts what the passes wrote, and its buckets add up. Six + // present tracks: unmeasured, current, stale, stall, corrupt, silent. + cov, err := LoudnessCoverage(ctx, pool) + if err != nil { + t.Fatalf("coverage: %v", err) + } + if cov.Total != 6 || cov.Measured != 3 || cov.Silent != 1 || cov.Unreadable != 1 || cov.Pending != 1 { + t.Errorf("coverage = %+v, want total 6, measured 3, silent 1, unreadable 1, pending 1", cov) + } + if cov.Measured+cov.Silent+cov.Unreadable+cov.Pending != cov.Total { + t.Errorf("coverage buckets %+v do not sum to the total", cov) + } + + // 5. A changed file loses its measurement, so it is measured again. + if err := q.DeleteTrackLoudness(ctx, current.ID); err != nil { + t.Fatalf("delete loudness: %v", err) + } + // 6. Switched off, the backfill does nothing, even with work waiting. + off := DefaultLoudnessSettings + off.Enabled = false + if _, err := settings.Set(ctx, off); err != nil { + t.Fatalf("switch analysis off: %v", err) + } + res, err = w.pass(ctx) + if err != nil { + t.Fatalf("pass with analysis off: %v", err) + } + if res.Processed != 0 || callCount("current.mp3") != 0 { + t.Fatalf("pass with analysis off = %+v, want nothing done", res) + } + if _, err := settings.Set(ctx, DefaultLoudnessSettings); err != nil { + t.Fatalf("switch analysis on: %v", err) + } + res, err = w.pass(ctx) + if err != nil { + t.Fatalf("pass after switching back on: %v", err) + } + if callCount("current.mp3") != 1 { + t.Fatalf("changed file measured %d times after switching back on (pass %+v), want 1", + callCount("current.mp3"), res) + } +} + +// The Go defaults must match the migration's, or a database that cannot be +// read would analyze differently from a fresh install. +func TestLoudnessSettings_DefaultsMatchMigration(t *testing.T) { + pool := newPool(t) + s, err := NewLoudnessSettingsService(context.Background(), pool) + if err != nil { + t.Fatalf("load: %v", err) + } + got := s.Get() + got.UpdatedAt = DefaultLoudnessSettings.UpdatedAt + if got != DefaultLoudnessSettings { + t.Errorf("migration defaults = %+v, Go defaults = %+v", got, DefaultLoudnessSettings) + } +} diff --git a/internal/library/loudness_settings.go b/internal/library/loudness_settings.go new file mode 100644 index 00000000..31beca20 --- /dev/null +++ b/internal/library/loudness_settings.go @@ -0,0 +1,106 @@ +package library + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Loudness analysis settings (M464 #4995). Rule 25: an operator setting is a +// database row, changed without a restart. One instance is shared by the +// backfill and the admin API, so a save reaches the worker at once. + +// LoudnessSettings mirrors the loudness_settings row. +type LoudnessSettings struct { + // Enabled off stops the background analysis. Measured tracks keep their + // values, so normalization keeps working for them. + Enabled bool + BackfillConcurrency int32 + // UpdatedAt is set by the database; ignored by Set. + UpdatedAt time.Time +} + +// DefaultLoudnessSettings mirrors migration 0065's column defaults, so a +// database that cannot be read still analyzes the way a fresh install does. +var DefaultLoudnessSettings = LoudnessSettings{ + Enabled: true, + BackfillConcurrency: loudnessBackfillConcurrency, +} + +// ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's +// CHECK would reject, so the API answers 400 naming the field. +var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range") + +// LoudnessSettingsService caches the settings and owns their persistence. +type LoudnessSettingsService struct { + pool *pgxpool.Pool + + mu sync.RWMutex + cur LoudnessSettings +} + +// NewLoudnessSettingsService loads once and caches. It always returns a usable +// service, holding the defaults when the load fails; the error says so. +func NewLoudnessSettingsService(ctx context.Context, pool *pgxpool.Pool) (*LoudnessSettingsService, error) { + s := &LoudnessSettingsService{pool: pool, cur: DefaultLoudnessSettings} + row, err := dbq.New(pool).GetLoudnessSettings(ctx) + if err != nil { + return s, fmt.Errorf("loudness settings: load: %w", err) + } + s.cur = loudnessSettingsFromRow(row) + return s, nil +} + +// Get returns the cached settings. A nil service answers with the defaults. +func (s *LoudnessSettingsService) Get() LoudnessSettings { + if s == nil { + return DefaultLoudnessSettings + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.cur +} + +// Set validates, persists and re-caches. +func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings) (LoudnessSettings, error) { + if err := validateLoudnessSettings(in); err != nil { + return LoudnessSettings{}, err + } + if s == nil { + return LoudnessSettings{}, errors.New("loudness settings: no settings service") + } + row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{ + Enabled: in.Enabled, + BackfillConcurrency: in.BackfillConcurrency, + }) + if err != nil { + return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err) + } + out := loudnessSettingsFromRow(row) + s.mu.Lock() + s.cur = out + s.mu.Unlock() + return out, nil +} + +func validateLoudnessSettings(in LoudnessSettings) error { + if in.BackfillConcurrency < minBackfillConcurrency || in.BackfillConcurrency > maxBackfillConcurrency { + return fmt.Errorf("%w: backfill_concurrency must be %d-%d", + ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency) + } + return nil +} + +func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings { + return LoudnessSettings{ + Enabled: row.Enabled, + BackfillConcurrency: row.BackfillConcurrency, + UpdatedAt: row.UpdatedAt.Time, + } +} diff --git a/internal/library/loudness_test.go b/internal/library/loudness_test.go new file mode 100644 index 00000000..d2f40704 --- /dev/null +++ b/internal/library/loudness_test.go @@ -0,0 +1,250 @@ +package library + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "os/exec" + "slices" + "strings" + "testing" + "time" +) + +// parseEbur128 runs the parser over a whole log, as computeLoudness does over +// ffmpeg's stderr. +func parseEbur128(log string) *ebur128Parser { + p := newEbur128Parser() + p.consume(strings.NewReader(log)) + return p +} + +// The fixture is real ffmpeg 6.1 output for 12 s of stereo tone whose first +// second is digital silence (testdata/ebur128_fixture.txt). Pinning the parser +// to captured output, not to a hand-written imitation of it, is the point: the +// per-block lines are where a format drift would hide. +func TestEbur128Parser_RealOutput(t *testing.T) { + raw, err := os.ReadFile("testdata/ebur128_fixture.txt") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + r := parseEbur128(string(raw)).result() + if r.err != nil { + t.Fatalf("result err = %v", r.err) + } + for name, c := range map[string]struct { + got *float32 + want float32 + }{ + "integrated": {r.integratedLUFS, -10.7}, + "true peak": {r.truePeakDBTP, -5.6}, + "range": {r.rangeLU, 2.0}, + } { + if c.got == nil || *c.got != c.want { + t.Errorf("%s = %v, want %v", name, c.got, c.want) + } + } + + // 120 blocks, of which the 10 covering the silent second are below the + // absolute gate and not counted. The loudest is -8.1 LUFS, the quietest + // that passed -22.6. + var blocks int32 + for _, c := range r.hist.counts { + blocks += c + } + if blocks != 110 { + t.Errorf("histogram holds %d blocks, want 110", blocks) + } + if r.hist.start != 474 || len(r.hist.counts) != 146 { + t.Errorf("histogram spans bins %d..%d, want 474..619 (-22.6..-8.1 LUFS)", + r.hist.start, int(r.hist.start)+len(r.hist.counts)-1) + } + if r.hist.counts[0] == 0 || r.hist.counts[len(r.hist.counts)-1] == 0 { + t.Errorf("histogram not trimmed to its occupied bins: %v", r.hist.counts) + } + + // Album loudness is computed from these histograms (#4996), so the + // histogram has to reproduce ffmpeg's own figure. + got, ok := r.hist.gatedLoudness() + if !ok || math.Abs(got-(-10.7)) > 0.05 { + t.Errorf("loudness from the histogram = %.3f (ok=%v), want ffmpeg's -10.7 within 0.05", got, ok) + } +} + +func TestEbur128Parser_SilenceIsAVerdictNotAMeasurement(t *testing.T) { + log := `[Parsed_ebur128_0 @ 0x1] t: 0.1 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x1] t: 0.2 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x1] Summary: + + Integrated loudness: + I: -70.0 LUFS + Threshold: 0.0 LUFS + + Loudness range: + LRA: 0.0 LU + Threshold: 0.0 LUFS + LRA low: 0.0 LUFS + LRA high: 0.0 LUFS + + True peak: + Peak: -inf dBFS +` + r := parseEbur128(log).result() + if r.err != nil { + t.Fatalf("err = %v, want a clean result", r.err) + } + if r.integratedLUFS != nil || r.truePeakDBTP != nil || r.rangeLU != nil { + t.Errorf("silence measured as integrated=%v peak=%v range=%v, want all nil", + r.integratedLUFS, r.truePeakDBTP, r.rangeLU) + } + if !r.hist.empty() { + t.Errorf("silence left blocks in the histogram: %+v", r.hist) + } + if r.inconclusive() { + t.Errorf("silence reported as inconclusive; it is settled until the file changes") + } +} + +func TestEbur128Parser_NoSummaryIsUnreadable(t *testing.T) { + r := parseEbur128("[in#0 @ 0x1] Error opening input: Invalid data found when processing input\n").result() + if r.err == nil { + t.Fatal("a log with no summary produced a measurement") + } + if r.inconclusive() { + t.Errorf("err %v reads as inconclusive; ffmpeg ran and found nothing to measure", r.err) + } + if !strings.Contains(r.err.Error(), "Invalid data found") { + t.Errorf("err %q does not carry ffmpeg's reason", r.err) + } +} + +func TestEbur128Parser_LoudBlocksLandInTheTopBin(t *testing.T) { + p := parseEbur128(`[Parsed_ebur128_0 @ 0x1] t: 0.4 TARGET:-23 LUFS M: 12.4 S: 12.4 I: 12.4 LUFS +[Parsed_ebur128_0 @ 0x1] t: 0.5 TARGET:-23 LUFS M: -5.0 S: -5.0 I: -5.0 LUFS +`) + h := p.histogram() + if int(h.start)+len(h.counts) != loudnessHistBins || h.counts[len(h.counts)-1] != 1 { + t.Errorf("a +12.4 LUFS block was not counted in the top bin: %+v", h) + } + if h.start != 650 || h.counts[0] != 1 { + t.Errorf("a -5.0 LUFS block is not in bin 650: start %d, first %d", h.start, h.counts[0]) + } +} + +func TestBlockHistogram_GatedLoudness(t *testing.T) { + bin := func(lufs float64) int { return int(math.Round((lufs - loudnessHistFloor) * loudnessHistPerLU)) } + hist := func(blocks map[float64]int32) blockHistogram { + lo, hi := loudnessHistBins, 0 + for l := range blocks { + lo, hi = min(lo, bin(l)), max(hi, bin(l)) + } + h := blockHistogram{start: int16(lo), counts: make([]int32, hi-lo+1)} + for l, n := range blocks { + h.counts[bin(l)-lo] = n + } + return h + } + + cases := []struct { + name string + blocks map[float64]int32 + want float64 + }{ + // One level throughout is that level. + {"steady", map[float64]int32{-14: 50}, -14}, + // The quiet half is 30 LU below the loud half, past the relative gate + // (10 LU below the ungated loudness, here about -13), so it is dropped + // and the result is the loud half alone. + {"quiet passage gated out", map[float64]int32{-10: 100, -40: 100}, -10}, + // 6 LU apart is inside the gate, so both count, energy-weighted: the + // result sits nearer the louder level than the midpoint (-15) does. + {"both inside the gate", map[float64]int32{-12: 100, -18: 100}, -14.037}, + } + for _, c := range cases { + got, ok := hist(c.blocks).gatedLoudness() + if !ok || math.Abs(got-c.want) > 0.01 { + t.Errorf("%s: gatedLoudness = %.3f (ok=%v), want %.3f", c.name, got, ok, c.want) + } + } + if _, ok := (blockHistogram{}).gatedLoudness(); ok { + t.Errorf("an empty histogram reported a loudness") + } +} + +func TestEbur128Args(t *testing.T) { + args := ebur128Args("/music/a.flac") + joined := strings.Join(args, " ") + for _, want := range []string{"peak=true", "dualmono=true", "framelog=info", "-map 0:a:0", "-nostdin", "-f null -"} { + if !strings.Contains(joined, want) { + t.Errorf("args %q lack %q", joined, want) + } + } + // The path is its own argument, never spliced into the filter string. + if i := slices.Index(args, "-i"); i < 0 || args[i+1] != "/music/a.flac" { + t.Errorf("args %q do not pass the path after -i", args) + } +} + +func TestLoudnessTimeout_ScalesWithLength(t *testing.T) { + if got := loudnessTimeout(0); got != loudnessBaseTimeout { + t.Errorf("unknown length: %s, want the base %s", got, loudnessBaseTimeout) + } + if got, want := loudnessTimeout(int32((2 * time.Hour).Milliseconds())), loudnessBaseTimeout+30*time.Minute; got != want { + t.Errorf("two-hour mix: %s, want %s", got, want) + } + if got := loudnessTimeout(-5); got != loudnessBaseTimeout { + t.Errorf("negative length: %s, want the base %s", got, loudnessBaseTimeout) + } +} + +func TestLoudnessResult_Inconclusive(t *testing.T) { + for _, err := range []error{ + fmt.Errorf("ffmpeg: %w", errLoudnessTimeout), + fmt.Errorf("ffmpeg: %w", context.Canceled), + fmt.Errorf("ffmpeg: %w", exec.ErrNotFound), + } { + if !(loudnessResult{err: err}).inconclusive() { + t.Errorf("%v: not inconclusive, so it would be stored as a verdict", err) + } + } + if (loudnessResult{err: errors.New("ffmpeg exited 1: moov atom not found")}).inconclusive() { + t.Errorf("a decode failure read as inconclusive; it would be retried every pass") + } + if (loudnessResult{}).inconclusive() { + t.Errorf("a clean result read as inconclusive") + } +} + +func TestBackfillLoudnessResult_Add(t *testing.T) { + var r BackfillLoudnessResult + for _, o := range []loudnessOutcome{ + loudnessMeasured, loudnessMeasured, loudnessSilent, loudnessUnreadable, + loudnessInconclusive, loudnessStoreFailed, + } { + r.add(o) + } + // A failed write stored nothing, so it is retried like an inconclusive one. + want := BackfillLoudnessResult{Processed: 6, Measured: 2, Silent: 1, Unreadable: 1, Inconclusive: 2} + if r != want { + t.Errorf("tally = %+v, want %+v", r, want) + } +} + +func TestValidateLoudnessSettings(t *testing.T) { + for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} { + if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil { + t.Errorf("concurrency %d rejected: %v", n, err) + } + } + for _, n := range []int32{0, maxBackfillConcurrency + 1} { + if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) { + t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err) + } + } + var nilSvc *LoudnessSettingsService + if got := nilSvc.Get(); got != DefaultLoudnessSettings { + t.Errorf("nil service Get = %+v, want the defaults", got) + } +} diff --git a/internal/library/replaygain.go b/internal/library/replaygain.go new file mode 100644 index 00000000..8c98ae51 --- /dev/null +++ b/internal/library/replaygain.go @@ -0,0 +1,102 @@ +package library + +import ( + "context" + "math" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ReplayGain delivery (M464 #4997). +// +// Clients are handed loudness in the ReplayGain 2.0 form every player already +// understands: a gain in dB that brings the track (or album) to the RG2 +// reference of -18 LUFS, and a linear peak. A client aiming at another target +// adds (target - ReplayGainReferenceLUFS) to the gain. The same numbers go to +// the native API, the sync feed and OpenSubsonic's replayGain, so every client +// levels a track identically. + +// ReplayGainReferenceLUFS is the RG2 reference loudness gains are relative to. +const ReplayGainReferenceLUFS = -18.0 + +// ReplayGain is one track's gains. Each field is nil when there is nothing to +// say: not yet measured, silent, or (for the album pair) an album still +// waiting on a track. AlbumPeak travels only with AlbumGain. +type ReplayGain struct { + TrackGain *float32 `json:"track_gain,omitempty"` + TrackPeak *float32 `json:"track_peak,omitempty"` + AlbumGain *float32 `json:"album_gain,omitempty"` + AlbumPeak *float32 `json:"album_peak,omitempty"` +} + +// Empty reports whether no gain is known at all. +func (g ReplayGain) Empty() bool { + return g.TrackGain == nil && g.AlbumGain == nil +} + +// GainDB converts an integrated loudness to its RG2 gain, to 0.01 dB. +func GainDB(lufs *float32) *float32 { + if lufs == nil { + return nil + } + v := float32(math.Round((ReplayGainReferenceLUFS-float64(*lufs))*100) / 100) + return &v +} + +// LinearPeak converts a true peak in dBTP to the linear amplitude ReplayGain +// peaks are written in (1.0 is full scale), to 4 decimals. +func LinearPeak(dbtp *float32) *float32 { + if dbtp == nil { + return nil + } + v := float32(math.Round(math.Pow(10, float64(*dbtp)/20)*10000) / 10000) + return &v +} + +// ReplayGainForTracks returns the gains for each of ids that has any. Tracks +// with none are absent from the map, so a lookup of a missing key yields the +// zero ReplayGain: no adjustment. +func ReplayGainForTracks(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) { + out := make(map[pgtype.UUID]ReplayGain, len(ids)) + if len(ids) == 0 { + return out, nil + } + rows, err := q.GetReplayGainByTrackIDs(ctx, ids) + if err != nil { + return nil, err + } + for _, r := range rows { + g := ReplayGain{TrackGain: GainDB(r.TrackLufs)} + if g.TrackGain != nil { + g.TrackPeak = LinearPeak(r.TrackPeakDbtp) + } + if g.AlbumGain = GainDB(r.AlbumLufs); g.AlbumGain != nil { + g.AlbumPeak = LinearPeak(r.AlbumPeakDbtp) + } + if !g.Empty() { + out[r.ID] = g + } + } + return out, nil +} + +// ReplayGainForAlbums returns the album pair for each of albumIDs that has an +// album loudness. The track fields are left nil. +func ReplayGainForAlbums(ctx context.Context, q *dbq.Queries, albumIDs []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) { + out := make(map[pgtype.UUID]ReplayGain, len(albumIDs)) + if len(albumIDs) == 0 { + return out, nil + } + rows, err := q.GetAlbumLoudnessByIDs(ctx, albumIDs) + if err != nil { + return nil, err + } + for _, r := range rows { + if g := GainDB(r.IntegratedLufs); g != nil { + out[r.AlbumID] = ReplayGain{AlbumGain: g, AlbumPeak: LinearPeak(r.TruePeakDbtp)} + } + } + return out, nil +} diff --git a/internal/library/replaygain_test.go b/internal/library/replaygain_test.go new file mode 100644 index 00000000..ae18d735 --- /dev/null +++ b/internal/library/replaygain_test.go @@ -0,0 +1,122 @@ +package library + +import ( + "context" + "math" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func TestGainDBAndLinearPeak(t *testing.T) { + f := func(v float32) *float32 { return &v } + for _, c := range []struct { + lufs, want float32 + }{ + {-18, 0}, // at the reference: no change + {-9.5, -8.5}, // a loud master is turned down + {-23.456, 5.46}, // a quiet one up, to 0.01 dB + } { + if got := GainDB(f(c.lufs)); got == nil || *got != c.want { + t.Errorf("GainDB(%v) = %v, want %v", c.lufs, got, c.want) + } + } + if GainDB(nil) != nil { + t.Errorf("GainDB(nil) is not nil") + } + for _, c := range []struct { + dbtp, want float32 + }{ + {0, 1}, // full scale + {-6.0206, 0.5}, // half amplitude + {1.2, 1.1482}, // an inter-sample over, above 1.0 + } { + got := LinearPeak(f(c.dbtp)) + if got == nil || math.Abs(float64(*got-c.want)) > 0.0001 { + t.Errorf("LinearPeak(%v) = %v, want %v", c.dbtp, got, c.want) + } + } + if LinearPeak(nil) != nil { + t.Errorf("LinearPeak(nil) is not nil") + } +} + +func TestReplayGainForTracks_Integration(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + q := dbq.New(pool) + dir := t.TempDir() + + measured, album, artist := seedTrack(t, pool, filepath.Join(dir, "measured.mp3")) + add := func(name string) dbq.Track { + t.Helper() + tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: name, AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track %s: %v", name, err) + } + return tr + } + older := add("older") + unmeasured := add("unmeasured") + f := func(v float32) *float32 { return &v } + for _, m := range []struct { + tr dbq.Track + lufs float32 + version int16 + }{ + {measured, -12, loudnessVersion}, + // A measurement by an older method is still a better gain than none. + {older, -20, loudnessVersion - 1}, + } { + if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{ + TrackID: m.tr.ID, IntegratedLufs: f(m.lufs), TruePeakDbtp: f(-1), AnalysisVersion: m.version, + }); err != nil { + t.Fatalf("seed: %v", err) + } + } + + gains, err := ReplayGainForTracks(ctx, q, []pgtype.UUID{measured.ID, older.ID, unmeasured.ID}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if g := gains[measured.ID]; g.TrackGain == nil || *g.TrackGain != -6 || g.TrackPeak == nil { + t.Errorf("measured track gains = %+v, want track gain -6 with a peak", g) + } + if g := gains[older.ID]; g.TrackGain == nil || *g.TrackGain != 2 { + t.Errorf("older-version track gains = %+v, want track gain 2", g) + } + if _, ok := gains[unmeasured.ID]; ok { + t.Errorf("unmeasured track has an entry; absent means no adjustment") + } + // No album value yet (none computed): the album pair is absent. + if g := gains[measured.ID]; g.AlbumGain != nil || g.AlbumPeak != nil { + t.Errorf("album pair present before album loudness exists: %+v", g) + } + + if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{ + AlbumID: album.ID, IntegratedLufs: f(-14), TruePeakDbtp: f(-0.5), + TracksTotal: 3, TracksSettled: 3, InputsDigest: "x", + }); err != nil { + t.Fatalf("seed album: %v", err) + } + gains, err = ReplayGainForTracks(ctx, q, []pgtype.UUID{unmeasured.ID}) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if g := gains[unmeasured.ID]; g.TrackGain != nil || g.AlbumGain == nil || *g.AlbumGain != -4 || g.AlbumPeak == nil { + t.Errorf("unmeasured track on a leveled album = %+v, want only the album pair (gain -4)", g) + } + byAlbum, err := ReplayGainForAlbums(ctx, q, []pgtype.UUID{album.ID}) + if err != nil { + t.Fatalf("album lookup: %v", err) + } + if g := byAlbum[album.ID]; g.AlbumGain == nil || *g.AlbumGain != -4 || g.TrackGain != nil { + t.Errorf("album gains = %+v, want album gain -4 and no track pair", g) + } +} diff --git a/internal/library/scanner.go b/internal/library/scanner.go index f2c00c32..c7d45dcd 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -402,6 +402,13 @@ func (s *Scanner) scanFile( // must not outlive them, or the sweep would compare audio that is gone. s.logger.Warn("fingerprint: clearing stale fingerprint failed", "path", path, "err", err) } + // Loudness is measured by its own worker, never inline: it decodes the + // whole file (see loudness_backfill.go). The scan's part is to drop a + // measurement of bytes that are gone, so clients stop leveling this + // track by the old file's loudness and the worker measures it again. + if err := q.DeleteTrackLoudness(ctx, track.ID); err != nil { + s.logger.Warn("loudness: clearing stale measurement failed", "path", path, "err", err) + } } if knownTrack { diff --git a/internal/library/testdata/ebur128_fixture.txt b/internal/library/testdata/ebur128_fixture.txt new file mode 100644 index 00000000..cf6eeb3f --- /dev/null +++ b/internal/library/testdata/ebur128_fixture.txt @@ -0,0 +1,149 @@ +Input #0, flac, from 'fixture.flac': + Metadata: + encoder : Lavf60.16.100 + Duration: 00:00:12.00, start: 0.000000, bitrate: 789 kb/s + Stream #0:0: Audio: flac, 44100 Hz, stereo, s32 (24 bit) +Stream mapping: + Stream #0:0 -> #0:0 (flac (native) -> pcm_s16le (native)) +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.0999773 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +Output #0, null, to 'pipe:': + Metadata: + encoder : Lavf60.16.100 + Stream #0:0: Audio: pcm_s16le, 44100 Hz, stereo, s16, 1411 kb/s + Metadata: + encoder : Lavc60.31.102 pcm_s16le +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.199977 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.299977 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.399977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.499977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.599977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.699977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.799977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.899977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.999977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.09998 TARGET:-23 LUFS M: -15.4 S:-120.7 I: -15.4 LUFS LRA: 0.0 LU FTPK: -7.1 -10.7 dBFS TPK: -7.1 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.19998 TARGET:-23 LUFS M: -12.2 S:-120.7 I: -13.5 LUFS LRA: 0.0 LU FTPK: -6.6 -10.9 dBFS TPK: -6.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.29998 TARGET:-23 LUFS M: -10.3 S:-120.7 I: -12.2 LUFS LRA: 0.0 LU FTPK: -6.3 -11.1 dBFS TPK: -6.3 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.39998 TARGET:-23 LUFS M: -9.0 S:-120.7 I: -11.1 LUFS LRA: 0.0 LU FTPK: -6.0 -11.4 dBFS TPK: -6.0 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.49998 TARGET:-23 LUFS M: -8.8 S:-120.7 I: -10.6 LUFS LRA: 0.0 LU FTPK: -5.8 -11.7 dBFS TPK: -5.8 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.59998 TARGET:-23 LUFS M: -8.7 S:-120.7 I: -10.2 LUFS LRA: 0.0 LU FTPK: -5.7 -12.1 dBFS TPK: -5.7 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.69998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.9 LUFS LRA: 0.0 LU FTPK: -5.6 -12.4 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.79998 TARGET:-23 LUFS M: -8.5 S:-120.7 I: -9.7 LUFS LRA: 0.0 LU FTPK: -5.6 -12.8 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.89998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.6 LUFS LRA: 0.0 LU FTPK: -5.6 -13.3 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.99998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -5.7 -13.8 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.09998 TARGET:-23 LUFS M: -8.8 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -5.8 -14.3 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.19998 TARGET:-23 LUFS M: -8.9 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -6.0 -14.9 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.29998 TARGET:-23 LUFS M: -9.2 S:-120.7 I: -9.3 LUFS LRA: 0.0 LU FTPK: -6.3 -15.6 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.39998 TARGET:-23 LUFS M: -9.5 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -6.7 -16.3 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.49998 TARGET:-23 LUFS M: -9.9 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -7.1 -17.2 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.59998 TARGET:-23 LUFS M: -10.4 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -7.7 -18.1 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.69998 TARGET:-23 LUFS M: -11.0 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -8.3 -19.2 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.79998 TARGET:-23 LUFS M: -11.6 S:-120.7 I: -9.6 LUFS LRA: 0.0 LU FTPK: -9.1 -20.4 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.89998 TARGET:-23 LUFS M: -12.4 S:-120.7 I: -9.7 LUFS LRA: 0.0 LU FTPK: -10.0 -21.8 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.99998 TARGET:-23 LUFS M: -13.4 S: -11.5 I: -9.9 LUFS LRA: 20.0 LU FTPK: -11.1 -23.6 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.09998 TARGET:-23 LUFS M: -14.5 S: -11.5 I: -10.0 LUFS LRA: 20.0 LU FTPK: -12.5 -25.8 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.19998 TARGET:-23 LUFS M: -15.8 S: -11.4 I: -10.1 LUFS LRA: 20.0 LU FTPK: -14.1 -28.2 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.29998 TARGET:-23 LUFS M: -17.3 S: -11.4 I: -10.3 LUFS LRA: 20.0 LU FTPK: -16.2 -25.4 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.39998 TARGET:-23 LUFS M: -19.1 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -19.2 -23.3 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.49998 TARGET:-23 LUFS M: -21.1 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -23.7 -21.6 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.59998 TARGET:-23 LUFS M: -22.6 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -22.6 -20.2 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.69998 TARGET:-23 LUFS M: -22.5 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -18.5 -19.0 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.79998 TARGET:-23 LUFS M: -20.9 S: -11.3 I: -10.6 LUFS LRA: 0.2 LU FTPK: -15.8 -17.9 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.89998 TARGET:-23 LUFS M: -18.9 S: -11.3 I: -10.9 LUFS LRA: 0.2 LU FTPK: -13.8 -17.0 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.99998 TARGET:-23 LUFS M: -17.1 S: -11.2 I: -11.0 LUFS LRA: 0.2 LU FTPK: -12.2 -16.2 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.09998 TARGET:-23 LUFS M: -15.6 S: -11.4 I: -11.1 LUFS LRA: 0.2 LU FTPK: -10.9 -15.5 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.19998 TARGET:-23 LUFS M: -14.3 S: -11.5 I: -11.2 LUFS LRA: 0.2 LU FTPK: -9.8 -14.8 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.29998 TARGET:-23 LUFS M: -13.2 S: -11.7 I: -11.3 LUFS LRA: 0.4 LU FTPK: -8.9 -14.2 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.39998 TARGET:-23 LUFS M: -12.3 S: -11.8 I: -11.3 LUFS LRA: 0.4 LU FTPK: -8.2 -13.7 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.49998 TARGET:-23 LUFS M: -11.5 S: -11.9 I: -11.3 LUFS LRA: 0.5 LU FTPK: -7.6 -13.2 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.59998 TARGET:-23 LUFS M: -10.8 S: -12.0 I: -11.3 LUFS LRA: 0.6 LU FTPK: -7.0 -12.8 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.69998 TARGET:-23 LUFS M: -10.2 S: -12.0 I: -11.2 LUFS LRA: 0.7 LU FTPK: -6.6 -12.4 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.79998 TARGET:-23 LUFS M: -9.7 S: -12.1 I: -11.2 LUFS LRA: 0.7 LU FTPK: -6.3 -12.0 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.89998 TARGET:-23 LUFS M: -9.3 S: -12.1 I: -11.1 LUFS LRA: 0.8 LU FTPK: -6.0 -11.7 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.99998 TARGET:-23 LUFS M: -8.9 S: -12.1 I: -11.0 LUFS LRA: 0.8 LU FTPK: -5.8 -11.4 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.09998 TARGET:-23 LUFS M: -8.6 S: -12.0 I: -11.0 LUFS LRA: 0.8 LU FTPK: -5.7 -11.1 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.19998 TARGET:-23 LUFS M: -8.4 S: -11.9 I: -10.8 LUFS LRA: 0.8 LU FTPK: -5.6 -10.8 dBFS TPK: -5.6 -10.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.29998 TARGET:-23 LUFS M: -8.3 S: -11.8 I: -10.7 LUFS LRA: 0.8 LU FTPK: -5.6 -10.6 dBFS TPK: -5.6 -10.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.39998 TARGET:-23 LUFS M: -8.2 S: -11.7 I: -10.5 LUFS LRA: 0.8 LU FTPK: -5.6 -10.4 dBFS TPK: -5.6 -10.4 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.49998 TARGET:-23 LUFS M: -8.1 S: -11.5 I: -10.4 LUFS LRA: 0.8 LU FTPK: -5.7 -10.2 dBFS TPK: -5.6 -10.2 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.59998 TARGET:-23 LUFS M: -8.1 S: -11.4 I: -10.4 LUFS LRA: 0.8 LU FTPK: -5.8 -10.1 dBFS TPK: -5.6 -10.1 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.69998 TARGET:-23 LUFS M: -8.2 S: -11.2 I: -10.3 LUFS LRA: 0.8 LU FTPK: -6.0 -10.0 dBFS TPK: -5.6 -10.0 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.79998 TARGET:-23 LUFS M: -8.3 S: -11.1 I: -10.2 LUFS LRA: 0.8 LU FTPK: -6.3 -9.9 dBFS TPK: -5.6 -9.9 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.89998 TARGET:-23 LUFS M: -8.4 S: -10.9 I: -10.2 LUFS LRA: 1.0 LU FTPK: -6.7 -9.8 dBFS TPK: -5.6 -9.8 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.99998 TARGET:-23 LUFS M: -8.7 S: -10.7 I: -10.2 LUFS LRA: 1.0 LU FTPK: -7.2 -9.7 dBFS TPK: -5.6 -9.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.09998 TARGET:-23 LUFS M: -8.9 S: -10.6 I: -10.1 LUFS LRA: 1.2 LU FTPK: -7.7 -9.7 dBFS TPK: -5.6 -9.7 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.19998 TARGET:-23 LUFS M: -9.2 S: -10.4 I: -10.1 LUFS LRA: 1.3 LU FTPK: -8.4 -9.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.29998 TARGET:-23 LUFS M: -9.6 S: -10.3 I: -10.1 LUFS LRA: 1.5 LU FTPK: -9.2 -9.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.39998 TARGET:-23 LUFS M: -10.0 S: -10.2 I: -10.1 LUFS LRA: 1.6 LU FTPK: -10.1 -9.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.49998 TARGET:-23 LUFS M: -10.4 S: -10.0 I: -10.1 LUFS LRA: 1.8 LU FTPK: -11.2 -9.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.59998 TARGET:-23 LUFS M: -10.9 S: -10.0 I: -10.1 LUFS LRA: 1.9 LU FTPK: -12.6 -9.7 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.69998 TARGET:-23 LUFS M: -11.4 S: -9.9 I: -10.1 LUFS LRA: 2.0 LU FTPK: -14.3 -9.7 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.79998 TARGET:-23 LUFS M: -11.9 S: -9.8 I: -10.2 LUFS LRA: 2.1 LU FTPK: -16.5 -9.8 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.89998 TARGET:-23 LUFS M: -12.5 S: -9.8 I: -10.2 LUFS LRA: 2.1 LU FTPK: -19.5 -9.9 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.99998 TARGET:-23 LUFS M: -12.9 S: -9.8 I: -10.2 LUFS LRA: 2.2 LU FTPK: -24.3 -10.0 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.09998 TARGET:-23 LUFS M: -13.3 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -22.2 -10.1 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.19998 TARGET:-23 LUFS M: -13.5 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -18.2 -10.3 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.29998 TARGET:-23 LUFS M: -13.5 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -15.6 -10.5 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.39998 TARGET:-23 LUFS M: -13.3 S: -9.9 I: -10.4 LUFS LRA: 2.2 LU FTPK: -13.6 -10.7 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.49998 TARGET:-23 LUFS M: -13.0 S: -9.9 I: -10.4 LUFS LRA: 2.2 LU FTPK: -12.0 -10.9 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.59998 TARGET:-23 LUFS M: -12.6 S: -10.0 I: -10.4 LUFS LRA: 2.2 LU FTPK: -10.8 -11.2 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.69998 TARGET:-23 LUFS M: -12.1 S: -10.0 I: -10.5 LUFS LRA: 2.2 LU FTPK: -9.7 -11.5 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.79998 TARGET:-23 LUFS M: -11.7 S: -10.1 I: -10.5 LUFS LRA: 2.2 LU FTPK: -8.9 -11.8 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.89998 TARGET:-23 LUFS M: -11.2 S: -10.1 I: -10.5 LUFS LRA: 2.2 LU FTPK: -8.1 -12.1 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.99998 TARGET:-23 LUFS M: -10.8 S: -10.2 I: -10.5 LUFS LRA: 2.2 LU FTPK: -7.5 -12.5 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.09998 TARGET:-23 LUFS M: -10.4 S: -10.3 I: -10.5 LUFS LRA: 2.2 LU FTPK: -7.0 -12.9 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.19998 TARGET:-23 LUFS M: -10.1 S: -10.4 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.6 -13.4 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.29998 TARGET:-23 LUFS M: -9.8 S: -10.4 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.2 -13.9 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.39998 TARGET:-23 LUFS M: -9.5 S: -10.5 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.0 -14.4 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.49998 TARGET:-23 LUFS M: -9.3 S: -10.5 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.8 -15.0 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.59998 TARGET:-23 LUFS M: -9.2 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.7 -15.7 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.69998 TARGET:-23 LUFS M: -9.1 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -16.5 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.79998 TARGET:-23 LUFS M: -9.0 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -17.3 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.89998 TARGET:-23 LUFS M: -9.0 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -18.3 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.99998 TARGET:-23 LUFS M: -9.1 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -5.7 -19.4 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.09998 TARGET:-23 LUFS M: -9.2 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -5.9 -20.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.19998 TARGET:-23 LUFS M: -9.4 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -6.1 -22.1 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.29998 TARGET:-23 LUFS M: -9.7 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -6.4 -23.9 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.39998 TARGET:-23 LUFS M: -10.0 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -6.7 -26.3 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.49998 TARGET:-23 LUFS M: -10.4 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -7.2 -27.7 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.59998 TARGET:-23 LUFS M: -10.9 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -7.8 -25.0 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.69998 TARGET:-23 LUFS M: -11.4 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -8.4 -23.0 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.79998 TARGET:-23 LUFS M: -12.0 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -9.2 -21.3 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.89998 TARGET:-23 LUFS M: -12.7 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -10.2 -20.0 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.99998 TARGET:-23 LUFS M: -13.5 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -11.3 -18.8 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.1 TARGET:-23 LUFS M: -14.4 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -12.7 -17.8 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.2 TARGET:-23 LUFS M: -15.3 S: -10.8 I: -10.4 LUFS LRA: 2.1 LU FTPK: -14.5 -16.9 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.3 TARGET:-23 LUFS M: -16.2 S: -10.8 I: -10.5 LUFS LRA: 2.1 LU FTPK: -16.7 -16.1 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.4 TARGET:-23 LUFS M: -17.1 S: -10.9 I: -10.5 LUFS LRA: 2.1 LU FTPK: -19.8 -15.4 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.5 TARGET:-23 LUFS M: -17.8 S: -11.0 I: -10.6 LUFS LRA: 2.1 LU FTPK: -24.9 -14.7 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.6 TARGET:-23 LUFS M: -18.0 S: -11.1 I: -10.6 LUFS LRA: 2.1 LU FTPK: -21.7 -14.1 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.7 TARGET:-23 LUFS M: -17.6 S: -11.1 I: -10.6 LUFS LRA: 2.1 LU FTPK: -17.9 -13.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.8 TARGET:-23 LUFS M: -16.7 S: -11.2 I: -10.7 LUFS LRA: 2.1 LU FTPK: -15.4 -13.1 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.9 TARGET:-23 LUFS M: -15.7 S: -11.3 I: -10.7 LUFS LRA: 2.1 LU FTPK: -13.4 -12.7 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11 TARGET:-23 LUFS M: -14.6 S: -11.4 I: -10.7 LUFS LRA: 2.1 LU FTPK: -11.9 -12.3 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.1 TARGET:-23 LUFS M: -13.6 S: -11.5 I: -10.8 LUFS LRA: 2.1 LU FTPK: -10.7 -11.9 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.2 TARGET:-23 LUFS M: -12.7 S: -11.6 I: -10.8 LUFS LRA: 2.1 LU FTPK: -9.6 -11.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.3 TARGET:-23 LUFS M: -11.8 S: -11.7 I: -10.8 LUFS LRA: 2.1 LU FTPK: -8.8 -11.3 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.4 TARGET:-23 LUFS M: -11.1 S: -11.7 I: -10.8 LUFS LRA: 2.0 LU FTPK: -8.1 -11.0 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.5 TARGET:-23 LUFS M: -10.5 S: -11.7 I: -10.8 LUFS LRA: 2.0 LU FTPK: -7.4 -10.8 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.6 TARGET:-23 LUFS M: -9.9 S: -11.8 I: -10.8 LUFS LRA: 2.0 LU FTPK: -6.9 -10.6 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.7 TARGET:-23 LUFS M: -9.5 S: -11.8 I: -10.8 LUFS LRA: 2.0 LU FTPK: -6.5 -10.4 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.8 TARGET:-23 LUFS M: -9.0 S: -11.7 I: -10.7 LUFS LRA: 2.0 LU FTPK: -6.2 -10.2 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.9 TARGET:-23 LUFS M: -8.7 S: -11.7 I: -10.7 LUFS LRA: 2.0 LU FTPK: -5.9 -10.1 dBFS TPK: -5.6 -9.6 dBFS +[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 12 TARGET:-23 LUFS M: -8.4 S: -11.6 I: -10.7 LUFS LRA: 2.0 LU FTPK: -5.8 -10.0 dBFS TPK: -5.6 -9.6 dBFS +[out#0/null @ 0x5ebc43345c00] video:0kB audio:2067kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown +size=N/A time=00:00:11.90 bitrate=N/A speed= 161x +[Parsed_ebur128_0 @ 0x5ebc43357b00] Summary: + + Integrated loudness: + I: -10.7 LUFS + Threshold: -20.8 LUFS + + Loudness range: + LRA: 2.0 LU + Threshold: -30.9 LUFS + LRA low: -11.8 LUFS + LRA high: -9.8 LUFS + + True peak: + Peak: -5.6 dBFS diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go index 8129b01a..cfa21359 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/lidarrquarantine/service_test.go @@ -46,7 +46,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: false, + ApiTokenHash: name + "-token", IsAdmin: false, }) if err != nil { t.Fatalf("seed user %s: %v", name, err) diff --git a/internal/lidarrrequests/service_test.go b/internal/lidarrrequests/service_test.go index 49891988..c1c90149 100644 --- a/internal/lidarrrequests/service_test.go +++ b/internal/lidarrrequests/service_test.go @@ -50,7 +50,7 @@ func newPool(t *testing.T) *pgxpool.Pool { func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false, }) if err != nil { t.Fatalf("seed user: %v", err) @@ -206,7 +206,7 @@ func TestListForUser_OnlyOwnRows(t *testing.T) { pool := newPool(t) alice := seedUser(t, pool) bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "x2", IsAdmin: false, + Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "x2", IsAdmin: false, }) if err != nil { t.Fatalf("seed bob: %v", err) diff --git a/internal/netsettings/publicurl_test.go b/internal/netsettings/publicurl_test.go new file mode 100644 index 00000000..1deebde5 --- /dev/null +++ b/internal/netsettings/publicurl_test.go @@ -0,0 +1,34 @@ +package netsettings + +import "testing" + +func TestNormalizePublicURL(t *testing.T) { + ok := map[string]string{ + "": "", + " ": "", + "https://music.example.com": "https://music.example.com", + "https://music.example.com/": "https://music.example.com", + "http://192.168.1.10:4533": "http://192.168.1.10:4533", + " https://Music.Example.com/ ": "https://Music.Example.com", + } + for in, want := range ok { + got, err := NormalizePublicURL(in) + if err != nil || got != want { + t.Errorf("NormalizePublicURL(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, in := range []string{ + "music.example.com", // no scheme + "ftp://music.example.com", // wrong scheme + "https://", // no host + "https://music.example.com/app", // path + "https://music.example.com/?x=1", // query + "https://music.example.com/#frag", // fragment + "https://user:pw@music.example.com", + "javascript:alert(1)", + } { + if _, err := NormalizePublicURL(in); err == nil { + t.Errorf("NormalizePublicURL(%q) accepted, want ErrInvalidPublicURL", in) + } + } +} diff --git a/internal/netsettings/service.go b/internal/netsettings/service.go index 9b526178..49d23d1b 100644 --- a/internal/netsettings/service.go +++ b/internal/netsettings/service.go @@ -12,6 +12,8 @@ import ( "context" "errors" "log/slog" + "net/url" + "strings" "sync" "github.com/jackc/pgx/v5/pgxpool" @@ -32,13 +34,18 @@ const ( // so the API layer can answer 400 instead of surfacing a constraint violation. var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10") +// ErrInvalidPublicURL is returned by SetPublicURL for anything that isn't a +// bare http(s) origin, so the API layer can answer 400. +var ErrInvalidPublicURL = errors.New("public URL must be an http:// or https:// address with a host and no path, query or fragment") + // Service caches the network settings and owns their persistence. type Service struct { pool *pgxpool.Pool logger *slog.Logger - mu sync.RWMutex - hops int + mu sync.RWMutex + hops int + publicURL string } // New loads the settings once and caches them. @@ -58,6 +65,7 @@ func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service return s, err } s.hops = int(row.TrustedProxyHops) + s.publicURL = row.PublicUrl return s, nil } @@ -106,3 +114,53 @@ func (s *Service) SetHops(ctx context.Context, hops int) error { } return nil } + +// PublicURL returns the operator-set address users reach Minstrel at, with no +// trailing slash, or "" when it hasn't been set. Links that leave the app (a +// password-reset email) are built from this and never from the request's +// Host header, which the requester controls. Nil-safe like Hops. +func (s *Service) PublicURL() string { + if s == nil { + return "" + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.publicURL +} + +// NormalizePublicURL validates raw as a bare http(s) origin and returns it +// without a trailing slash. "" is valid and means unset. +func NormalizePublicURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || + u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "", ErrInvalidPublicURL + } + return u.Scheme + "://" + u.Host, nil +} + +// SetPublicURL validates, persists and caches the public URL. "" clears it. +func (s *Service) SetPublicURL(ctx context.Context, raw string) error { + normalized, err := NormalizePublicURL(raw) + if err != nil { + return err + } + if s == nil || s.pool == nil { + return errors.New("network settings unavailable") + } + row, err := dbq.New(s.pool).UpdatePublicURL(ctx, normalized) + if err != nil { + return err + } + s.mu.Lock() + s.publicURL = row.PublicUrl + s.mu.Unlock() + if s.logger != nil { + s.logger.Info("netsettings: public URL updated", "public_url", normalized) + } + return nil +} diff --git a/internal/playevents/writer_test.go b/internal/playevents/writer_test.go index bab96826..24893d50 100644 --- a/internal/playevents/writer_test.go +++ b/internal/playevents/writer_test.go @@ -51,7 +51,7 @@ func newFixture(t *testing.T, durationMs int32) fixture { pool := testPool(t) q := dbq.New(pool) u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) diff --git a/internal/playlists/service_test_helpers_test.go b/internal/playlists/service_test_helpers_test.go index a50118d0..f206cfd3 100644 --- a/internal/playlists/service_test_helpers_test.go +++ b/internal/playlists/service_test_helpers_test.go @@ -58,7 +58,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", + ApiTokenHash: name + "-token", IsAdmin: false, }) if err != nil { diff --git a/internal/playsessions/service_test.go b/internal/playsessions/service_test.go index c0a7da0a..613eb3d0 100644 --- a/internal/playsessions/service_test.go +++ b/internal/playsessions/service_test.go @@ -42,7 +42,7 @@ func seedTestUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", - ApiToken: "x", + ApiTokenHash: "x", IsAdmin: false, }) if err != nil { diff --git a/internal/recommendation/candidates_test.go b/internal/recommendation/candidates_test.go index 2545ded2..4da79e8a 100644 --- a/internal/recommendation/candidates_test.go +++ b/internal/recommendation/candidates_test.go @@ -50,7 +50,7 @@ func newFixture(t *testing.T, n int) fixture { pool := testPool(t) q := dbq.New(pool) u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) @@ -197,7 +197,7 @@ func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) { func TestLoadCandidates_CrossUserIsolation(t *testing.T) { f := newFixture(t, 2) bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, + Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "y", IsAdmin: false, }) // Alice likes tracks[1]; Bob shouldn't see it. _, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) diff --git a/internal/recommendation/suggestions_integration_test.go b/internal/recommendation/suggestions_integration_test.go index 7a8cd40c..9c86e76c 100644 --- a/internal/recommendation/suggestions_integration_test.go +++ b/internal/recommendation/suggestions_integration_test.go @@ -42,7 +42,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: false, + ApiTokenHash: name + "-token", IsAdmin: false, }) if err != nil { t.Fatalf("seed user: %v", err) diff --git a/internal/scrobble/queue_test.go b/internal/scrobble/queue_test.go index 10b92c00..e3a22c94 100644 --- a/internal/scrobble/queue_test.go +++ b/internal/scrobble/queue_test.go @@ -57,7 +57,7 @@ func seed(t *testing.T, opts seedOpts) setup { pool, q := testPool(t) ctx := context.Background() u, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) diff --git a/internal/server/hygiene.go b/internal/server/hygiene.go new file mode 100644 index 00000000..649f392f --- /dev/null +++ b/internal/server/hygiene.go @@ -0,0 +1,154 @@ +package server + +import ( + "io" + "mime" + "net/http" + "strings" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +// maxRequestBody caps every request body. The largest legitimate one is a +// playlist save of a few thousand track ids, well under 1 MiB; 4 MiB leaves +// room without letting one request hold arbitrary memory. +const maxRequestBody = 4 << 20 + +// bodyReadTimeout bounds how long a client may take to send a request body. +const bodyReadTimeout = 30 * time.Second + +// limitRequestBody caps the body size and the time allowed to deliver it. +// +// Why not http.Server.ReadTimeout: that deadline stays armed for the whole +// request, and once a handler has consumed the body, net/http's background +// read hits it and cancels the request context. That would cut off audio +// streams and the SSE event stream at the timeout. Here the deadline is set +// only on requests that carry a body, and cleared the moment the body has +// been read, so long-running responses are never affected. +func limitRequestBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body == nil || r.Body == http.NoBody { + next.ServeHTTP(w, r) + return + } + rc := http.NewResponseController(w) + // Best-effort: a ResponseWriter that can't set deadlines (a test + // recorder) still gets the size limit. + armed := rc.SetReadDeadline(time.Now().Add(bodyReadTimeout)) == nil + body := http.MaxBytesReader(w, r.Body, maxRequestBody) + if armed { + body = &deadlineClearingBody{ReadCloser: body, rc: rc} + } + r.Body = body + next.ServeHTTP(w, r) + }) +} + +// deadlineClearingBody lifts the read deadline once the body is exhausted. +// A deadline change applies to pending reads too, so this also releases the +// background read net/http starts at end-of-body. +type deadlineClearingBody struct { + io.ReadCloser + rc *http.ResponseController + cleared bool +} + +func (b *deadlineClearingBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + if err != nil && !b.cleared { + b.cleared = true + _ = b.rc.SetReadDeadline(time.Time{}) + } + return n, err +} + +func (b *deadlineClearingBody) Close() error { + if !b.cleared { + b.cleared = true + _ = b.rc.SetReadDeadline(time.Time{}) + } + return b.ReadCloser.Close() +} + +// requireJSONForCookieWrites refuses a state-changing /api request that is +// authenticated by the session cookie unless its body is JSON. +// +// SameSite=Strict already keeps the cookie off cross-site requests. This is +// the backstop for the case SameSite cannot see: a sibling app on the same +// registrable domain (another *.fabledsword.com service) counts as same-site. +// An HTML form or a no-preflight fetch can only send form-encoded, multipart +// or text/plain bodies, so demanding application/json closes that path. +// Bearer-token requests and /rest (query-string auth) carry nothing a browser +// attaches on its own, so they are not checked. The Android app does send +// the cookie, but every body it sends is JSON (Retrofit's kotlinx converter) +// and its bodiless writes carry no Content-Type, so it passes unchanged. +func requireJSONForCookieWrites(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + if !strings.HasPrefix(r.URL.Path, "/api/") { + next.ServeHTTP(w, r) + return + } + if c, err := r.Cookie(auth.SessionCookieName); err != nil || c.Value == "" { + next.ServeHTTP(w, r) + return + } + ct := r.Header.Get("Content-Type") + if ct == "" && (r.ContentLength == 0 || r.Body == nil || r.Body == http.NoBody) { + // No body, no content type: nothing a form could have sent. + next.ServeHTTP(w, r) + return + } + if mt, _, err := mime.ParseMediaType(ct); err != nil || mt != "application/json" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnsupportedMediaType) + _, _ = io.WriteString(w, `{"error":{"code":"unsupported_media_type","message":"request body must be application/json"}}`) + return + } + next.ServeHTTP(w, r) + }) +} + +// securityHeaders sets the response headers every response should carry. +// Each is set only when the handler hasn't, so a route with a reason to +// differ keeps its own value. The document's Content-Security-Policy is set +// by the SPA handler, which knows the inline-script hashes. +// +// HSTS goes out only when the request reached us over HTTPS as the trusted +// proxy reports it (rule 94): sending it over plain HTTP is ignored by +// browsers at best, and the app never forces HTTPS. +func securityHeaders(hops func() int) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + setDefault(h, "X-Content-Type-Options", "nosniff") + setDefault(h, "Referrer-Policy", "strict-origin-when-cross-origin") + setDefault(h, "Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()") + // frame-ancestors in the document's CSP covers modern browsers; + // this covers the rest, and every non-HTML response. + setDefault(h, "X-Frame-Options", "DENY") + if auth.IsHTTPS(r, hopsOrZero(hops)) { + setDefault(h, "Strict-Transport-Security", "max-age=31536000") + } + next.ServeHTTP(w, r) + }) + } +} + +func setDefault(h http.Header, key, value string) { + if h.Get(key) == "" { + h.Set(key, value) + } +} + +func hopsOrZero(hops func() int) int { + if hops == nil { + return 0 + } + return hops() +} diff --git a/internal/server/hygiene_test.go b/internal/server/hygiene_test.go new file mode 100644 index 00000000..ae9a1c4f --- /dev/null +++ b/internal/server/hygiene_test.go @@ -0,0 +1,128 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + if _, err := io.ReadAll(r.Body); err != nil { + http.Error(w, "too large", http.StatusRequestEntityTooLarge) + return + } + } + w.WriteHeader(http.StatusNoContent) + }) +} + +func TestLimitRequestBody_RejectsOversizedBody(t *testing.T) { + h := limitRequestBody(okHandler()) + + big := strings.NewReader(strings.Repeat("x", maxRequestBody+1)) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", big)) + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("oversized body: status = %d, want the handler's read to fail", w.Code) + } + + small := strings.NewReader(`{"name":"ok"}`) + w = httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", small)) + if w.Code != http.StatusNoContent { + t.Errorf("small body: status = %d, want 204", w.Code) + } +} + +// The cases a cross-site form or no-preflight fetch could produce must be +// refused when the session cookie rides along; everything a real client +// sends must pass. +func TestRequireJSONForCookieWrites(t *testing.T) { + cookie := &http.Cookie{Name: auth.SessionCookieName, Value: "tok"} + tests := []struct { + name string + method string + path string + contentType string + body string + withCookie bool + want int + }{ + {"form post with cookie", http.MethodPost, "/api/me/password", "application/x-www-form-urlencoded", "a=b", true, http.StatusUnsupportedMediaType}, + {"text/plain post with cookie", http.MethodPost, "/api/me/password", "text/plain", `{"a":1}`, true, http.StatusUnsupportedMediaType}, + {"multipart with cookie", http.MethodPut, "/api/me/profile", "multipart/form-data; boundary=x", "--x--", true, http.StatusUnsupportedMediaType}, + {"body without content type", http.MethodPost, "/api/me/profile", "", `{"a":1}`, true, http.StatusUnsupportedMediaType}, + + {"json with cookie", http.MethodPost, "/api/me/password", "application/json", `{}`, true, http.StatusNoContent}, + {"json with charset", http.MethodPost, "/api/me/password", "application/json; charset=utf-8", `{}`, true, http.StatusNoContent}, + {"bodiless delete with cookie", http.MethodDelete, "/api/me/sessions/1", "", "", true, http.StatusNoContent}, + {"form post without cookie (bearer client)", http.MethodPost, "/api/me/password", "text/plain", "x", false, http.StatusNoContent}, + {"subsonic post is not /api", http.MethodPost, "/rest/scrobble", "application/x-www-form-urlencoded", "id=1", true, http.StatusNoContent}, + {"GET is never checked", http.MethodGet, "/api/me", "text/plain", "", true, http.StatusNoContent}, + } + h := requireJSONForCookieWrites(okHandler()) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var body io.Reader + if tc.body != "" { + body = strings.NewReader(tc.body) + } + req := httptest.NewRequest(tc.method, tc.path, body) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + if tc.withCookie { + req.AddCookie(cookie) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != tc.want { + t.Errorf("status = %d, want %d", w.Code, tc.want) + } + }) + } +} + +func TestSecurityHeaders(t *testing.T) { + serve := func(hops int, proto string) http.Header { + h := securityHeaders(func() int { return hops })(okHandler()) + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + if proto != "" { + req.Header.Set("X-Forwarded-Proto", proto) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w.Header() + } + + base := serve(0, "") + for key, want := range map[string]string{ + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Frame-Options": "DENY", + } { + if got := base.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + if !strings.Contains(base.Get("Permissions-Policy"), "microphone=()") { + t.Errorf("Permissions-Policy = %q", base.Get("Permissions-Policy")) + } + + // Rule 94's three cases for HSTS. + if got := serve(0, "https").Get("Strict-Transport-Security"); got != "" { + t.Errorf("hops 0 + forwarded https: HSTS = %q, want none", got) + } + if got := serve(1, "https").Get("Strict-Transport-Security"); got == "" { + t.Error("hops 1 + forwarded https: want HSTS") + } + if got := serve(1, "").Get("Strict-Transport-Security"); got != "" { + t.Errorf("plain request: HSTS = %q, want none", got) + } +} diff --git a/internal/server/release_gate_test.go b/internal/server/release_gate_test.go new file mode 100644 index 00000000..345d84de --- /dev/null +++ b/internal/server/release_gate_test.go @@ -0,0 +1,120 @@ +package server + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// The publish gate (rule 177, M462 #4984), pinned. release.yml holds every +// verifying lane and every publishing job in one graph so that a publish can +// depend on the lanes; these tests keep that dependency from eroding. +// +// The failure each one guards against is silent. A lane added without being +// named in a publisher's needs runs, goes red, and :dev publishes anyway. A +// condition relaxed to `!failure()` lets a lane that never started count as +// a pass. Neither shows up as a broken build — only as a gate that no longer +// gates. + +// gateLanes are the verifying jobs. Every publisher must need each of them and +// require its success by name. +var gateLanes = []string{"go", "integration", "web", "android", "govulncheck"} + +// gatePublishers push something other people can pull: image tags, and the +// APK + sidecar attached to a Release. +var gatePublishers = []string{"image-release", "release-assets"} + +// gateOthers are the remaining jobs and why they sit outside the gate: +// android-release only builds a workflow artifact (nothing outside the run can +// pull it), and verify-release reports on a finished release. +var gateOthers = []string{"android-release", "verify-release"} + +type workflowJob struct { + Needs any `yaml:"needs"` + If string `yaml:"if"` +} + +func releaseJobs(t *testing.T) map[string]workflowJob { + t.Helper() + body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml")) + if err != nil { + t.Fatal(err) + } + var wf struct { + Jobs map[string]workflowJob `yaml:"jobs"` + } + if err := yaml.Unmarshal(body, &wf); err != nil { + t.Fatalf("parse release.yml: %v", err) + } + if len(wf.Jobs) == 0 { + t.Fatal("release.yml has no jobs") + } + return wf.Jobs +} + +func jobNeeds(j workflowJob) []string { + switch v := j.Needs.(type) { + case string: + return []string{v} + case []any: + out := make([]string, 0, len(v)) + for _, n := range v { + if s, ok := n.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + +// A job nobody has classified is the case that matters: a new lane that was +// never added to the publishers' needs. +func TestReleaseGate_EveryJobIsClassified(t *testing.T) { + for name := range releaseJobs(t) { + if slices.Contains(gateLanes, name) || slices.Contains(gatePublishers, name) || slices.Contains(gateOthers, name) { + continue + } + t.Errorf("release.yml job %q is not classified. If it verifies, add it to gateLanes and to every publisher's needs and if; "+ + "if it publishes, add it to gatePublishers and gate it; otherwise add it to gateOthers with the reason", name) + } + for _, want := range append(append(slices.Clone(gateLanes), gatePublishers...), gateOthers...) { + if _, ok := releaseJobs(t)[want]; !ok { + t.Errorf("release.yml has no %q job, which this test expects", want) + } + } +} + +func TestReleaseGate_PublishersNeedEveryLaneToSucceed(t *testing.T) { + jobs := releaseJobs(t) + for _, pub := range gatePublishers { + job, ok := jobs[pub] + if !ok { + t.Errorf("no %q job", pub) + continue + } + needs := jobNeeds(job) + cond := strings.Join(strings.Fields(job.If), " ") + for _, lane := range gateLanes { + if !slices.Contains(needs, lane) { + t.Errorf("%s does not need %q: it can publish without waiting for that lane", pub, lane) + } + if !strings.Contains(cond, "needs."+lane+".result == 'success'") { + t.Errorf("%s's if does not require needs.%s.result == 'success': a skipped or failed %s would not stop it\n if: %s", + pub, lane, lane, cond) + } + } + // always() and failure() both make a job run past a red dependency; + // !cancelled() is fine only because the per-lane success checks above + // carry the gate. + for _, forbidden := range []string{"always()", "failure()"} { + if strings.Contains(cond, forbidden) { + t.Errorf("%s's if uses %s, which runs it past a failed lane\n if: %s", pub, forbidden, cond) + } + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 94c59ddd..c833c508 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -105,6 +105,9 @@ type Server struct { // fingerprint workers, so a save from the admin card reaches them without a // restart. Router() constructs a fallback when nil (tests). FingerprintSettings *library.FingerprintSettingsService + // LoudnessSettings is the DB-backed loudness analysis policy (M464 #4995), + // shared with the loudness backfill. Nil makes the router load its own. + LoudnessSettings *library.LoudnessSettingsService // ReacqSettings is the DB-backed missing-file re-acquisition policy // (milestone #290) — the same instance the sweeper in cmd/minstrel/main.go // reads, so a save from the admin card reaches it without a restart @@ -139,6 +142,9 @@ func (s *Server) Router() http.Handler { r.Use(middleware.RequestID) r.Use(requestLog(s.Logger, netSettings.Hops)) r.Use(middleware.Recoverer) + r.Use(securityHeaders(netSettings.Hops)) + r.Use(limitRequestBody) + r.Use(requireJSONForCookieWrites) r.Get("/healthz", s.handleHealthz) @@ -211,7 +217,15 @@ func (s *Server) Router() http.Handler { s.Logger.Warn("fingerprint settings unavailable; serving defaults", "err", err) } } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings) + loudSettings := s.LoudnessSettings + if loudSettings == nil { + var err error + loudSettings, err = library.NewLoudnessSettingsService(context.Background(), s.Pool) + if err != nil { + s.Logger.Warn("loudness settings unavailable; serving defaults", "err", err) + } + } + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second @@ -220,7 +234,7 @@ func (s *Server) Router() http.Handler { r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()). Post("/api/admin/scan", s.handleAdminScan) } - subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer) + subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer, netSettings.Hops) } spa := web.Handler(s.BrandingCfg) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 29c0eaf1..bc037214 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -190,7 +190,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) { user, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: "test-shadowing-admin", PasswordHash: "x", - ApiToken: "test-shadowing-token", + ApiTokenHash: "test-shadowing-token", IsAdmin: true, }) if err != nil { @@ -276,7 +276,7 @@ func TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper(t *testin user, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: "test-reacq-settings-admin", PasswordHash: "x", - ApiToken: "test-reacq-settings-token", + ApiTokenHash: "test-reacq-settings-token", IsAdmin: true, }) if err != nil { diff --git a/internal/similarity/worker_integration_test.go b/internal/similarity/worker_integration_test.go index c8fa0fde..d323d978 100644 --- a/internal/similarity/worker_integration_test.go +++ b/internal/similarity/worker_integration_test.go @@ -57,7 +57,7 @@ func newFixture(t *testing.T) fixture { pool, q := testPool(t) ctx := context.Background() u, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go index b651588d..68f33fb7 100644 --- a/internal/subsonic/auth.go +++ b/internal/subsonic/auth.go @@ -6,12 +6,16 @@ import ( "crypto/subtle" "encoding/hex" "errors" + "fmt" + "math" "net/http" + "strconv" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) @@ -32,18 +36,38 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) { return u, ok } -// Middleware authenticates the request against users.api_token (apiKey) or +// Middleware authenticates the request against users.api_token_hash (apiKey) or // users.subsonic_password (t/s or p). On failure it writes a Subsonic failed // envelope using the request's f= format; downstream handlers never see an // unauthenticated request. -func Middleware(pool *pgxpool.Pool, cfg Config) func(http.Handler) http.Handler { +// +// guard throttles wrong credentials per account and per address, the same +// limits as the native login. Only wrong-credential failures count: Subsonic +// clients authenticate on every request, so counting successes would lock +// out a client for playing an album. hops is the live trusted-proxy depth +// used to find the caller's address. A nil guard disables throttling. +func Middleware(pool *pgxpool.Pool, cfg Config, guard *auth.LoginGuard, hops func() int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + addr := auth.ClientIP(r, hops()) + // apiKey requests have no account to charge; they count against + // the address alone. + account := r.URL.Query().Get("u") + if blocked, wait := guard.Blocked(account, addr); blocked { + secs := int(math.Ceil(wait.Seconds())) + w.Header().Set("Retry-After", strconv.Itoa(max(secs, 1))) + WriteFail(w, r, ErrGeneric, fmt.Sprintf("Too many failed sign-in attempts; try again in %d seconds", max(secs, 1))) + return + } user, code, msg := authenticate(r, pool, cfg) + if code == ErrWrongCredentials { + guard.Fail(account, addr) + } if code != 0 { WriteFail(w, r, code, msg) return } + guard.Succeed(account) ctx := context.WithValue(r.Context(), userCtxKey, user) next.ServeHTTP(w, r.WithContext(ctx)) }) @@ -55,7 +79,7 @@ func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, in params := r.URL.Query() if apiKey := params.Get("apiKey"); apiKey != "" { - user, err := q.GetUserByAPIToken(r.Context(), apiKey) + user, err := q.GetUserByAPITokenHash(r.Context(), auth.HashAPIToken(apiKey)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return dbq.User{}, ErrWrongCredentials, "Invalid apiKey" diff --git a/internal/subsonic/browse.go b/internal/subsonic/browse.go index 70225084..2afb0ed8 100644 --- a/internal/subsonic/browse.go +++ b/internal/subsonic/browse.go @@ -185,9 +185,10 @@ func (b *browseHandlers) getAlbum(w http.ResponseWriter, r *http.Request) { WriteFail(w, r, ErrGeneric, "Failed to load tracks") return } + gains := replayGains(r.Context(), q, tracks) songs := make([]SongRef, 0, len(tracks)) for _, t := range tracks { - songs = append(songs, songRef(t, album.Title, artist.Name)) + songs = append(songs, songRef(t, album.Title, artist.Name, gains[t.ID])) } Write(w, r, AlbumResponse{ Envelope: NewEnvelope("ok"), @@ -230,7 +231,7 @@ func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) { } Write(w, r, SongResponse{ Envelope: NewEnvelope("ok"), - Song: songRef(track, album.Title, artist.Name), + Song: songRef(track, album.Title, artist.Name, replayGains(r.Context(), q, []dbq.Track{track})[track.ID]), }) } @@ -386,6 +387,7 @@ func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) { WriteFail(w, r, ErrGeneric, "Song search failed") return } + gains := replayGains(r.Context(), q, tracks) for _, t := range tracks { album, aerr := q.GetAlbumByID(r.Context(), t.AlbumID) if aerr != nil { @@ -397,7 +399,7 @@ func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) { WriteFail(w, r, ErrGeneric, "Song search failed") return } - result.Songs = append(result.Songs, songRef(t, album.Title, artist.Name)) + result.Songs = append(result.Songs, songRef(t, album.Title, artist.Name, gains[t.ID])) } } diff --git a/internal/subsonic/replaygain_test.go b/internal/subsonic/replaygain_test.go new file mode 100644 index 00000000..774093c5 --- /dev/null +++ b/internal/subsonic/replaygain_test.go @@ -0,0 +1,58 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "strings" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +// OpenSubsonic's replayGain is an object in JSON and an element with +// attributes in XML. Clients parse both, so both are pinned (#4997). +func TestSongRef_ReplayGainEncodesForBothFormats(t *testing.T) { + gain, peak := float32(-4.5), float32(0.8913) + tr := dbq.Track{Title: "Song", FilePath: "/m/a.flac", FileFormat: "flac"} + s := songRef(tr, "Album", "Artist", library.ReplayGain{TrackGain: &gain, TrackPeak: &peak}) + + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + rg, ok := m["replayGain"].(map[string]any) + if !ok { + t.Fatalf("JSON has no replayGain object: %s", b) + } + if rg["trackGain"] != -4.5 || rg["trackPeak"] == nil { + t.Errorf("JSON replayGain = %v, want trackGain -4.5 and a trackPeak", rg) + } + if _, ok := rg["albumGain"]; ok { + t.Errorf("JSON replayGain carries albumGain with no album value: %v", rg) + } + + x, err := xml.Marshal(s) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(x), ``) { + t.Errorf("XML lacks the replayGain element: %s", x) + } +} + +func TestSongRef_NoReplayGainWhenUnmeasured(t *testing.T) { + s := songRef(dbq.Track{Title: "Song", FilePath: "/m/a.mp3"}, "Album", "Artist", library.ReplayGain{}) + if s.ReplayGain != nil { + t.Fatalf("unmeasured song carries replayGain %+v", s.ReplayGain) + } + b, _ := json.Marshal(s) + x, _ := xml.Marshal(s) + if strings.Contains(string(b), "replayGain") || strings.Contains(string(x), "replayGain") { + t.Errorf("unmeasured song encodes replayGain: %s / %s", b, x) + } +} diff --git a/internal/subsonic/scrobble_test.go b/internal/subsonic/scrobble_test.go index 6567e5df..8cfcfcce 100644 --- a/internal/subsonic/scrobble_test.go +++ b/internal/subsonic/scrobble_test.go @@ -39,7 +39,7 @@ func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) { dbtest.ResetDB(t, pool) q := dbq.New(pool) u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) diff --git a/internal/subsonic/star.go b/internal/subsonic/star.go index b2b333f5..7d717a3a 100644 --- a/internal/subsonic/star.go +++ b/internal/subsonic/star.go @@ -191,6 +191,7 @@ func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]Son if err != nil { return nil, nil, nil, err } + gains := replayGains(ctx, q, trackRows) for _, t := range trackRows { album, err := q.GetAlbumByID(ctx, t.AlbumID) if err != nil { @@ -200,7 +201,7 @@ func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]Son if err != nil { return nil, nil, nil, err } - songs = append(songs, songRef(t, album.Title, artist.Name)) + songs = append(songs, songRef(t, album.Title, artist.Name, gains[t.ID])) } albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{ diff --git a/internal/subsonic/star_test.go b/internal/subsonic/star_test.go index c609e01b..40a0d323 100644 --- a/internal/subsonic/star_test.go +++ b/internal/subsonic/star_test.go @@ -40,7 +40,7 @@ func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, dbtest.ResetDB(t, pool) q := dbq.New(pool) u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false, }) a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID}) @@ -228,7 +228,7 @@ func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) { pool, alice, track, _, _ := testStarPool(t) q := dbq.New(pool) bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ - Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, + Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "y", IsAdmin: false, }) _, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID}) diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index 75260ff2..3e5e6d95 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -19,11 +20,14 @@ import ( // both /rest/foo and /rest/foo.view because client conventions vary. Both // GET and POST are accepted; Subsonic's auth params live in the query string // either way. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer) { +// +// hops is the live trusted-proxy depth, read per request so an admin change +// applies without a restart; it locates the caller for the auth throttle. +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer, hops func() int) { b := &browseHandlers{pool: pool} m := newMediaHandlers(pool, events, logger) r.Route("/rest", func(sub chi.Router) { - sub.Use(Middleware(pool, cfg)) + sub.Use(Middleware(pool, cfg, auth.NewLoginGuard(), hops)) register(sub, "/ping", handlePing) register(sub, "/getLicense", handleGetLicense) register(sub, "/getMusicFolders", b.getMusicFolders) diff --git a/internal/subsonic/types.go b/internal/subsonic/types.go index 19ffb63d..2414fe60 100644 --- a/internal/subsonic/types.go +++ b/internal/subsonic/types.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" ) // IDs on the wire are the bare UUID string. Subsonic endpoints know from @@ -180,6 +181,20 @@ type SongRef struct { BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"` IsDir bool `json:"isDir" xml:"isDir,attr"` Type string `json:"type" xml:"type,attr"` + // ReplayGain is OpenSubsonic's replayGain (M464 #4997): Minstrel's own + // measurements, so third-party clients that level by ReplayGain do it + // from the same numbers the Minstrel apps use. Omitted until measured. + ReplayGain *ReplayGain `json:"replayGain,omitempty" xml:"replayGain,omitempty"` +} + +// ReplayGain is the OpenSubsonic replayGain object: gains in dB to the +// -18 LUFS ReplayGain 2.0 reference, peaks as linear amplitude. A field with +// no value is omitted. +type ReplayGain struct { + TrackGain *float32 `json:"trackGain,omitempty" xml:"trackGain,attr,omitempty"` + AlbumGain *float32 `json:"albumGain,omitempty" xml:"albumGain,attr,omitempty"` + TrackPeak *float32 `json:"trackPeak,omitempty" xml:"trackPeak,attr,omitempty"` + AlbumPeak *float32 `json:"albumPeak,omitempty" xml:"albumPeak,attr,omitempty"` } type SongResponse struct { @@ -284,7 +299,10 @@ func albumDetail(a dbq.Album, artistName string, songs []SongRef) AlbumDetail { } } -func songRef(t dbq.Track, albumTitle, artistName string) SongRef { +// songRef takes the track's gains as an argument rather than looking them up, +// so no response that lists songs can leave them out by forgetting a step: +// each caller fetches them once for its whole list (replayGains). +func songRef(t dbq.Track, albumTitle, artistName string, g library.ReplayGain) SongRef { s := SongRef{ ID: uuidToID(t.ID), Parent: uuidToID(t.AlbumID), @@ -313,9 +331,30 @@ func songRef(t dbq.Track, albumTitle, artistName string) SongRef { if t.Genre != nil { s.Genre = *t.Genre } + if !g.Empty() { + s.ReplayGain = &ReplayGain{ + TrackGain: g.TrackGain, AlbumGain: g.AlbumGain, + TrackPeak: g.TrackPeak, AlbumPeak: g.AlbumPeak, + } + } return s } +// replayGains fetches the gains for a list of tracks in one query. A failed +// lookup yields no gains rather than failing the response: the songs still +// play, just unleveled, which is how they played before gains existed. +func replayGains(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) map[pgtype.UUID]library.ReplayGain { + ids := make([]pgtype.UUID, len(tracks)) + for i, t := range tracks { + ids[i] = t.ID + } + gains, err := library.ReplayGainForTracks(ctx, q, ids) + if err != nil { + return map[pgtype.UUID]library.ReplayGain{} + } + return gains +} + // coverArtID returns the album UUID as the cover-art key. getCoverArt uses // the album row to find art either in cover_art_path (when the scanner sets // it) or via sidecar lookup in the album directory, so emitting the id diff --git a/internal/taste/profile_db_test.go b/internal/taste/profile_db_test.go index 8f301154..fe9a27b3 100644 --- a/internal/taste/profile_db_test.go +++ b/internal/taste/profile_db_test.go @@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: false, + ApiTokenHash: name + "-token", IsAdmin: false, }) if err != nil { t.Fatalf("seed user: %v", err) diff --git a/internal/tracks/service_test.go b/internal/tracks/service_test.go index 02c9aa57..0456619a 100644 --- a/internal/tracks/service_test.go +++ b/internal/tracks/service_test.go @@ -44,7 +44,7 @@ func seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { t.Helper() u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + name, PasswordHash: "x", - ApiToken: name + "-token", IsAdmin: true, + ApiTokenHash: name + "-token", IsAdmin: true, }) if err != nil { t.Fatalf("seed admin %s: %v", name, err) diff --git a/web/csp.go b/web/csp.go new file mode 100644 index 00000000..b255998c --- /dev/null +++ b/web/csp.go @@ -0,0 +1,57 @@ +package web + +import ( + "crypto/sha256" + "encoding/base64" + "regexp" + "strings" +) + +// inlineScriptRe matches each `) + +var srcAttrRe = regexp.MustCompile(`(?i)\bsrc\s*=`) + +// contentSecurityPolicy builds the policy served with index.html. +// +// Scripts are allowed from our own origin plus the exact inline scripts the +// page carries: the theme bootstrap in app.html, the branding global the Vite +// plugin injects, and SvelteKit's start-up block. Their hashes are taken from +// the page AFTER the branding template has run, so they match the bytes the +// browser actually receives, whatever the operator's app name. Any script +// that differs, including one injected through an XSS, is refused. +// +// The rest: +// - style-src allows inline styles: Svelte transitions and style: +// directives write them, and inline style is not a script vector. +// - img-src admits https:/http: because Lidarr suggestion art is a remote +// poster URL. Images cannot run code. +// - media-src/connect-src stay on our own origin: streams, the API and the +// SSE stream are all same-origin. blob: covers Web Audio and object URLs. +func contentSecurityPolicy(indexHTML []byte) string { + scriptSrc := []string{"'self'"} + for _, m := range inlineScriptRe.FindAllSubmatch(indexHTML, -1) { + if srcAttrRe.Match(m[1]) { + continue + } + sum := sha256.Sum256(m[2]) + scriptSrc = append(scriptSrc, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'") + } + return strings.Join([]string{ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + "script-src " + strings.Join(scriptSrc, " "), + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https: http:", + "font-src 'self' data:", + "media-src 'self' blob:", + "connect-src 'self'", + "worker-src 'self' blob:", + "manifest-src 'self'", + }, "; ") +} diff --git a/web/csp_test.go b/web/csp_test.go new file mode 100644 index 00000000..b603597a --- /dev/null +++ b/web/csp_test.go @@ -0,0 +1,60 @@ +package web + +import ( + "crypto/sha256" + "encoding/base64" + "strings" + "testing" +) + +func hashOf(body string) string { + sum := sha256.Sum256([]byte(body)) + return "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" +} + +func TestContentSecurityPolicy_HashesInlineScriptsOnly(t *testing.T) { + theme := "(function () { document.documentElement.dataset.theme = 'dark'; })();" + brand := `window.__MINSTREL__ = { appName: "Minstrel" };` + html := "" + + `` + + "" + + csp := contentSecurityPolicy([]byte(html)) + + var scriptSrc string + for _, d := range strings.Split(csp, "; ") { + if strings.HasPrefix(d, "script-src ") { + scriptSrc = d + } + } + if scriptSrc == "" { + t.Fatalf("no script-src in %q", csp) + } + for _, want := range []string{"'self'", hashOf(theme), hashOf(brand)} { + if !strings.Contains(scriptSrc, want) { + t.Errorf("script-src %q missing %s", scriptSrc, want) + } + } + if strings.Count(scriptSrc, "'sha256-") != 2 { + t.Errorf("script-src %q: want exactly the two inline scripts hashed, not the src= one", scriptSrc) + } + if strings.Contains(scriptSrc, "unsafe-inline") || strings.Contains(scriptSrc, "unsafe-eval") { + t.Errorf("script-src must not fall back to unsafe-*: %q", scriptSrc) + } + for _, want := range []string{"frame-ancestors 'none'", "object-src 'none'", "connect-src 'self'"} { + if !strings.Contains(csp, want) { + t.Errorf("csp missing %q", want) + } + } +} + +// The hash must be of the page as served, after the branding template has +// substituted the operator's app name, or a renamed instance would refuse +// its own bootstrap script. +func TestContentSecurityPolicy_TracksTemplatedContent(t *testing.T) { + a := contentSecurityPolicy([]byte(``)) + b := contentSecurityPolicy([]byte(``)) + if a == b { + t.Error("different script bodies produced the same policy") + } +} diff --git a/web/embed.go b/web/embed.go index 2c63b3c9..690c41c7 100644 --- a/web/embed.go +++ b/web/embed.go @@ -47,12 +47,14 @@ func Handler(branding config.BrandingConfig) http.Handler { panic("web: branding template failed: " + err.Error()) } + csp := contentSecurityPolicy(index) + fileServer := http.FileServer(http.FS(sub)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { clean := path.Clean(r.URL.Path) if clean == "/" || clean == "." { - serveIndex(w, index) + serveIndex(w, index, csp) return } name := strings.TrimPrefix(clean, "/") @@ -60,12 +62,16 @@ func Handler(branding config.BrandingConfig) http.Handler { fileServer.ServeHTTP(w, r) return } - serveIndex(w, index) + serveIndex(w, index, csp) }) } -func serveIndex(w http.ResponseWriter, index []byte) { +func serveIndex(w http.ResponseWriter, index []byte, csp string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The policy only means anything on the document; JSON, audio and image + // responses can't run script, so it rides here rather than on every + // response. + w.Header().Set("Content-Security-Policy", csp) w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(index) } diff --git a/web/package-lock.json b/web/package-lock.json index bad543a2..1a238850 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -635,10 +635,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", @@ -796,9 +795,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -813,9 +809,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -830,9 +823,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -847,9 +837,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -864,9 +851,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -881,9 +865,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -898,9 +879,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -915,9 +893,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -932,9 +907,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -949,9 +921,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -966,9 +935,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -983,9 +949,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1000,9 +963,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1101,10 +1061,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "license": "MIT", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "peerDependencies": { "acorn": "^8.9.0" } @@ -1120,18 +1079,17 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.57.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz", - "integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, - "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", + "acorn": "^8.16.0", "cookie": "^0.6.0", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -1354,12 +1312,6 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1474,10 +1426,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "bin": { "acorn": "bin/acorn" }, @@ -1632,11 +1583,10 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", - "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, - "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -1671,9 +1621,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -1689,13 +1639,12 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -1739,9 +1688,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001790", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", - "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -1756,8 +1705,7 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, "node_modules/chai": { "version": "5.3.3", @@ -1965,10 +1913,9 @@ } }, "node_modules/devalue": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", - "license": "MIT" + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -2007,11 +1954,10 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.343", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", - "integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==", - "dev": true, - "license": "ISC" + "version": "1.5.445", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.445.tgz", + "integrity": "sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==", + "dev": true }, "node_modules/entities": { "version": "6.0.1", @@ -2126,7 +2072,6 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -2138,10 +2083,9 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", - "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", - "license": "MIT", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, @@ -2246,17 +2190,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, - "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -2396,11 +2339,10 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, - "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -2806,9 +2748,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -2816,7 +2758,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2825,11 +2766,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, - "license": "MIT" + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -2946,9 +2889,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -2964,11 +2907,10 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3088,11 +3030,10 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, - "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3371,11 +3312,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -3444,23 +3384,21 @@ } }, "node_modules/svelte": { - "version": "5.55.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", - "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", - "license": "MIT", + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.9.2", "esm-env": "^1.2.1", - "esrap": "^2.2.4", + "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -3494,6 +3432,11 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte/node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3785,9 +3728,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -3803,7 +3746,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -4070,11 +4012,10 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, - "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 8f97e103..fa925e28 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -364,6 +364,38 @@ export async function updateFingerprintSettings( return api.put('/api/admin/library/fingerprint-settings', s); } +// Loudness analysis (#4995) ------------------------------------------------- + +export type LoudnessCoverage = { + total: number; + measured: number; + // Read fine, but no part of the track was loud enough to measure: silence, + // or a file shorter than half a second. + silent: number; + unreadable: number; + pending: number; + // False when the operator has switched analysis off: pending then never + // shrinks, and nothing should read as progress. + enabled: boolean; +}; + +export async function getLoudnessCoverage(): Promise { + return api.get('/api/admin/library/loudness'); +} + +export type LoudnessSettings = { + enabled: boolean; + backfill_concurrency: number; +}; + +export async function getLoudnessSettings(): Promise { + return api.get('/api/admin/library/loudness-settings'); +} + +export async function updateLoudnessSettings(s: LoudnessSettings): Promise { + return api.put('/api/admin/library/loudness-settings', s); +} + // Cover-art providers ------------------------------------------------------ export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart'; @@ -708,6 +740,9 @@ export type NetworkSettings = { detected_client_ip: string; forwarded_chain: string; remote_addr: string; + // Where users reach Minstrel. Password-reset emails link here and are not + // sent while it is empty. + public_url: string; }; export async function getNetworkSettings(): Promise { @@ -722,6 +757,13 @@ export async function updateNetworkSettings(hops: number): Promise { + return api.put('/api/admin/network-settings', { + public_url: publicUrl + }); +} + // Duplicates report (#3912) ------------------------------------------------- export async function listDuplicates( diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index aa0d168e..b984d252 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -2,8 +2,25 @@ export type ApiError = { code: string; message: string; status: number; + /** Seconds until a 429'd request may be retried, from Retry-After. */ + retryAfter?: number; }; +/** + * The sign-in, register and reset screens' wording for a throttled attempt, + * or null when err isn't one. Rounds up to whole minutes: the server's + * windows are minutes long, and "try again in 1 second" after a lockout + * would be untrue the moment it was read. + */ +export function rateLimitMessage(err: unknown): string | null { + const apiErr = err as ApiError | undefined; + if (apiErr?.status !== 429) return null; + const secs = apiErr.retryAfter; + if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.'; + const mins = Math.ceil(secs / 60); + return `Too many attempts. Try again in ${mins} minute${mins === 1 ? '' : 's'}.`; +} + export type User = { id: string; username: string; @@ -45,6 +62,10 @@ export async function apiFetch(path: string, init?: RequestInit): Promise 0) err.retryAfter = retryAfter; + } throw err; } return body; diff --git a/web/src/lib/api/errors.ts b/web/src/lib/api/errors.ts index 3de3b71b..c02325cd 100644 --- a/web/src/lib/api/errors.ts +++ b/web/src/lib/api/errors.ts @@ -19,7 +19,9 @@ const DETAIL_CODES: ReadonlySet = new Set([ 'library_not_writable', 'file_delete_failed', // The server names the field and its range (#3913). - 'invalid_setting' + 'invalid_setting', + // The server says what shape of address it wants (#4981). + 'invalid_public_url' ]); /** diff --git a/web/src/lib/api/me.ts b/web/src/lib/api/me.ts index e9a9ab66..1bda18a9 100644 --- a/web/src/lib/api/me.ts +++ b/web/src/lib/api/me.ts @@ -46,14 +46,34 @@ export async function updateProfile(input: { display_name?: string; email?: stri return api.put('/api/me/profile', input); } -export async function getAPIToken(): Promise { - return api.get('/api/me/api-token'); -} - +/** + * Mints a new API key and returns it. The server keeps only its hash, so + * this is the one time the key can be read; there is no way to fetch it + * again later. + */ export async function regenerateAPIToken(): Promise { return api.post('/api/me/api-token', {}); } +// Subsonic password (#5026) ------------------------------------------------ +// For Subsonic clients that only sign in with a username and password (the +// t/s scheme). The server generates it, so it is never the login password; +// like the API key it is shown once, when generated. + +export type SubsonicPasswordStatus = { enabled: boolean }; + +export async function getSubsonicPasswordStatus(): Promise { + return api.get('/api/me/subsonic-password'); +} + +export async function generateSubsonicPassword(): Promise<{ password: string }> { + return api.post<{ password: string }>('/api/me/subsonic-password', {}); +} + +export async function clearSubsonicPassword(): Promise { + await api.del('/api/me/subsonic-password'); +} + // Submits the browser's current IANA timezone for the authenticated // user. Called from the auth store on login + bootstrap + once weekly // (cadence tracked client-side in localStorage). Failures are diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts index 49903a20..b8b26db2 100644 --- a/web/src/lib/auth/store.svelte.ts +++ b/web/src/lib/auth/store.svelte.ts @@ -47,10 +47,19 @@ export async function login(username: string, password: string): Promise { void sendTimezoneIfStale(); } +/** + * Whether the server has no accounts yet, in which case the first + * registration must carry the setup token printed in the server log. + */ +export async function getSetupStatus(): Promise<{ setup_required: boolean }> { + return api.get<{ setup_required: boolean }>('/api/auth/setup-status'); +} + export async function register(opts: { username: string; password: string; inviteToken?: string; + setupToken?: string; displayName?: string; }): Promise { const body: Record = { @@ -58,6 +67,7 @@ export async function register(opts: { password: opts.password, }; if (opts.inviteToken) body.invite_token = opts.inviteToken; + if (opts.setupToken) body.setup_token = opts.setupToken; if (opts.displayName) body.display_name = opts.displayName; const res = await api.post('/api/auth/register', body); setUser(res.user); diff --git a/web/src/lib/components/LoudnessSettingsCard.svelte b/web/src/lib/components/LoudnessSettingsCard.svelte new file mode 100644 index 00000000..6003f1df --- /dev/null +++ b/web/src/lib/components/LoudnessSettingsCard.svelte @@ -0,0 +1,173 @@ + + +
+
+

Loudness analysis

+

+ Measures how loud each track is, so playback can even out the volume between tracks. It runs + in the background; until a track is measured, it plays at its own volume. +

+
+ + {#if coverage && coverage.total > 0} +
+ + {coverage.measured.toLocaleString()} of {coverage.total.toLocaleString()} tracks measured + + {#if coverage.pending > 0} + · + {coverage.pending.toLocaleString()} pending + {/if} + {#if !coverage.enabled && coverage.pending > 0} + · + paused while analysis is off + {/if} + {#if coverage.silent > 0} + · + + {coverage.silent.toLocaleString()} silent + + {/if} + {#if coverage.unreadable > 0} + · + + {coverage.unreadable.toLocaleString()} unreadable + + {/if} +
+ {/if} + + {#if loadError} +

+ Couldn't load loudness analysis settings. + +

+ {:else if form === null} +

Loading…

+ {:else} + + + + + {#if !concurrencyOk} +

+ Files analyzed at once must be a whole number from 1 to 8. +

+ {/if} + +
+ +
+ {/if} +
diff --git a/web/src/lib/components/LoudnessSettingsCard.test.ts b/web/src/lib/components/LoudnessSettingsCard.test.ts new file mode 100644 index 00000000..670676ad --- /dev/null +++ b/web/src/lib/components/LoudnessSettingsCard.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import type { LoudnessCoverage, LoudnessSettings } from '$lib/api/admin'; + +vi.mock('$lib/api/admin', () => ({ + getLoudnessSettings: vi.fn(), + updateLoudnessSettings: vi.fn(), + getLoudnessCoverage: vi.fn() +})); + +vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() })); + +import LoudnessSettingsCard from './LoudnessSettingsCard.svelte'; +import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin'; +import { pushToast } from '$lib/stores/toast.svelte'; + +const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2 }; +const coverage: LoudnessCoverage = { + total: 1200, + measured: 900, + silent: 2, + unreadable: 3, + pending: 295, + enabled: true +}; + +afterEach(() => vi.clearAllMocks()); + +async function renderCard( + over: Partial = {}, + cov: Partial = {} +) { + vi.mocked(getLoudnessSettings).mockResolvedValue({ ...base, ...over }); + vi.mocked(getLoudnessCoverage).mockResolvedValue({ ...coverage, ...cov }); + const r = render(LoudnessSettingsCard); + await screen.findByRole('spinbutton', { name: /files analyzed at once/i }); + return r; +} + +const saveButton = () => screen.getByRole('button', { name: /save/i }); +const concurrency = () => screen.getByRole('spinbutton', { name: /files analyzed at once/i }); + +describe('LoudnessSettingsCard', () => { + test('shows how far the analysis has got', async () => { + await renderCard(); + const gauge = await screen.findByTestId('loudness-coverage'); + expect(gauge.textContent).toMatch(/900 of 1,200 tracks measured/); + expect(gauge.textContent).toMatch(/295 pending/); + expect(gauge.textContent).toMatch(/2 silent/); + expect(gauge.textContent).toMatch(/3 unreadable/); + expect(gauge.textContent).not.toMatch(/paused/); + }); + + // With analysis off, pending never shrinks; the gauge must not read as progress. + test('says the work is paused when analysis is off', async () => { + await renderCard({ enabled: false }, { enabled: false }); + const gauge = await screen.findByTestId('loudness-coverage'); + expect(gauge.textContent).toMatch(/paused while analysis is off/); + }); + + test('save is disabled until something changes, then saves the form', async () => { + vi.mocked(updateLoudnessSettings).mockResolvedValue({ ...base, backfill_concurrency: 4 }); + await renderCard(); + expect(saveButton()).toHaveProperty('disabled', true); + + await fireEvent.input(concurrency(), { target: { value: '4' } }); + await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false)); + await fireEvent.click(saveButton()); + + await waitFor(() => + expect(updateLoudnessSettings).toHaveBeenCalledWith({ enabled: true, backfill_concurrency: 4 }) + ); + await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Loudness analysis settings saved.')); + // The gauge is read again after a save: switching analysis on or off changes it. + expect(getLoudnessCoverage).toHaveBeenCalledTimes(2); + }); + + test('an out-of-range value is named and cannot be saved', async () => { + await renderCard(); + await fireEvent.input(concurrency(), { target: { value: '9' } }); + await waitFor(() => + expect(screen.getByTestId('settings-problems').textContent).toMatch(/1 to 8/) + ); + expect(saveButton()).toHaveProperty('disabled', true); + }); + + test('a failed load offers a retry', async () => { + vi.mocked(getLoudnessSettings).mockRejectedValue(new Error('boom')); + vi.mocked(getLoudnessCoverage).mockResolvedValue(coverage); + render(LoudnessSettingsCard); + await screen.findByText(/couldn't load loudness analysis settings/i); + expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy(); + }); +}); diff --git a/web/src/lib/components/PublicAddressCard.svelte b/web/src/lib/components/PublicAddressCard.svelte new file mode 100644 index 00000000..2c984af0 --- /dev/null +++ b/web/src/lib/components/PublicAddressCard.svelte @@ -0,0 +1,107 @@ + + +
+
+

Public address

+

+ The address people use to reach Minstrel. Password-reset emails link here. +

+
+ + {#if loadError} +

+ Couldn't load network settings. + +

+ {:else if saved === null} +

Loading…

+ {:else} +
+ + {#if here && value.trim() !== here} + + {/if} + +
+ + {#if !saved} +

+

+ {/if} + {/if} +
diff --git a/web/src/lib/components/PublicAddressCard.test.ts b/web/src/lib/components/PublicAddressCard.test.ts new file mode 100644 index 00000000..56a7d711 --- /dev/null +++ b/web/src/lib/components/PublicAddressCard.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import PublicAddressCard from './PublicAddressCard.svelte'; + +const getNetworkSettings = vi.fn(); +const updatePublicUrl = vi.fn(); + +vi.mock('$lib/api/admin', () => ({ + getNetworkSettings: () => getNetworkSettings(), + updatePublicUrl: (url: string) => updatePublicUrl(url) +})); + +const pushToast = vi.fn(); +vi.mock('$lib/stores/toast.svelte', () => ({ + pushToast: (...args: unknown[]) => pushToast(...args) +})); + +function settings(publicUrl: string) { + return { + trusted_proxy_hops: 1, + max_hops: 10, + detected_client_ip: '198.51.100.7', + forwarded_chain: '198.51.100.7', + remote_addr: '172.18.0.1:40000', + public_url: publicUrl + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('PublicAddressCard', () => { + test('warns that reset emails are not sent while the address is unset', async () => { + getNetworkSettings.mockResolvedValue(settings('')); + render(PublicAddressCard); + expect(await screen.findByText(/password-reset emails are not being sent/i)).toBeTruthy(); + }); + + test('no warning once an address is saved', async () => { + getNetworkSettings.mockResolvedValue(settings('https://music.example.com')); + render(PublicAddressCard); + await screen.findByDisplayValue('https://music.example.com'); + expect(screen.queryByText(/not being sent/i)).toBeNull(); + }); + + test('offers this page’s own origin and saves it trimmed', async () => { + getNetworkSettings.mockResolvedValue(settings('')); + updatePublicUrl.mockResolvedValue(settings(window.location.origin)); + render(PublicAddressCard); + + await fireEvent.click(await screen.findByRole('button', { name: /^Use / })); + await fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(updatePublicUrl).toHaveBeenCalledWith(window.location.origin)); + expect(pushToast).toHaveBeenCalledWith('Public address saved.'); + }); + + test('shows the server’s reason when an address is refused', async () => { + getNetworkSettings.mockResolvedValue(settings('')); + updatePublicUrl.mockRejectedValue({ + code: 'invalid_public_url', + message: 'public URL must be an http:// or https:// address', + status: 400 + }); + render(PublicAddressCard); + + const input = await screen.findByPlaceholderText('https://music.example.com'); + await fireEvent.input(input, { target: { value: 'music.example.com' } }); + await fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(pushToast).toHaveBeenCalled()); + expect(pushToast.mock.calls[0][1]).toBe('error'); + }); +}); diff --git a/web/src/lib/styles/error-copy.json b/web/src/lib/styles/error-copy.json index 237d999a..349d4b2a 100644 --- a/web/src/lib/styles/error-copy.json +++ b/web/src/lib/styles/error-copy.json @@ -4,6 +4,7 @@ "forbidden": "You don't have permission to do that.", "not_authorized": "You don't have permission to do that.", "invalid_credentials": "Wrong username or password.", + "rate_limited": "Too many attempts. Wait a few minutes and try again.", "wrong_password": "Current password is incorrect.", "password_too_short": "Password must be at least 8 characters.", "username_invalid": "That username isn't valid.", @@ -46,6 +47,7 @@ "duplicate_group_not_pending": "That group has already been resolved.", "survivor_not_in_group": "That copy isn't part of this group any more.", "invalid_setting": "That setting is out of range.", + "invalid_public_url": "That address isn't valid.", "album_not_found": "That album no longer exists.", "artist_not_found": "That artist no longer exists.", "playlist_not_found": "That playlist no longer exists.", diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 97feda2a..5cc0c1a2 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -2,6 +2,7 @@ import { pageTitle } from '$lib/branding'; import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte'; import StageBadge from '$lib/components/StageBadge.svelte'; + import LoudnessSettingsCard from '$lib/components/LoudnessSettingsCard.svelte'; import { stageState } from './stage-state'; import { useQueryClient } from '@tanstack/svelte-query'; import { @@ -463,6 +464,10 @@ {/if} + + +

Cover art

diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts index d80205f0..04f80b6f 100644 --- a/web/src/routes/admin/admin.test.ts +++ b/web/src/routes/admin/admin.test.ts @@ -48,7 +48,13 @@ vi.mock('$lib/api/admin', async () => { deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}), triggerScan: vi.fn().mockResolvedValue({}), refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }), - researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }) + researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }), + // LoudnessSettingsCard, rendered by the page and tested on its own. + getLoudnessSettings: vi.fn().mockResolvedValue({ enabled: true, backfill_concurrency: 2 }), + updateLoudnessSettings: vi.fn(), + getLoudnessCoverage: vi.fn().mockResolvedValue({ + total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true + }) }; }); diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index e69c25b1..eb749efb 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -28,6 +28,7 @@ import { pushToast } from '$lib/stores/toast.svelte'; import Modal from '$lib/components/Modal.svelte'; import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte'; + import PublicAddressCard from '$lib/components/PublicAddressCard.svelte'; import type { LidarrConfig, LidarrTestResult } from '$lib/api/types'; // Lidarr connection panel. The "saved api key" is masked as "***" on GET — @@ -827,6 +828,7 @@ other card on this page, and it's an operator-wide setting rather than a per-user preference. --> + import { pageTitle } from '$lib/branding'; import { forgotPassword } from '$lib/auth/store.svelte'; + import { rateLimitMessage } from '$lib/api/client'; let email = $state(''); let submitted = $state(false); let submitting = $state(false); + let throttled = $state(null); async function onSubmit(e: SubmitEvent) { e.preventDefault(); submitting = true; + throttled = null; try { await forgotPassword(email); - } catch { - // Swallow — the page always shows the same success message - // regardless of outcome, to mirror the server's no-enumeration - // posture. A failed call must not surface as an unhandled - // rejection in the browser console. - } finally { submitted = true; + } catch (err) { + // A throttled request says so: the server applies the limit whether + // or not the email is registered, so it reveals nothing, and a + // "check your inbox" for a mail that was never sent would mislead. + // Every other failure is swallowed and shows the same success + // message, mirroring the server's no-enumeration posture. + throttled = rateLimitMessage(err); + submitted = throttled === null; + } finally { submitting = false; } } @@ -53,6 +59,9 @@ > {submitting ? 'Sending…' : 'Send reset link'} + {#if throttled} + + {/if} {:else}

diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index 82a2caf1..5d82ed8e 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { login } from '$lib/auth/store.svelte'; - import type { ApiError } from '$lib/api/client'; + import { rateLimitMessage, type ApiError } from '$lib/api/client'; let username = $state(''); let password = $state(''); @@ -30,7 +30,10 @@ goto(dest, { replaceState: true }); } catch (err) { const apiErr = err as ApiError; - if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') { + const throttled = rateLimitMessage(err); + if (throttled) { + error = throttled; + } else if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') { error = 'Invalid username or password.'; password = ''; } else { diff --git a/web/src/routes/register/+page.svelte b/web/src/routes/register/+page.svelte index c4eaca31..dd37293a 100644 --- a/web/src/routes/register/+page.svelte +++ b/web/src/routes/register/+page.svelte @@ -1,13 +1,28 @@ {pageTitle('Settings')} @@ -531,19 +603,23 @@

API Token

- Used by Subsonic clients (DSub, Symfonium, etc.) to authenticate to your library. - Regenerating invalidates clients that have the old token cached. + Used by Subsonic clients that sign in with an API key (OpenSubsonic apiKey). + Minstrel keeps only a fingerprint of the token, so it can't show you the current one. + Regenerate to get a new token; clients using the old one will need the new one.

{#if apiToken} {apiToken} +

Copy this now. It won't be shown again.

{/if}
- + {#if apiToken} + + {/if}
- + +
+

Subsonic password

+

+ For Subsonic apps that can't use an API token and ask for a username and password instead. + Sign those apps in with your username and this password, not your login password. + Minstrel generates it, and has to store it readable for these apps to work, so it is never + your login password. Use the API token where the app supports it. +

+ {#if subsonicEnabled !== null} +

+ {subsonicEnabled ? 'A Subsonic password is set.' : "No Subsonic password is set; these apps can't sign in."} +

+ {/if} + {#if subsonicPassword} + + {subsonicPassword} + +

Copy this now. It won't be shown again.

+ {/if} +
+ {#if subsonicPassword} + + {/if} + + {#if subsonicEnabled} + + {/if} +
+
+ +
diff --git a/web/src/routes/settings/settings.test.ts b/web/src/routes/settings/settings.test.ts index 7923241b..5ffd2b16 100644 --- a/web/src/routes/settings/settings.test.ts +++ b/web/src/routes/settings/settings.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte'; import { readable, writable } from 'svelte/store'; import type { LBStatus } from '$lib/api/listenbrainz'; @@ -16,8 +16,10 @@ vi.mock('$lib/api/me', () => ({ changePassword: vi.fn(), // Default to a resolved value so the page's $effect doesn't crash // on `.then()` of undefined when individual tests don't override. - getAPIToken: vi.fn().mockResolvedValue({ api_token: '' }), - regenerateAPIToken: vi.fn() + regenerateAPIToken: vi.fn(), + getSubsonicPasswordStatus: vi.fn(), + generateSubsonicPassword: vi.fn(), + clearSubsonicPassword: vi.fn() })); // Mutable holder so individual tests can inject populated metrics; @@ -46,8 +48,10 @@ import { import { updateProfile, changePassword, - getAPIToken, - regenerateAPIToken + regenerateAPIToken, + getSubsonicPasswordStatus, + generateSubsonicPassword, + clearSubsonicPassword } from '$lib/api/me'; function mockStatusStore(data: LBStatus) { @@ -133,7 +137,6 @@ function setupPage() { ); (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore()); (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); - (getAPIToken as ReturnType).mockResolvedValue({ api_token: 'tok_abc123' }); } describe('Settings page — Profile card', () => { @@ -350,5 +353,62 @@ describe('Settings page — API Token card', () => { ); await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i })); await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled()); + // The new key is shown once, with a way to copy it. + expect(await screen.findByText('new_tok_xyz')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^copy$/i })).toBeInTheDocument(); + }); + + test('no token is shown or fetched before Regenerate', async () => { + setupPage(); + render(SettingsPage); + await waitFor(() => + expect(screen.getByRole('button', { name: /regenerate/i })).toBeInTheDocument() + ); + expect(screen.queryByRole('button', { name: /^copy$/i })).toBeNull(); + }); +}); + +describe('Settings page — Subsonic password card', () => { + function mockLB() { + (createLBStatusQuery as ReturnType).mockReturnValue( + mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null }) + ); + (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore()); + (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); + } + + test('with none set, Generate creates one on the first click and shows it once', async () => { + mockLB(); + (getSubsonicPasswordStatus as ReturnType).mockResolvedValue({ enabled: false }); + (generateSubsonicPassword as ReturnType).mockResolvedValue({ password: 'gen_pw_123' }); + render(SettingsPage); + await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: /turn off/i })).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: /^generate$/i })); + await waitFor(() => expect(generateSubsonicPassword).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByText('gen_pw_123')).toBeInTheDocument()); + expect(screen.getByText(/a subsonic password is set/i)).toBeInTheDocument(); + }); + + test('with one set, Regenerate and Turn off each need a second click', async () => { + mockLB(); + (getSubsonicPasswordStatus as ReturnType).mockResolvedValue({ enabled: true }); + (clearSubsonicPassword as ReturnType).mockResolvedValue(undefined); + render(SettingsPage); + // The API token card has a Regenerate button too; the Subsonic card's is + // the one beside Turn off. + const turnOff = await screen.findByRole('button', { name: /turn off/i }); + const subsonicRegen = within(turnOff.parentElement!).getByRole('button', { name: /^regenerate$/i }); + + await fireEvent.click(subsonicRegen); + expect(generateSubsonicPassword).not.toHaveBeenCalled(); + expect(subsonicRegen).toHaveTextContent(/click again to confirm/i); + + await fireEvent.click(turnOff); + expect(clearSubsonicPassword).not.toHaveBeenCalled(); + await fireEvent.click(turnOff); + await waitFor(() => expect(clearSubsonicPassword).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument()); }); });