diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c1ba8714c..3bc539132 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -519,13 +519,18 @@ jobs: docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }} push-manifest-dockerhub: - name: Push to Docker Hub + name: Push to Docker Hub (${{ matrix.repository }}) runs-on: ubuntu-latest permissions: contents: read needs: [build, check-push-enabled] if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' continue-on-error: true + strategy: + fail-fast: false + matrix: + # Always push to DOCKER_HUB_REPO. The upstream repository also pushes to the navidrome/navidrome org repository. + repository: ${{ fromJSON(github.repository == 'navidrome/navidrome' && format('["{0}","navidrome/navidrome"]', vars.DOCKER_HUB_REPO) || format('["{0}"]', vars.DOCKER_HUB_REPO)) }} steps: - uses: actions/checkout@v7 @@ -541,7 +546,7 @@ jobs: id: docker with: github_token: ${{ secrets.GITHUB_TOKEN }} - hub_repository: ${{ vars.DOCKER_HUB_REPO }} + hub_repository: ${{ matrix.repository }} hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} @@ -558,7 +563,7 @@ jobs: - name: Inspect image in Docker Hub run: | - docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }} + docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }} cleanup-digests: name: Cleanup digest artifacts diff --git a/Makefile b/Makefile index f31d98a01..811c00054 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ endif SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386 IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//') PLATFORMS ?= $(SUPPORTED_PLATFORMS) -DOCKER_TAG ?= deluan/navidrome:develop +DOCKER_TAG ?= navidrome/navidrome:develop GOLANGCI_LINT_VERSION ?= v2.14.0 VACUUM_VERSION ?= v0.30.6 @@ -223,7 +223,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che --output "./binaries" --target binary . .PHONY: docker-build -docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms +docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms @echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true diff --git a/README.md b/README.md index 4bc85e6a6..a405319b6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases) [![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master) [![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest) -[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome) +[![Docker Pulls](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.navidrome.org%2Fbadges%2Fcontainer-downloads.json&logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/navidrome/navidrome) [![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF) [![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) @@ -35,11 +35,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta ## Cloud Hosting -[PikaPods](https://www.pikapods.com) has partnered with us to offer you an -[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods). -A share of the revenue helps fund the development of Navidrome at no additional cost for you. +Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you. + +Run on PikaPods +
+Deploy with Zenith +
+Deploy on ElfHosted + + + + -[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome) ## Features diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index e7dd18ac1..0f9a90d94 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - return new(metadata.ToMediaFile(1, "folderID")) + return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID")) } BeforeEach(func() { diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml index 261502224..bd31e23f8 100644 --- a/api/.vacuum.yaml +++ b/api/.vacuum.yaml @@ -36,14 +36,6 @@ rules: - sharing - radio - admin - - password - nd-operation-security-required: - description: Every operation declares security explicitly (use [] for public operations). - severity: error - given: $.paths[*][get,put,post,delete,patch] - then: - field: security - function: defined nd-operation-stability-level-required: description: Every operation declares its stability level, which the breaking-change gate relies on. severity: error diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index 5755ee75b..bfe794c71 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Navidrome API", "version": "1.0.0", - "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as\n`Authorization: Bearer \u003csecret\u003e`. A revoked grant stops working within one minute at most.\n", + "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n", "license": { "name": "GPL-3.0", "url": "https://www.gnu.org/licenses/gpl-3.0.html" @@ -18,10 +18,6 @@ { "name": "server", "description": "Server discovery and the published OpenAPI document." - }, - { - "name": "auth", - "description": "Grants and login methods." } ], "paths": { @@ -33,9 +29,8 @@ "tags": [ "server" ], - "security": [], "summary": "Describe the server", - "description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n", + "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n", "responses": { "200": { "description": "Server description.", @@ -53,41 +48,6 @@ } } }, - "/capabilities": { - "get": { - "operationId": "getCapabilities", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "summary": "List implemented capability modules", - "description": "The capability modules this server implements. Any valid grant may read it, whatever its scopes.", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Implemented modules.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Capabilities" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/openapi.json": { "get": { "operationId": "getOpenAPISpecJSON", @@ -96,7 +56,6 @@ "tags": [ "server" ], - "security": [], "summary": "Get the OpenAPI document (JSON)", "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", "responses": { @@ -122,56 +81,6 @@ } } }, - "/auth/grants": { - "get": { - "operationId": "listGrants", - "x-module": "core", - "x-scope": "read", - "x-stability-level": "alpha", - "tags": [ - "auth" - ], - "summary": "List my grants", - "description": "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed.", - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/offset" - }, - { - "$ref": "#/components/parameters/limit" - } - ], - "responses": { - "200": { - "description": "A page of grants.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GrantList" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/openapi.yaml": { "get": { "operationId": "getOpenAPISpecYAML", @@ -180,7 +89,6 @@ "tags": [ "server" ], - "security": [], "summary": "Get the OpenAPI document (YAML)", "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", "responses": { @@ -205,262 +113,6 @@ } } } - }, - "/auth/grants/{id}": { - "delete": { - "operationId": "revokeGrant", - "x-module": "core", - "x-scope": "read", - "x-stability-level": "alpha", - "tags": [ - "auth" - ], - "summary": "Revoke one of my grants", - "description": "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404.", - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Grant id.", - "schema": { - "type": "string", - "maxLength": 64 - } - } - ], - "responses": { - "204": { - "description": "Revoked." - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/auth/logout": { - "post": { - "operationId": "logout", - "x-module": "core", - "x-scope": "read", - "x-stability-level": "alpha", - "tags": [ - "auth" - ], - "summary": "Log out", - "description": "Revokes the grant that made this request.", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Logged out.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogoutResponse" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/auth/login": { - "post": { - "operationId": "login", - "x-module": "password", - "x-stability-level": "alpha", - "tags": [ - "auth" - ], - "summary": "Log in with a password", - "description": "Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.", - "security": [], - "requestBody": { - "description": "The credentials and a description of the client.", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CredentialsRequest" - } - } - } - }, - "responses": { - "200": { - "description": "The new grant.", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControlNoStore" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GrantCreated" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/auth/setup": { - "post": { - "operationId": "setupFirstAdmin", - "x-module": "password", - "x-stability-level": "alpha", - "tags": [ - "auth" - ], - "summary": "Create the first admin", - "description": "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409.", - "security": [], - "requestBody": { - "description": "The credentials and a description of the client.", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CredentialsRequest" - } - } - } - }, - "responses": { - "201": { - "description": "The admin was created.", - "headers": { - "Cache-Control": { - "$ref": "#/components/headers/CacheControlNoStore" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GrantCreated" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/auth/password": { - "post": { - "operationId": "changePassword", - "x-module": "password", - "x-scope": "password", - "x-stability-level": "alpha", - "tags": [ - "auth" - ], - "summary": "Change my password", - "description": "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server.", - "security": [ - { - "bearerAuth": [] - } - ], - "requestBody": { - "description": "The current and the new password.", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PasswordChangeRequest" - } - } - } - }, - "responses": { - "204": { - "description": "Password changed." - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } } }, "components": { @@ -468,7 +120,8 @@ "bearerAuth": { "type": "http", "scheme": "bearer", - "description": "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." + "bearerFormat": "JWT", + "description": "Short-lived access token minted from a device grant. Not yet applied to any operation." } }, "schemas": { @@ -500,23 +153,17 @@ "description": "True until the first admin user has been created." }, "loginMethods": { - "$ref": "#/components/schemas/LoginMethods" + "type": "array", + "description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.", + "items": { + "type": "string", + "enum": [ + "password" + ] + } } } }, - "LoginMethods": { - "type": "object", - "description": "Login methods this server accepts, keyed by method. A missing key means the method is not offered.\nKeys are optional on purpose: discovery is read by clients of any version against servers of any\nversion, so new methods are added as new optional keys. Clients ignore keys they do not know.\n", - "properties": { - "password": { - "$ref": "#/components/schemas/PasswordLoginMethod" - } - } - }, - "PasswordLoginMethod": { - "type": "object", - "description": "Username and password login (`POST /auth/login`). No settings yet." - }, "Problem": { "type": "object", "description": "RFC 9457 problem details, returned for every 4xx and 5xx response.", @@ -540,7 +187,7 @@ }, "detail": { "type": "string", - "description": "Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients." + "description": "Human-readable explanation specific to this occurrence. Omitted for internal errors." }, "code": { "type": "string", @@ -549,21 +196,12 @@ "validation", "unauthorized", "forbidden", - "insufficient_scope", "not_found", "method_not_allowed", - "setup_complete", - "password_managed_externally", - "payload_too_large", - "rate_limited", "unavailable", "internal" ] }, - "referenceId": { - "type": "string", - "description": "Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request." - }, "errors": { "type": "array", "description": "Per-field failures. Present only when `code` is `validation`.", @@ -590,310 +228,6 @@ "description": "Why the value was rejected." } } - }, - "Capabilities": { - "type": "object", - "description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling grant may use.\n", - "properties": { - "core": { - "$ref": "#/components/schemas/CoreCapability" - }, - "password": { - "$ref": "#/components/schemas/PasswordCapability" - } - } - }, - "CoreCapability": { - "type": "object", - "description": "The mandatory core module.", - "required": [ - "version" - ], - "properties": { - "version": { - "type": "integer", - "description": "Module version. Bumped only on semantic change." - } - } - }, - "PasswordCapability": { - "type": "object", - "description": "The password login module (login, first-admin setup, password change).", - "required": [ - "version" - ], - "properties": { - "version": { - "type": "integer", - "description": "Module version. Bumped only on semantic change." - } - } - }, - "GrantList": { - "type": "object", - "description": "A page of the caller's grants.", - "required": [ - "items", - "total", - "offset", - "limit" - ], - "properties": { - "items": { - "type": "array", - "description": "Grants on this page, by last use, most recent first; never-used grants last.", - "items": { - "$ref": "#/components/schemas/Grant" - } - }, - "total": { - "type": "integer", - "description": "Total number of grants." - }, - "offset": { - "type": "integer", - "description": "Zero-based index of the first returned item." - }, - "limit": { - "type": "integer", - "description": "Maximum number of items in this page." - } - } - }, - "LogoutResponse": { - "type": "object", - "description": "Result of a logout.", - "required": [ - "logoutUrl" - ], - "properties": { - "logoutUrl": { - "type": "string", - "nullable": true, - "description": "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do." - } - } - }, - "CredentialsRequest": { - "type": "object", - "description": "Username, password and client description for a login or first-admin setup.", - "required": [ - "username", - "password", - "client" - ], - "properties": { - "username": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Login name." - }, - "password": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "Password." - }, - "client": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "description": "Name of the client app." - }, - "clientVersion": { - "type": "string", - "maxLength": 32, - "description": "Version of the client app." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "description": "Label for this grant. Defaults to `client`." - }, - "scopes": { - "type": "array", - "maxItems": 32, - "description": "Scopes the grant may hold. Omit for `all`.", - "items": { - "$ref": "#/components/schemas/ScopeRequest" - } - } - } - }, - "GrantCreated": { - "type": "object", - "description": "Returned by every login method. The secret is shown only here; store it and never parse it.", - "required": [ - "secret", - "grant", - "user" - ], - "properties": { - "secret": { - "type": "string", - "maxLength": 512, - "description": "Opaque grant secret. Send it as `Authorization: Bearer \u003csecret\u003e`." - }, - "grant": { - "description": "The new grant.", - "allOf": [ - { - "$ref": "#/components/schemas/Grant" - } - ] - }, - "user": { - "description": "The user the grant belongs to.", - "allOf": [ - { - "$ref": "#/components/schemas/AuthUser" - } - ] - } - } - }, - "PasswordChangeRequest": { - "type": "object", - "description": "Change the caller's own password.", - "required": [ - "currentPassword", - "newPassword" - ], - "properties": { - "currentPassword": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "The current password." - }, - "newPassword": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "The new password." - }, - "revokeOtherGrants": { - "type": "boolean", - "default": true, - "description": "Revoke every other grant of the user. The calling grant always survives. Default true." - } - } - }, - "Grant": { - "type": "object", - "description": "A long-lived grant held by one client of one user.", - "required": [ - "id", - "name", - "client", - "clientVersion", - "scopes", - "provider", - "createdAt", - "lastUsedAt", - "lastUsedIp", - "current" - ], - "properties": { - "id": { - "type": "string", - "description": "Grant id." - }, - "name": { - "type": "string", - "description": "Label shown to the user." - }, - "client": { - "type": "string", - "description": "Name of the client app that holds the grant." - }, - "clientVersion": { - "type": "string", - "nullable": true, - "description": "Version of the client app, when it sent one." - }, - "scopes": { - "type": "array", - "description": "Scopes this grant carries.", - "items": { - "$ref": "#/components/schemas/Scope" - } - }, - "provider": { - "type": "string", - "description": "How the grant was created, for example `password` or `setup`. Free-form; new values may appear." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the grant was created." - }, - "lastUsedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When the grant was last used, at a coarse granularity. Null until first use." - }, - "lastUsedIp": { - "type": "string", - "nullable": true, - "description": "Client IP of the last use. Null until first use." - }, - "current": { - "type": "boolean", - "description": "True for the grant that made this request." - } - } - }, - "Scope": { - "type": "string", - "description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n", - "enum": [ - "all", - "read", - "password" - ] - }, - "ScopeRequest": { - "type": "string", - "description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.", - "pattern": "^[a-z][a-z-]*(:write)?$", - "maxLength": 64 - }, - "AuthUser": { - "type": "object", - "description": "The user a grant belongs to.", - "required": [ - "id", - "userName", - "name", - "isAdmin", - "passwordChangeable" - ], - "properties": { - "id": { - "type": "string", - "description": "User id." - }, - "userName": { - "type": "string", - "description": "Login name." - }, - "name": { - "type": "string", - "description": "Display name." - }, - "isAdmin": { - "type": "boolean", - "description": "Whether the user is an administrator." - }, - "passwordChangeable": { - "type": "boolean", - "description": "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false." - } - } } }, "responses": { @@ -907,21 +241,6 @@ } } }, - "Unauthorized": { - "description": "Missing, invalid, or expired credentials.", - "headers": { - "WWW-Authenticate": { - "$ref": "#/components/headers/WWWAuthenticate" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, "NotModified": { "description": "Not modified.", "headers": { @@ -929,127 +248,14 @@ "$ref": "#/components/headers/ETag" } } - }, - "BadRequest": { - "description": "The request is malformed or fails validation.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "Forbidden": { - "description": "The caller is authenticated but not allowed to do this.", - "headers": { - "WWW-Authenticate": { - "$ref": "#/components/headers/WWWAuthenticate" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "NotFound": { - "description": "No such resource or endpoint.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "PayloadTooLarge": { - "description": "The request body is too large (`payload_too_large`).", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "TooManyRequests": { - "description": "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds.", - "headers": { - "Retry-After": { - "description": "Seconds to wait before retrying.", - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "Conflict": { - "description": "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - } - }, - "parameters": { - "offset": { - "name": "offset", - "in": "query", - "description": "Zero-based index of the first item to return.", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0 - } - }, - "limit": { - "name": "limit", - "in": "query", - "description": "Maximum number of items to return.", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 2000, - "default": 100 - } } }, "headers": { - "WWWAuthenticate": { - "description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.", - "schema": { - "type": "string" - } - }, "ETag": { "description": "Entity tag for `If-None-Match` revalidation.", "schema": { "type": "string" } - }, - "CacheControlNoStore": { - "description": "Always `no-store`, because the response carries a secret.", - "schema": { - "type": "string", - "enum": [ - "no-store" - ] - } } } } diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index 8b430b629..f1ae95b76 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: | Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /capabilities` and never sniff versions. + capability modules through `GET /server` and never sniff versions. Enums are open: new values may be added to any enum within v1. Clients must accept values they do not recognise instead of failing. @@ -15,10 +15,6 @@ info: `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods in its `Allow` header. - - Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in - `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as - `Authorization: Bearer `. A revoked grant stops working within one minute at most. license: name: GPL-3.0 url: https://www.gnu.org/licenses/gpl-3.0.html @@ -27,8 +23,6 @@ servers: tags: - name: server description: Server discovery and the published OpenAPI document. - - name: auth - description: Grants and login methods. paths: /server: get: @@ -36,11 +30,11 @@ paths: x-module: core x-stability-level: alpha tags: [server] - security: [] summary: Describe the server description: | Returns the public server description. No authentication required. - Capability modules are listed by `GET /capabilities`. + Authenticated requests will additionally receive the implemented capability modules + once authentication is available. responses: '200': description: Server description. @@ -50,30 +44,12 @@ paths: $ref: '#/components/schemas/ServerInfo' '500': $ref: '#/components/responses/InternalError' - /capabilities: - get: - operationId: getCapabilities - x-module: core - x-stability-level: alpha - tags: [server] - summary: List implemented capability modules - description: The capability modules this server implements. Any valid grant may read it, whatever its scopes. - security: [{bearerAuth: []}] - responses: - '200': - description: Implemented modules. - content: - application/json: - schema: {$ref: '#/components/schemas/Capabilities'} - '401': {$ref: '#/components/responses/Unauthorized'} - '500': {$ref: '#/components/responses/InternalError'} /openapi.json: get: operationId: getOpenAPISpecJSON x-module: core x-stability-level: alpha tags: [server] - security: [] summary: Get the OpenAPI document (JSON) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: @@ -89,41 +65,12 @@ paths: description: OpenAPI 3.0 document. '304': $ref: '#/components/responses/NotModified' - /auth/grants: - get: - operationId: listGrants - x-module: core - x-scope: read - x-stability-level: alpha - tags: [auth] - summary: List my grants - description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed." - security: [{bearerAuth: []}] - parameters: - - $ref: '#/components/parameters/offset' - - $ref: '#/components/parameters/limit' - responses: - '200': - description: A page of grants. - content: - application/json: - schema: - $ref: '#/components/schemas/GrantList' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '500': - $ref: '#/components/responses/InternalError' /openapi.yaml: get: operationId: getOpenAPISpecYAML x-module: core x-stability-level: alpha tags: [server] - security: [] summary: Get the OpenAPI document (YAML) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: @@ -139,172 +86,13 @@ paths: description: OpenAPI 3.0 document. '304': $ref: '#/components/responses/NotModified' - /auth/grants/{id}: - delete: - operationId: revokeGrant - x-module: core - x-scope: read - x-stability-level: alpha - tags: [auth] - summary: Revoke one of my grants - description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404." - security: [{bearerAuth: []}] - parameters: - - name: id - in: path - required: true - description: Grant id. - schema: - type: string - maxLength: 64 - responses: - '204': - description: Revoked. - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - /auth/logout: - post: - operationId: logout - x-module: core - x-scope: read - x-stability-level: alpha - tags: [auth] - summary: Log out - description: Revokes the grant that made this request. - security: [{bearerAuth: []}] - responses: - '200': - description: Logged out. - content: - application/json: - schema: - $ref: '#/components/schemas/LogoutResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '500': - $ref: '#/components/responses/InternalError' - /auth/login: - post: - operationId: login - x-module: password - x-stability-level: alpha - tags: [auth] - summary: Log in with a password - description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way. - security: [] - requestBody: - description: The credentials and a description of the client. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CredentialsRequest' - responses: - '200': - description: The new grant. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControlNoStore' - content: - application/json: - schema: - $ref: '#/components/schemas/GrantCreated' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '429': - $ref: '#/components/responses/TooManyRequests' - '500': - $ref: '#/components/responses/InternalError' - /auth/setup: - post: - operationId: setupFirstAdmin - x-module: password - x-stability-level: alpha - tags: [auth] - summary: Create the first admin - description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409." - security: [] - requestBody: - description: The credentials and a description of the client. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CredentialsRequest' - responses: - '201': - description: The admin was created. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControlNoStore' - content: - application/json: - schema: - $ref: '#/components/schemas/GrantCreated' - '400': - $ref: '#/components/responses/BadRequest' - '409': - $ref: '#/components/responses/Conflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '429': - $ref: '#/components/responses/TooManyRequests' - '500': - $ref: '#/components/responses/InternalError' - /auth/password: - post: - operationId: changePassword - x-module: password - x-scope: password - x-stability-level: alpha - tags: [auth] - summary: Change my password - description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server." - security: [{bearerAuth: []}] - requestBody: - description: The current and the new password. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PasswordChangeRequest' - responses: - '204': - description: Password changed. - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/Conflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '429': - $ref: '#/components/responses/TooManyRequests' - '500': - $ref: '#/components/responses/InternalError' components: securitySchemes: bearerAuth: type: http scheme: bearer - description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." + bearerFormat: JWT + description: Short-lived access token minted from a device grant. Not yet applied to any operation. schemas: ServerInfo: type: object @@ -329,19 +117,12 @@ components: type: boolean description: True until the first admin user has been created. loginMethods: - $ref: '#/components/schemas/LoginMethods' - LoginMethods: - type: object - description: | - Login methods this server accepts, keyed by method. A missing key means the method is not offered. - Keys are optional on purpose: discovery is read by clients of any version against servers of any - version, so new methods are added as new optional keys. Clients ignore keys they do not know. - properties: - password: - $ref: '#/components/schemas/PasswordLoginMethod' - PasswordLoginMethod: - type: object - description: Username and password login (`POST /auth/login`). No settings yet. + type: array + description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. + items: + type: string + enum: + - password Problem: type: object description: RFC 9457 problem details, returned for every 4xx and 5xx response. @@ -364,7 +145,7 @@ components: description: HTTP status code of this response. detail: type: string - description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. + description: Human-readable explanation specific to this occurrence. Omitted for internal errors. code: type: string description: Machine-readable error code, and the value clients switch on. New codes may be added. @@ -372,18 +153,10 @@ components: - validation - unauthorized - forbidden - - insufficient_scope - not_found - method_not_allowed - - setup_complete - - password_managed_externally - - payload_too_large - - rate_limited - unavailable - internal - referenceId: - type: string - description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request. errors: type: array description: Per-field failures. Present only when `code` is `validation`. @@ -402,238 +175,6 @@ components: message: type: string description: Why the value was rejected. - Capabilities: - type: object - description: | - Capability modules this server implements, keyed by module. Keys are optional; a missing key means the - module is not implemented. New modules are added as new optional keys. These are server facts, not what - the calling grant may use. - properties: - core: - $ref: '#/components/schemas/CoreCapability' - password: - $ref: '#/components/schemas/PasswordCapability' - CoreCapability: - type: object - description: The mandatory core module. - required: - - version - properties: - version: - type: integer - description: Module version. Bumped only on semantic change. - PasswordCapability: - type: object - description: The password login module (login, first-admin setup, password change). - required: - - version - properties: - version: - type: integer - description: Module version. Bumped only on semantic change. - GrantList: - type: object - description: "A page of the caller's grants." - required: - - items - - total - - offset - - limit - properties: - items: - type: array - description: "Grants on this page, by last use, most recent first; never-used grants last." - items: - $ref: '#/components/schemas/Grant' - total: - type: integer - description: Total number of grants. - offset: - type: integer - description: Zero-based index of the first returned item. - limit: - type: integer - description: Maximum number of items in this page. - LogoutResponse: - type: object - description: Result of a logout. - required: - - logoutUrl - properties: - logoutUrl: - type: string - nullable: true - description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do." - CredentialsRequest: - type: object - description: "Username, password and client description for a login or first-admin setup." - required: - - username - - password - - client - properties: - username: - type: string - minLength: 1 - maxLength: 255 - description: Login name. - password: - type: string - minLength: 1 - maxLength: 1024 - description: Password. - client: - type: string - minLength: 1 - maxLength: 64 - description: Name of the client app. - clientVersion: - type: string - maxLength: 32 - description: Version of the client app. - name: - type: string - minLength: 1 - maxLength: 64 - description: "Label for this grant. Defaults to `client`." - scopes: - type: array - maxItems: 32 - description: "Scopes the grant may hold. Omit for `all`." - items: - $ref: '#/components/schemas/ScopeRequest' - GrantCreated: - type: object - description: "Returned by every login method. The secret is shown only here; store it and never parse it." - required: - - secret - - grant - - user - properties: - secret: - type: string - maxLength: 512 - description: "Opaque grant secret. Send it as `Authorization: Bearer `." - grant: - description: The new grant. - allOf: - - $ref: '#/components/schemas/Grant' - user: - description: The user the grant belongs to. - allOf: - - $ref: '#/components/schemas/AuthUser' - PasswordChangeRequest: - type: object - description: "Change the caller's own password." - required: - - currentPassword - - newPassword - properties: - currentPassword: - type: string - minLength: 1 - maxLength: 1024 - description: The current password. - newPassword: - type: string - minLength: 1 - maxLength: 1024 - description: The new password. - revokeOtherGrants: - type: boolean - default: true - description: "Revoke every other grant of the user. The calling grant always survives. Default true." - Grant: - type: object - description: A long-lived grant held by one client of one user. - required: - - id - - name - - client - - clientVersion - - scopes - - provider - - createdAt - - lastUsedAt - - lastUsedIp - - current - properties: - id: - type: string - description: Grant id. - name: - type: string - description: Label shown to the user. - client: - type: string - description: Name of the client app that holds the grant. - clientVersion: - type: string - nullable: true - description: "Version of the client app, when it sent one." - scopes: - type: array - description: Scopes this grant carries. - items: - $ref: '#/components/schemas/Scope' - provider: - type: string - description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear." - createdAt: - type: string - format: date-time - description: When the grant was created. - lastUsedAt: - type: string - format: date-time - nullable: true - description: "When the grant was last used, at a coarse granularity. Null until first use." - lastUsedIp: - type: string - nullable: true - description: Client IP of the last use. Null until first use. - current: - type: boolean - description: True for the grant that made this request. - Scope: - type: string - description: | - A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on - grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. - enum: - - all - - read - - password - ScopeRequest: - type: string - description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working." - pattern: '^[a-z][a-z-]*(:write)?$' - maxLength: 64 - AuthUser: - type: object - description: The user a grant belongs to. - required: - - id - - userName - - name - - isAdmin - - passwordChangeable - properties: - id: - type: string - description: User id. - userName: - type: string - description: Login name. - name: - type: string - description: Display name. - isAdmin: - type: boolean - description: Whether the user is an administrator. - passwordChangeable: - type: boolean - description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false." responses: InternalError: description: Unexpected server failure. Details are in the server log. @@ -641,96 +182,13 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' - Unauthorized: - description: Missing, invalid, or expired credentials. - headers: - WWW-Authenticate: - $ref: '#/components/headers/WWWAuthenticate' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' NotModified: description: Not modified. headers: ETag: $ref: '#/components/headers/ETag' - BadRequest: - description: The request is malformed or fails validation. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - Forbidden: - description: The caller is authenticated but not allowed to do this. - headers: - WWW-Authenticate: - $ref: '#/components/headers/WWWAuthenticate' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - NotFound: - description: No such resource or endpoint. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - PayloadTooLarge: - description: "The request body is too large (`payload_too_large`)." - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - TooManyRequests: - description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds." - headers: - Retry-After: - description: Seconds to wait before retrying. - schema: - type: integer - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - Conflict: - description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`." - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - parameters: - offset: - name: offset - in: query - description: Zero-based index of the first item to return. - required: false - schema: - type: integer - minimum: 0 - default: 0 - limit: - name: limit - in: query - description: Maximum number of items to return. - required: false - schema: - type: integer - minimum: 1 - maximum: 2000 - default: 100 headers: - WWWAuthenticate: - description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' - schema: - type: string ETag: description: Entity tag for `If-None-Match` revalidation. schema: type: string - CacheControlNoStore: - description: Always `no-store`, because the response carries a secret. - schema: - type: string - enum: - - no-store diff --git a/api/openapi/components/headers/CacheControlNoStore.yaml b/api/openapi/components/headers/CacheControlNoStore.yaml deleted file mode 100644 index 07250936b..000000000 --- a/api/openapi/components/headers/CacheControlNoStore.yaml +++ /dev/null @@ -1,4 +0,0 @@ -description: Always `no-store`, because the response carries a secret. -schema: - type: string - enum: [no-store] diff --git a/api/openapi/components/headers/WWWAuthenticate.yaml b/api/openapi/components/headers/WWWAuthenticate.yaml deleted file mode 100644 index 65d5fb2fb..000000000 --- a/api/openapi/components/headers/WWWAuthenticate.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' -schema: - type: string diff --git a/api/openapi/components/responses/Conflict.yaml b/api/openapi/components/responses/Conflict.yaml deleted file mode 100644 index a602ffd6b..000000000 --- a/api/openapi/components/responses/Conflict.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`." -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/Forbidden.yaml b/api/openapi/components/responses/Forbidden.yaml index 9f39b6b53..6259185ea 100644 --- a/api/openapi/components/responses/Forbidden.yaml +++ b/api/openapi/components/responses/Forbidden.yaml @@ -1,7 +1,4 @@ description: The caller is authenticated but not allowed to do this. -headers: - WWW-Authenticate: - $ref: ../headers/WWWAuthenticate.yaml content: application/problem+json: schema: diff --git a/api/openapi/components/responses/PayloadTooLarge.yaml b/api/openapi/components/responses/PayloadTooLarge.yaml deleted file mode 100644 index 4918bf1e7..000000000 --- a/api/openapi/components/responses/PayloadTooLarge.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: "The request body is too large (`payload_too_large`)." -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/TooManyRequests.yaml b/api/openapi/components/responses/TooManyRequests.yaml deleted file mode 100644 index de82cd59e..000000000 --- a/api/openapi/components/responses/TooManyRequests.yaml +++ /dev/null @@ -1,10 +0,0 @@ -description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds." -headers: - Retry-After: - description: Seconds to wait before retrying. - schema: - type: integer -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/Unauthorized.yaml b/api/openapi/components/responses/Unauthorized.yaml index 87d5bab08..0209f4dd9 100644 --- a/api/openapi/components/responses/Unauthorized.yaml +++ b/api/openapi/components/responses/Unauthorized.yaml @@ -1,7 +1,4 @@ description: Missing, invalid, or expired credentials. -headers: - WWW-Authenticate: - $ref: ../headers/WWWAuthenticate.yaml content: application/problem+json: schema: diff --git a/api/openapi/components/schemas/AuthUser.yaml b/api/openapi/components/schemas/AuthUser.yaml deleted file mode 100644 index 722a6823c..000000000 --- a/api/openapi/components/schemas/AuthUser.yaml +++ /dev/null @@ -1,19 +0,0 @@ -type: object -description: The user a grant belongs to. -required: [id, userName, name, isAdmin, passwordChangeable] -properties: - id: - type: string - description: User id. - userName: - type: string - description: Login name. - name: - type: string - description: Display name. - isAdmin: - type: boolean - description: Whether the user is an administrator. - passwordChangeable: - type: boolean - description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false." diff --git a/api/openapi/components/schemas/Capabilities.yaml b/api/openapi/components/schemas/Capabilities.yaml deleted file mode 100644 index aa760e8e5..000000000 --- a/api/openapi/components/schemas/Capabilities.yaml +++ /dev/null @@ -1,10 +0,0 @@ -type: object -description: | - Capability modules this server implements, keyed by module. Keys are optional; a missing key means the - module is not implemented. New modules are added as new optional keys. These are server facts, not what - the calling grant may use. -properties: - core: - $ref: ./CoreCapability.yaml - password: - $ref: ./PasswordCapability.yaml diff --git a/api/openapi/components/schemas/CoreCapability.yaml b/api/openapi/components/schemas/CoreCapability.yaml deleted file mode 100644 index c52c21318..000000000 --- a/api/openapi/components/schemas/CoreCapability.yaml +++ /dev/null @@ -1,5 +0,0 @@ -type: object -description: The mandatory core module. -required: [version] -properties: - version: {type: integer, description: Module version. Bumped only on semantic change.} diff --git a/api/openapi/components/schemas/CredentialsRequest.yaml b/api/openapi/components/schemas/CredentialsRequest.yaml deleted file mode 100644 index 08e198b23..000000000 --- a/api/openapi/components/schemas/CredentialsRequest.yaml +++ /dev/null @@ -1,34 +0,0 @@ -type: object -description: "Username, password and client description for a login or first-admin setup." -required: [username, password, client] -properties: - username: - type: string - minLength: 1 - maxLength: 255 - description: Login name. - password: - type: string - minLength: 1 - maxLength: 1024 - description: Password. - client: - type: string - minLength: 1 - maxLength: 64 - description: Name of the client app. - clientVersion: - type: string - maxLength: 32 - description: Version of the client app. - name: - type: string - minLength: 1 - maxLength: 64 - description: "Label for this grant. Defaults to `client`." - scopes: - type: array - maxItems: 32 - description: "Scopes the grant may hold. Omit for `all`." - items: - $ref: ./ScopeRequest.yaml diff --git a/api/openapi/components/schemas/Grant.yaml b/api/openapi/components/schemas/Grant.yaml deleted file mode 100644 index 95b4aae19..000000000 --- a/api/openapi/components/schemas/Grant.yaml +++ /dev/null @@ -1,41 +0,0 @@ -type: object -description: A long-lived grant held by one client of one user. -required: [id, name, client, clientVersion, scopes, provider, createdAt, lastUsedAt, lastUsedIp, current] -properties: - id: - type: string - description: Grant id. - name: - type: string - description: Label shown to the user. - client: - type: string - description: Name of the client app that holds the grant. - clientVersion: - type: string - nullable: true - description: "Version of the client app, when it sent one." - scopes: - type: array - description: Scopes this grant carries. - items: - $ref: ./Scope.yaml - provider: - type: string - description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear." - createdAt: - type: string - format: date-time - description: When the grant was created. - lastUsedAt: - type: string - format: date-time - nullable: true - description: "When the grant was last used, at a coarse granularity. Null until first use." - lastUsedIp: - type: string - nullable: true - description: Client IP of the last use. Null until first use. - current: - type: boolean - description: True for the grant that made this request. diff --git a/api/openapi/components/schemas/GrantCreated.yaml b/api/openapi/components/schemas/GrantCreated.yaml deleted file mode 100644 index d385ba335..000000000 --- a/api/openapi/components/schemas/GrantCreated.yaml +++ /dev/null @@ -1,16 +0,0 @@ -type: object -description: "Returned by every login method. The secret is shown only here; store it and never parse it." -required: [secret, grant, user] -properties: - secret: - type: string - maxLength: 512 - description: "Opaque grant secret. Send it as `Authorization: Bearer `." - grant: - description: The new grant. - allOf: - - $ref: ./Grant.yaml - user: - description: The user the grant belongs to. - allOf: - - $ref: ./AuthUser.yaml diff --git a/api/openapi/components/schemas/GrantList.yaml b/api/openapi/components/schemas/GrantList.yaml deleted file mode 100644 index 8e80391ae..000000000 --- a/api/openapi/components/schemas/GrantList.yaml +++ /dev/null @@ -1,18 +0,0 @@ -type: object -description: "A page of the caller's grants." -required: [items, total, offset, limit] -properties: - items: - type: array - description: "Grants on this page, by last use, most recent first; never-used grants last." - items: - $ref: ./Grant.yaml - total: - type: integer - description: Total number of grants. - offset: - type: integer - description: Zero-based index of the first returned item. - limit: - type: integer - description: Maximum number of items in this page. diff --git a/api/openapi/components/schemas/LoginMethods.yaml b/api/openapi/components/schemas/LoginMethods.yaml deleted file mode 100644 index 106d3a6ed..000000000 --- a/api/openapi/components/schemas/LoginMethods.yaml +++ /dev/null @@ -1,8 +0,0 @@ -type: object -description: | - Login methods this server accepts, keyed by method. A missing key means the method is not offered. - Keys are optional on purpose: discovery is read by clients of any version against servers of any - version, so new methods are added as new optional keys. Clients ignore keys they do not know. -properties: - password: - $ref: ./PasswordLoginMethod.yaml diff --git a/api/openapi/components/schemas/LogoutResponse.yaml b/api/openapi/components/schemas/LogoutResponse.yaml deleted file mode 100644 index 12700fba7..000000000 --- a/api/openapi/components/schemas/LogoutResponse.yaml +++ /dev/null @@ -1,8 +0,0 @@ -type: object -description: Result of a logout. -required: [logoutUrl] -properties: - logoutUrl: - type: string - nullable: true - description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do." diff --git a/api/openapi/components/schemas/PasswordCapability.yaml b/api/openapi/components/schemas/PasswordCapability.yaml deleted file mode 100644 index 319cdbf3d..000000000 --- a/api/openapi/components/schemas/PasswordCapability.yaml +++ /dev/null @@ -1,5 +0,0 @@ -type: object -description: The password login module (login, first-admin setup, password change). -required: [version] -properties: - version: {type: integer, description: Module version. Bumped only on semantic change.} diff --git a/api/openapi/components/schemas/PasswordChangeRequest.yaml b/api/openapi/components/schemas/PasswordChangeRequest.yaml deleted file mode 100644 index c176014c4..000000000 --- a/api/openapi/components/schemas/PasswordChangeRequest.yaml +++ /dev/null @@ -1,18 +0,0 @@ -type: object -description: "Change the caller's own password." -required: [currentPassword, newPassword] -properties: - currentPassword: - type: string - minLength: 1 - maxLength: 1024 - description: The current password. - newPassword: - type: string - minLength: 1 - maxLength: 1024 - description: The new password. - revokeOtherGrants: - type: boolean - default: true - description: "Revoke every other grant of the user. The calling grant always survives. Default true." diff --git a/api/openapi/components/schemas/PasswordLoginMethod.yaml b/api/openapi/components/schemas/PasswordLoginMethod.yaml deleted file mode 100644 index e8a1f0ee2..000000000 --- a/api/openapi/components/schemas/PasswordLoginMethod.yaml +++ /dev/null @@ -1,2 +0,0 @@ -type: object -description: Username and password login (`POST /auth/login`). No settings yet. diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml index fb966f914..0fd36d4b1 100644 --- a/api/openapi/components/schemas/Problem.yaml +++ b/api/openapi/components/schemas/Problem.yaml @@ -16,7 +16,7 @@ properties: description: HTTP status code of this response. detail: type: string - description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. + description: Human-readable explanation specific to this occurrence. Omitted for internal errors. code: type: string description: Machine-readable error code, and the value clients switch on. New codes may be added. @@ -24,18 +24,10 @@ properties: - validation - unauthorized - forbidden - - insufficient_scope - not_found - method_not_allowed - - setup_complete - - password_managed_externally - - payload_too_large - - rate_limited - unavailable - internal - referenceId: - type: string - description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request. errors: type: array description: Per-field failures. Present only when `code` is `validation`. diff --git a/api/openapi/components/schemas/Scope.yaml b/api/openapi/components/schemas/Scope.yaml deleted file mode 100644 index a1763945f..000000000 --- a/api/openapi/components/schemas/Scope.yaml +++ /dev/null @@ -1,5 +0,0 @@ -type: string -description: | - A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on - grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. -enum: [all, read, password] diff --git a/api/openapi/components/schemas/ScopeRequest.yaml b/api/openapi/components/schemas/ScopeRequest.yaml deleted file mode 100644 index a9a936147..000000000 --- a/api/openapi/components/schemas/ScopeRequest.yaml +++ /dev/null @@ -1,4 +0,0 @@ -type: string -description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working." -pattern: '^[a-z][a-z-]*(:write)?$' -maxLength: 64 diff --git a/api/openapi/components/schemas/ServerInfo.yaml b/api/openapi/components/schemas/ServerInfo.yaml index f80fbce5c..8906924eb 100644 --- a/api/openapi/components/schemas/ServerInfo.yaml +++ b/api/openapi/components/schemas/ServerInfo.yaml @@ -15,4 +15,8 @@ properties: type: boolean description: True until the first admin user has been created. loginMethods: - $ref: ./LoginMethods.yaml + type: array + description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. + items: + type: string + enum: [password] diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 02a0bda8d..73cbb7b27 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: | Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /capabilities` and never sniff versions. + capability modules through `GET /server` and never sniff versions. Enums are open: new values may be added to any enum within v1. Clients must accept values they do not recognise instead of failing. @@ -15,10 +15,6 @@ info: `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods in its `Allow` header. - - Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in - `x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as - `Authorization: Bearer `. A revoked grant stops working within one minute at most. license: name: GPL-3.0 url: https://www.gnu.org/licenses/gpl-3.0.html @@ -27,32 +23,17 @@ servers: tags: - name: server description: Server discovery and the published OpenAPI document. - - name: auth - description: Grants and login methods. paths: /server: $ref: ./paths/server.yaml - /capabilities: - $ref: ./paths/capabilities.yaml /openapi.json: $ref: ./paths/openapi.yaml#/json /openapi.yaml: $ref: ./paths/openapi.yaml#/yaml - /auth/grants: - $ref: ./paths/auth.yaml#/grants - /auth/grants/{id}: - $ref: ./paths/auth.yaml#/grant - /auth/logout: - $ref: ./paths/auth.yaml#/logout - /auth/login: - $ref: ./paths/auth.yaml#/login - /auth/setup: - $ref: ./paths/auth.yaml#/setup - /auth/password: - $ref: ./paths/auth.yaml#/password components: securitySchemes: bearerAuth: type: http scheme: bearer - description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`." + bearerFormat: JWT + description: Short-lived access token minted from a device grant. Not yet applied to any operation. diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml deleted file mode 100644 index 83c28ea15..000000000 --- a/api/openapi/paths/auth.yaml +++ /dev/null @@ -1,188 +0,0 @@ -grants: - get: - operationId: listGrants - x-module: core - x-scope: read - x-stability-level: alpha - tags: [auth] - summary: List my grants - description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed." - security: [{bearerAuth: []}] - parameters: - - $ref: ../components/parameters/offset.yaml - - $ref: ../components/parameters/limit.yaml - responses: - '200': - description: A page of grants. - content: - application/json: - schema: - $ref: ../components/schemas/GrantList.yaml - '400': - $ref: ../components/responses/BadRequest.yaml - '401': - $ref: ../components/responses/Unauthorized.yaml - '403': - $ref: ../components/responses/Forbidden.yaml - '500': - $ref: ../components/responses/InternalError.yaml -grant: - delete: - operationId: revokeGrant - x-module: core - x-scope: read - x-stability-level: alpha - tags: [auth] - summary: Revoke one of my grants - description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404." - security: [{bearerAuth: []}] - parameters: - - name: id - in: path - required: true - description: Grant id. - schema: - type: string - maxLength: 64 - responses: - '204': - description: Revoked. - '400': - $ref: ../components/responses/BadRequest.yaml - '401': - $ref: ../components/responses/Unauthorized.yaml - '403': - $ref: ../components/responses/Forbidden.yaml - '404': - $ref: ../components/responses/NotFound.yaml - '500': - $ref: ../components/responses/InternalError.yaml -logout: - post: - operationId: logout - x-module: core - x-scope: read - x-stability-level: alpha - tags: [auth] - summary: Log out - description: Revokes the grant that made this request. - security: [{bearerAuth: []}] - responses: - '200': - description: Logged out. - content: - application/json: - schema: - $ref: ../components/schemas/LogoutResponse.yaml - '401': - $ref: ../components/responses/Unauthorized.yaml - '403': - $ref: ../components/responses/Forbidden.yaml - '500': - $ref: ../components/responses/InternalError.yaml -login: - post: - operationId: login - x-module: password - x-stability-level: alpha - tags: [auth] - summary: Log in with a password - description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way. - security: [] - requestBody: - description: The credentials and a description of the client. - required: true - content: - application/json: - schema: - $ref: ../components/schemas/CredentialsRequest.yaml - responses: - '200': - description: The new grant. - headers: - Cache-Control: - $ref: ../components/headers/CacheControlNoStore.yaml - content: - application/json: - schema: - $ref: ../components/schemas/GrantCreated.yaml - '400': - $ref: ../components/responses/BadRequest.yaml - '401': - $ref: ../components/responses/Unauthorized.yaml - '413': - $ref: ../components/responses/PayloadTooLarge.yaml - '429': - $ref: ../components/responses/TooManyRequests.yaml - '500': - $ref: ../components/responses/InternalError.yaml -setup: - post: - operationId: setupFirstAdmin - x-module: password - x-stability-level: alpha - tags: [auth] - summary: Create the first admin - description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409." - security: [] - requestBody: - description: The credentials and a description of the client. - required: true - content: - application/json: - schema: - $ref: ../components/schemas/CredentialsRequest.yaml - responses: - '201': - description: The admin was created. - headers: - Cache-Control: - $ref: ../components/headers/CacheControlNoStore.yaml - content: - application/json: - schema: - $ref: ../components/schemas/GrantCreated.yaml - '400': - $ref: ../components/responses/BadRequest.yaml - '409': - $ref: ../components/responses/Conflict.yaml - '413': - $ref: ../components/responses/PayloadTooLarge.yaml - '429': - $ref: ../components/responses/TooManyRequests.yaml - '500': - $ref: ../components/responses/InternalError.yaml -password: - post: - operationId: changePassword - x-module: password - x-scope: password - x-stability-level: alpha - tags: [auth] - summary: Change my password - description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server." - security: [{bearerAuth: []}] - requestBody: - description: The current and the new password. - required: true - content: - application/json: - schema: - $ref: ../components/schemas/PasswordChangeRequest.yaml - responses: - '204': - description: Password changed. - '400': - $ref: ../components/responses/BadRequest.yaml - '401': - $ref: ../components/responses/Unauthorized.yaml - '403': - $ref: ../components/responses/Forbidden.yaml - '409': - $ref: ../components/responses/Conflict.yaml - '413': - $ref: ../components/responses/PayloadTooLarge.yaml - '429': - $ref: ../components/responses/TooManyRequests.yaml - '500': - $ref: ../components/responses/InternalError.yaml diff --git a/api/openapi/paths/capabilities.yaml b/api/openapi/paths/capabilities.yaml deleted file mode 100644 index dc0d9d7ca..000000000 --- a/api/openapi/paths/capabilities.yaml +++ /dev/null @@ -1,16 +0,0 @@ -get: - operationId: getCapabilities - x-module: core - x-stability-level: alpha - tags: [server] - summary: List implemented capability modules - description: The capability modules this server implements. Any valid grant may read it, whatever its scopes. - security: [{bearerAuth: []}] - responses: - '200': - description: Implemented modules. - content: - application/json: - schema: {$ref: ../components/schemas/Capabilities.yaml} - '401': {$ref: ../components/responses/Unauthorized.yaml} - '500': {$ref: ../components/responses/InternalError.yaml} diff --git a/api/openapi/paths/openapi.yaml b/api/openapi/paths/openapi.yaml index bc0b874f6..3c25dc8b7 100644 --- a/api/openapi/paths/openapi.yaml +++ b/api/openapi/paths/openapi.yaml @@ -4,7 +4,6 @@ json: x-module: core x-stability-level: alpha tags: [server] - security: [] summary: Get the OpenAPI document (JSON) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: @@ -26,7 +25,6 @@ yaml: x-module: core x-stability-level: alpha tags: [server] - security: [] summary: Get the OpenAPI document (YAML) description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. responses: diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml index 1e17a3b87..1f881dbb1 100644 --- a/api/openapi/paths/server.yaml +++ b/api/openapi/paths/server.yaml @@ -3,11 +3,11 @@ get: x-module: core x-stability-level: alpha tags: [server] - security: [] summary: Describe the server description: | Returns the public server description. No authentication required. - Capability modules are listed by `GET /capabilities`. + Authenticated requests will additionally receive the implemented capability modules + once authentication is available. responses: '200': description: Server description. diff --git a/cmd/inspect.go b/cmd/inspect.go index 5e88793cc..05f569f3e 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -1,13 +1,17 @@ package cmd import ( + "context" "encoding/json" "fmt" + "path/filepath" "strings" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -28,7 +32,7 @@ var inspectCmd = &cobra.Command{ Long: "Show file tags as seen by Navidrome", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - runInspector(args) + runInspector(cmd.Context(), args) }, } @@ -55,18 +59,24 @@ func prettyMarshal(v any) ([]byte, error) { return []byte(res.String()), nil } -func runInspector(args []string) { +func runInspector(ctx context.Context, args []string) { marshal := marshalers[format] if marshal == nil { log.Fatal("Invalid format", "format", format) } + libs := loadLibraries(ctx) + matcher := model.NewLibraryMatcher(libs) var out []core.InspectOutput for _, filePath := range args { if !model.IsAudioFile(filePath) { log.Warn("Not an audio file", "file", filePath) continue } - output, err := core.Inspect(filePath, 1, "") + lib, ok := libraryForFile(matcher, filePath) + if !ok && len(libs) > 0 { + log.Warn("File is not in any library, using the global PID config", "file", filePath) + } + output, err := core.Inspect(filePath, lib, "") if err != nil { log.Warn("Unable to process file", "file", filePath, "error", err) continue @@ -77,3 +87,33 @@ func runInspector(args []string) { data, _ := marshal(out) fmt.Println(string(data)) } + +// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB. +func loadLibraries(ctx context.Context) model.Libraries { + if dbFile, ok := existingDBFile(); !ok { + log.Warn(ctx, "No database found, using the global PID config", "path", dbFile) + return nil + } + defer db.Init(ctx)() + libs, err := persistence.New(db.Db()).Library().GetAll(ctx) + if err != nil { + log.Warn(ctx, "Could not load libraries, using the global PID config", err) + return nil + } + for i := range libs { + if absPath, err := filepath.Abs(libs[i].Path); err == nil { + libs[i].Path = absPath + } + } + return libs +} + +// libraryForFile falls back to the default library with no overrides, which uses the global PID config. +func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) { + if absPath, err := filepath.Abs(filePath); err == nil { + if lib, ok := matcher.FindLibrary(absPath); ok { + return lib, true + } + } + return model.Library{ID: model.DefaultLibraryID}, false +} diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go new file mode 100644 index 000000000..728dc8770 --- /dev/null +++ b/cmd/inspect_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("inspect", func() { + Describe("libraryForFile", func() { + var matcher *model.LibraryMatcher + var root string + + BeforeEach(func() { + root = GinkgoT().TempDir() + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + matcher = model.NewLibraryMatcher(model.Libraries{ + {ID: 1, Path: filepath.Join(root, "music")}, + {ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"}, + }) + }) + + It("returns the library that contains an absolute path", func() { + lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3")) + Expect(ok).To(BeTrue()) + Expect(lib.ID).To(Equal(1)) + }) + + It("resolves a relative path against the working directory", func() { + lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3")) + Expect(ok).To(BeTrue()) + Expect(lib.PIDAlbum).To(Equal("folder")) + }) + + It("falls back to the default library without overrides", func() { + lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3")) + Expect(ok).To(BeFalse()) + Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID})) + }) + }) + + Describe("loadLibraries", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does not create a database when there is none", func() { + dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db") + conf.Server.DbPath = dbFile + "?_journal_mode=WAL" + + Expect(loadLibraries(GinkgoT().Context())).To(BeNil()) + Expect(dbFile).ToNot(BeAnExistingFile()) + }) + }) +}) diff --git a/cmd/root.go b/cmd/root.go index 089f09472..e39c55365 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -190,16 +190,20 @@ func schedulePeriodicScan(ctx context.Context) func() error { } } -func pidHashChanged(ds model.DataStore) (bool, error) { - pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "") +// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from +// the one used by their last finished scan +func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) { + libs, err := ds.Library().GetAll(ctx) if err != nil { - return false, err + return nil, err } - pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "") - if err != nil { - return false, err + var names []string + for _, lib := range libs { + if lib.PIDChanged() { + names = append(names, lib.Name) + } } - return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil + return names, nil } // runInitialScan runs an initial scan of the music library if needed. @@ -214,12 +218,12 @@ func runInitialScan(ctx context.Context) func() error { if err != nil { return err } - pidHasChanged, err := pidHashChanged(ds) + pidChangedLibs, err := librariesWithChangedPID(ctx, ds) if err != nil { return err } scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup - scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged + scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0 time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan if scanNeeded { s := CreateScanner(ctx) @@ -227,9 +231,9 @@ func runInitialScan(ctx context.Context) func() error { case fullScanRequired == "1": log.Warn(ctx, "Full scan required after migration") _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) - case pidHasChanged: - log.Warn(ctx, "PID config changed, performing full scan") - fullScanRequired = "1" + case len(pidChangedLibs) > 0: + // Includes never-scanned libraries. The scanner rescans in full only the ones that need it + log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs) case inProgress: log.Warn(ctx, "Resuming interrupted scan") default: diff --git a/cmd/root_test.go b/cmd/root_test.go index af8d44e7e..423cd2a8f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "net/http" "net/http/httptest" "path" @@ -9,6 +10,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -44,3 +47,30 @@ var _ = Describe("profilerHandler", func() { Entry("with a trailing-slash BasePath", "/music/"), ) }) + +var _ = Describe("librariesWithChangedPID", func() { + var ds *tests.MockDataStore + var libs *tests.MockLibraryRepo + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + libs = &tests.MockLibraryRepo{} + ds = &tests.MockDataStore{MockedLibrary: libs} + }) + + It("returns only the libraries whose PID config changed", func() { + pid := model.Library{}.EffectivePID() + libs.SetData(model.Libraries{ + {ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 3, Name: "Never scanned"}, + }) + Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned")) + }) + + It("returns the error from the repository", func() { + libs.Err = errors.New("db down") + _, err := librariesWithChangedPID(GinkgoT().Context(), ds) + Expect(err).To(MatchError("db down")) + }) +}) diff --git a/cmd/utils.go b/cmd/utils.go index 72ec67f90..f35a31fb1 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -18,11 +18,16 @@ import ( "github.com/navidrome/navidrome/persistence" ) -// requireExistingDB aborts the command when the database file (DbPath minus DSN -// params) does not exist. -func requireExistingDB() { +// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists. +func existingDBFile() (string, bool) { path, _, _ := strings.Cut(conf.Server.DbPath, "?") - if _, err := os.Stat(path); os.IsNotExist(err) { + _, err := os.Stat(path) + return path, err == nil +} + +// requireExistingDB aborts the command when the database file does not exist. +func requireExistingDB() { + if path, ok := existingDBFile(); !ok { log.Fatal("No existing database", "path", path) } } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 19f92d9d5..2a396689d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -95,8 +95,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) + archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) @@ -110,7 +111,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router @@ -159,9 +159,10 @@ func CreatePublicRouter() *public.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork) - router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) + archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) + router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver) return router } diff --git a/consts/consts.go b/consts/consts.go index 8faffdbd7..42e9ec42f 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -34,7 +34,6 @@ const ( JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour - APIv1GrantIdleExpiry = 90 * 24 * time.Hour DefaultSmartRefresh = 5 * time.Second DefaultShareExpiration = 8760 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year @@ -157,8 +156,6 @@ const ( //DefaultAlbumPID = "album_legacy" DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" - PIDAlbumKey = "PIDAlbum" - PIDTrackKey = "PIDTrack" ) const ( diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go index 400c54fc7..1eb414b15 100644 --- a/core/agents/session_keys.go +++ b/core/agents/session_keys.go @@ -3,7 +3,6 @@ package agents import ( "context" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -14,7 +13,6 @@ type SessionKeys struct { } func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { - ctx = log.WithSecrets(ctx, sessionKey) return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey) } diff --git a/core/agents/session_keys_test.go b/core/agents/session_keys_test.go index 66eaf3a57..e0232c08e 100644 --- a/core/agents/session_keys_test.go +++ b/core/agents/session_keys_test.go @@ -1,31 +1,21 @@ package agents import ( - "bytes" "context" - "database/sql" - "os" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/tests" - "github.com/pocketbase/dbx" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("SessionKeys", func() { - var ctx context.Context + ctx := context.Background() user := model.User{ID: "u-1"} ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}} sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"} - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - It("uses the assigned key name", func() { Expect(sk.KeyName).To(Equal("fakeSessionKey")) }) @@ -44,34 +34,4 @@ var _ = Describe("SessionKeys", func() { _, err := sk.Get(ctx, "u-2") Expect(err).To(MatchError(model.ErrNotFound)) }) - - It("never logs the session key, but still logs the user id and key name", func() { - conn, err := sql.Open("sqlite3", ":memory:") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(conn.Close) - conn.SetMaxOpenConns(1) - _, err = conn.ExecContext(ctx, "create table user_props (user_id varchar, key varchar, value varchar)") - Expect(err).ToNot(HaveOccurred()) - props := persistence.NewUserPropsRepository(dbx.NewFromDB(conn, "sqlite3")) - dbKeys := SessionKeys{DataStore: &tests.MockDataStore{MockedUserProps: props}, KeyName: "LastFMSessionKey"} - - logs := &bytes.Buffer{} - log.SetOutput(logs) - log.SetLevel(log.LevelTrace) - DeferCleanup(func() { - log.SetOutput(os.Stderr) - log.SetLevel(log.LevelFatal) - }) - - Expect(dbKeys.Put(ctx, "logged-user-id", "inserted-session-key")).To(Succeed()) - Expect(dbKeys.Put(ctx, "logged-user-id", "updated-session-key")).To(Succeed()) - - Expect(dbKeys.Get(ctx, "logged-user-id")).To(Equal("updated-session-key")) - Expect(logs.String()).To(ContainSubstring("INSERT INTO user_props")) - Expect(logs.String()).To(ContainSubstring("UPDATE user_props")) - Expect(logs.String()).To(ContainSubstring("logged-user-id")) - Expect(logs.String()).To(ContainSubstring("LastFMSessionKey")) - Expect(logs.String()).ToNot(ContainSubstring("inserted-session-key")) - Expect(logs.String()).ToNot(ContainSubstring("updated-session-key")) - }) }) diff --git a/core/apiauth/apiauth_suite_test.go b/core/apiauth/apiauth_suite_test.go deleted file mode 100644 index 5d6143b0e..000000000 --- a/core/apiauth/apiauth_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package apiauth - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestAPIAuth(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "API Auth Suite") -} diff --git a/core/apiauth/context.go b/core/apiauth/context.go deleted file mode 100644 index 082d92d45..000000000 --- a/core/apiauth/context.go +++ /dev/null @@ -1,14 +0,0 @@ -package apiauth - -import "context" - -type principalKey struct{} - -func WithPrincipal(ctx context.Context, p *Principal) context.Context { - return context.WithValue(ctx, principalKey{}, p) -} - -func PrincipalFrom(ctx context.Context) (*Principal, bool) { - p, ok := ctx.Value(principalKey{}).(*Principal) - return p, ok -} diff --git a/core/apiauth/credentials.go b/core/apiauth/credentials.go deleted file mode 100644 index 43834ab51..000000000 --- a/core/apiauth/credentials.go +++ /dev/null @@ -1,68 +0,0 @@ -package apiauth - -import ( - "context" - "crypto/subtle" - "errors" - - "github.com/navidrome/navidrome/model" -) - -type Outcome int - -const ( - NotMine Outcome = iota - Authenticated - Rejected - Unavailable -) - -type CredentialResult struct { - Outcome Outcome - User *model.User - Provider string - PasswordLocal bool -} - -type CredentialChecker interface { - Check(ctx context.Context, username, password string) (CredentialResult, error) -} - -// checkCredentials asks each checker in turn; only NotMine moves on, so an owning provider's "no" is final. -func checkCredentials(ctx context.Context, checkers []CredentialChecker, username, password string) (CredentialResult, error) { - for _, c := range checkers { - res, err := c.Check(ctx, username, password) - if err != nil { - return CredentialResult{}, err - } - switch res.Outcome { - case NotMine: - continue - case Authenticated: - return res, nil - case Unavailable: - return CredentialResult{}, model.ErrNotAvailable - default: - return CredentialResult{}, model.ErrInvalidAuth - } - } - return CredentialResult{}, model.ErrInvalidAuth -} - -type dbChecker struct { - ds model.DataStore -} - -func (c dbChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) { - u, err := c.ds.User().FindByUsernameWithPassword(ctx, username) - if errors.Is(err, model.ErrNotFound) { - return CredentialResult{Outcome: NotMine}, nil - } - if err != nil { - return CredentialResult{}, err - } - if subtle.ConstantTimeCompare([]byte(u.Password), []byte(password)) != 1 { - return CredentialResult{Outcome: Rejected}, nil - } - return CredentialResult{Outcome: Authenticated, User: u, Provider: "password", PasswordLocal: true}, nil -} diff --git a/core/apiauth/credentials_test.go b/core/apiauth/credentials_test.go deleted file mode 100644 index a57d54c29..000000000 --- a/core/apiauth/credentials_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package apiauth - -import ( - "context" - "errors" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type fakeChecker struct { - res CredentialResult - err error - hit bool -} - -func (f *fakeChecker) Check(context.Context, string, string) (CredentialResult, error) { - f.hit = true - return f.res, f.err -} - -var _ = Describe("credential chain", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - It("authenticates against the database with the stored password", func() { - u := createUser(ctx, "pw", false) - res, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "pw") - Expect(err).ToNot(HaveOccurred()) - Expect(res.Outcome).To(Equal(Authenticated)) - Expect(res.User.ID).To(Equal(u.ID)) - Expect(res.Provider).To(Equal("password")) - Expect(res.PasswordLocal).To(BeTrue()) - }) - - It("rejects a wrong password and an unknown user the same way", func() { - u := createUser(ctx, "pw", false) - _, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "nope") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - _, err = checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, "ghost", "pw") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("moves on only from NotMine, and an owner's rejection stops the chain", func() { - owner := &fakeChecker{res: CredentialResult{Outcome: Rejected}} - later := &fakeChecker{res: CredentialResult{Outcome: Authenticated, User: &model.User{ID: "x"}}} - _, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: NotMine}}, owner, later}, "a", "b") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - Expect(later.hit).To(BeFalse()) - }) - - It("maps Unavailable to ErrNotAvailable and passes through checker errors", func() { - _, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: Unavailable}}}, "a", "b") - Expect(err).To(MatchError(model.ErrNotAvailable)) - boom := errors.New("boom") - _, err = checkCredentials(ctx, []CredentialChecker{&fakeChecker{err: boom}}, "a", "b") - Expect(err).To(MatchError(boom)) - }) -}) diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go deleted file mode 100644 index 5f54e6007..000000000 --- a/core/apiauth/db_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package apiauth - -import ( - "context" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/persistence" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var realDS model.DataStore - -// One database for the whole suite: db.Db() is a process-wide singleton. -var _ = BeforeSuite(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiauth.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000" - DeferCleanup(db.Init(GinkgoT().Context())) - realDS = persistence.New(db.Db()) -}) - -func createUser(ctx context.Context, password string, admin bool) model.User { - name := "user-" + id.NewRandom() - u := model.User{UserName: name, Name: name, NewPassword: password, IsAdmin: admin} - ExpectWithOffset(1, realDS.User().Put(ctx, &u)).To(Succeed()) - stored, err := realDS.User().FindByUsername(ctx, name) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - return *stored -} - -// login signs u in (nil scopes asks for all) and authenticates with the new grant secret. -func login(ctx context.Context, svc *Service, u model.User, password string, scopes []string) (*Issued, *Principal) { - issued, err := svc.Login(ctx, u.UserName, password, meta, scopes) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - p, err := svc.Authenticate(ctx, issued.Secret, "") - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - return issued, p -} diff --git a/core/apiauth/export_test.go b/core/apiauth/export_test.go deleted file mode 100644 index fdfde677c..000000000 --- a/core/apiauth/export_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package apiauth - -import ( - "time" - - "github.com/navidrome/navidrome/model" -) - -func (s *Service) SetClock(now func() time.Time) { s.now = now } - -func (s *Service) SetCheckers(f func(model.DataStore) []CredentialChecker) { s.checkers = f } diff --git a/core/apiauth/scopes.go b/core/apiauth/scopes.go deleted file mode 100644 index 319269a00..000000000 --- a/core/apiauth/scopes.go +++ /dev/null @@ -1,63 +0,0 @@ -package apiauth - -import ( - "slices" - "strings" -) - -const ( - ScopeAll = "all" - ScopeRead = "read" - ScopePassword = "password" - ScopeAdmin = "admin" -) - -// KnownScopes lists the scopes of modules this server implements; `all` expands to these. -var KnownScopes = []string{ScopeRead, ScopePassword} - -func known(s string) bool { - return slices.Contains(KnownScopes, s) -} - -func grantable(s string, isAdmin bool) bool { - return known(s) && (s != ScopeAdmin || isAdmin) -} - -func normalize(in []string) []string { - out := slices.Clone(in) - slices.Sort(out) - return slices.Compact(out) -} - -// Entitled returns the scopes a new grant stores. -func Entitled(requested []string, isAdmin bool) []string { - if requested == nil { - return []string{ScopeAll} - } - var out []string - for _, s := range requested { - if s == ScopeAll || grantable(s, isAdmin) { - out = append(out, s) - } - } - return normalize(out) -} - -// Expand turns a grant's stored scopes into the concrete scopes it carries right now. -func Expand(granted []string, isAdmin bool) []string { - var out []string - for _, s := range granted { - if s == ScopeAll { - out = append(out, KnownScopes...) - continue - } - out = append(out, s) - } - out = slices.DeleteFunc(out, func(s string) bool { return !grantable(s, isAdmin) }) - return normalize(out) -} - -func Satisfies(scopes []string, required string) bool { - return slices.Contains(scopes, required) || - (!strings.HasSuffix(required, ":write") && slices.Contains(scopes, required+":write")) -} diff --git a/core/apiauth/scopes_test.go b/core/apiauth/scopes_test.go deleted file mode 100644 index 351f935e2..000000000 --- a/core/apiauth/scopes_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package apiauth - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("scopes", func() { - BeforeEach(func() { - saved := KnownScopes - KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin, "playlists", "playlists:write"} - DeferCleanup(func() { KnownScopes = saved }) - }) - - Describe("Entitled", func() { - It("stores all when nothing is requested", func() { - Expect(Entitled(nil, false)).To(Equal([]string{ScopeAll})) - }) - It("drops unknown scopes and admin for non-admins", func() { - Expect(Entitled([]string{"read", "future", "admin"}, false)).To(Equal([]string{"read"})) - }) - It("keeps admin for admins and keeps all", func() { - Expect(Entitled([]string{"admin", "all"}, true)).To(Equal([]string{"admin", "all"})) - }) - }) - - Describe("Expand", func() { - It("replaces all with every known scope except admin for non-admins", func() { - Expect(Expand([]string{ScopeAll}, false)).To(Equal([]string{"password", "playlists", "playlists:write", "read"})) - }) - It("includes admin for admins", func() { - Expect(Expand([]string{ScopeAll}, true)).To(ContainElement("admin")) - }) - It("drops admin from explicit scopes when the user is no longer an admin", func() { - Expect(Expand([]string{"admin", "read"}, false)).To(Equal([]string{"read"})) - }) - It("drops scopes that are no longer known", func() { - Expect(Expand([]string{"read", "retired"}, false)).To(Equal([]string{"read"})) - }) - }) - - Describe("Satisfies", func() { - It("accepts the exact scope or its :write form", func() { - Expect(Satisfies([]string{"read"}, "read")).To(BeTrue()) - Expect(Satisfies([]string{"playlists:write"}, "playlists")).To(BeTrue()) - Expect(Satisfies([]string{"playlists"}, "playlists:write")).To(BeFalse()) - Expect(Satisfies(nil, "read")).To(BeFalse()) - }) - }) -}) diff --git a/core/apiauth/secret.go b/core/apiauth/secret.go deleted file mode 100644 index beaed16c5..000000000 --- a/core/apiauth/secret.go +++ /dev/null @@ -1,20 +0,0 @@ -package apiauth - -import ( - "crypto/sha256" - "encoding/hex" - - "github.com/navidrome/navidrome/model/id" -) - -const secretPrefix = "ndg_" - -func newSecret() (secret, hash string) { - secret = secretPrefix + id.NewRandom() - return secret, hashSecret(secret) -} - -func hashSecret(secret string) string { - sum := sha256.Sum256([]byte(secret)) - return hex.EncodeToString(sum[:]) -} diff --git a/core/apiauth/secret_test.go b/core/apiauth/secret_test.go deleted file mode 100644 index 3f9bae3da..000000000 --- a/core/apiauth/secret_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package apiauth - -import ( - "regexp" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("grant secrets", func() { - It("are ndg_ plus 22 base62 characters, hashed as hex SHA-256", func() { - secret, hash := newSecret() - Expect(secret).To(MatchRegexp(`^ndg_[0-9A-Za-z]{22}$`)) - Expect(hash).To(MatchRegexp(`^[0-9a-f]{64}$`)) - Expect(hashSecret(secret)).To(Equal(hash)) - }) - It("are unique", func() { - a, _ := newSecret() - b, _ := newSecret() - Expect(a).ToNot(Equal(b)) - Expect(regexp.MustCompile(`^ndg_`).MatchString(a)).To(BeTrue()) - }) -}) diff --git a/core/apiauth/service.go b/core/apiauth/service.go deleted file mode 100644 index 3ec70476f..000000000 --- a/core/apiauth/service.go +++ /dev/null @@ -1,264 +0,0 @@ -package apiauth - -import ( - "cmp" - "context" - "errors" - "fmt" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" -) - -const ( - IdleExpiry = consts.APIv1GrantIdleExpiry - touchInterval = 5 * time.Minute -) - -var ( - ErrPasswordManagedExternally = errors.New("password is managed externally") - ErrCurrentPasswordMismatch = errors.New("current password does not match") -) - -type ClientMeta struct { - Name string - Client string - ClientVersion string -} - -type Issued struct { - Secret string - Grant model.Grant - User model.User -} - -type Principal struct { - User model.User - GrantID string - Scopes []string -} - -type Service struct { - ds model.DataStore - checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction - now func() time.Time -} - -func New(ds model.DataStore) *Service { - return &Service{ - ds: ds, - checkers: func(ds model.DataStore) []CredentialChecker { - return []CredentialChecker{dbChecker{ds: ds}} - }, - now: time.Now, - } -} - -func PasswordChangeable(u model.User) bool { - return u.IsAdmin || conf.Server.EnableUserEditing -} - -func (s *Service) Login(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) { - res, err := checkCredentials(ctx, s.checkers(s.ds), username, password) - if err != nil { - return nil, err - } - issued, err := s.issue(ctx, s.ds, *res.User, res.Provider, meta, scopes) - if err != nil { - return nil, err - } - if err := s.ds.User().UpdateLastLoginAt(ctx, res.User.ID); err != nil { - log.Warn(ctx, "API v1: could not update last login", "user", res.User.UserName, err) - } - return issued, nil -} - -func (s *Service) Setup(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) { - var issued *Issued - _, err := auth.CreateFirstAdmin(ctx, s.ds, username, password, func(tx model.DataStore, u *model.User) error { - var err error - issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes) - return err - }) - if err != nil { - return nil, err - } - return issued, nil -} - -// issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead. -func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, provider string, meta ClientMeta, scopes []string) (*Issued, error) { - u.Password = "" - secret, hash := newSecret() - g := model.Grant{ - UserID: u.ID, - Name: cmp.Or(meta.Name, meta.Client), - Client: meta.Client, - ClientVersion: meta.ClientVersion, - Scopes: Entitled(scopes, u.IsAdmin), - Provider: provider, - SecretHash: hash, - UserEpoch: u.TokenEpoch, - CreatedAt: s.now(), - } - if err := ds.Grant().Put(ctx, &g); err != nil { - return nil, fmt.Errorf("storing grant: %w", err) - } - return &Issued{Secret: secret, Grant: g, User: u}, nil -} - -func (s *Service) Authenticate(ctx context.Context, secret, ip string) (*Principal, error) { - g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret)) - if errors.Is(err, model.ErrNotFound) { - return nil, model.ErrInvalidAuth - } - if err != nil { - return nil, err - } - if idleSince := s.now().Add(-IdleExpiry); g.LastActivity().Before(idleSince) { - s.dropIdle(ctx, g.ID, idleSince) - return nil, model.ErrInvalidAuth - } - u, err := s.ds.User().Get(ctx, g.UserID) - if errors.Is(err, model.ErrNotFound) { - return nil, model.ErrInvalidAuth - } - if err != nil { - return nil, err - } - if g.UserEpoch != u.TokenEpoch { - if g, u, err = s.settleEpoch(ctx, g.ID); err != nil { - return nil, err - } - } - s.touch(ctx, g, ip) - return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil -} - -// dropIdle deletes only still-idle grants, sparing one renewed meanwhile. -func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) { - if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil { - log.Warn(ctx, "API v1: could not delete idle grants", "grant", id, err) - } -} - -// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password change -// and make a kept grant look dead. Deleting below the snapshot's epoch is safe: a later change only moves kept grants up. -func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant, *model.User, error) { - var g *model.Grant - var u *model.User - err := s.ds.WithTx(func(tx model.DataStore) error { - var err error - if g, err = tx.Grant().Get(ctx, grantID); err != nil { - return err - } - u, err = tx.User().Get(ctx, g.UserID) - return err - }) - if errors.Is(err, model.ErrNotFound) { - return nil, nil, model.ErrInvalidAuth - } - if err != nil { - return nil, nil, err - } - if g.UserEpoch != u.TokenEpoch { - if err := s.ds.Grant().DeleteStaleEpochs(ctx, u.ID, u.TokenEpoch); err != nil { - log.Warn(ctx, "API v1: could not delete the user's grants from older epochs", "user", u.ID, "grant", grantID, err) - } - return nil, nil, model.ErrInvalidAuth - } - return g, u, nil -} - -// touch writes last_used at most every touchInterval (zero lastUsed: never used); the SQL condition holds that across nodes. -func (s *Service) touch(ctx context.Context, g *model.Grant, ip string) { - now := s.now() - if lastUsed := gg.V(g.LastUsedAt); !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) { - return - } - if err := s.ds.Grant().Touch(ctx, g.ID, ip, now, now.Add(-touchInterval)); err != nil { - log.Warn(ctx, "API v1: could not record grant use", "grant", g.ID, err) - } -} - -// ListGrants shows only the current epoch: grants left on an older one are dead but only deleted when presented. -func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit int) (model.Grants, int64, error) { - idleSince := s.now().Add(-IdleExpiry) - grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince, offset, limit) - if err != nil { - return nil, 0, err - } - total, err := s.ds.Grant().CountForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince) - return grants, total, err -} - -func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) error { - return s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID) -} - -// Logout succeeds when the grant is already gone, e.g. revoked by another node or a concurrent logout. -func (s *Service) Logout(ctx context.Context, p *Principal) error { - err := s.RevokeGrant(ctx, p, p.GrantID) - if errors.Is(err, model.ErrNotFound) { - return nil - } - return err -} - -// ChangePassword does every check inside the locked transaction, so a reset that lands first is never overwritten. -func (s *Service) ChangePassword(ctx context.Context, p *Principal, current, newPassword string, revokeOthers bool) error { - return s.ds.WithTxImmediate(func(tx model.DataStore) error { - u, err := tx.User().Get(ctx, p.User.ID) - if errors.Is(err, model.ErrNotFound) { - return model.ErrInvalidAuth - } - if err != nil { - return err - } - g, err := tx.Grant().Get(ctx, p.GrantID) - if errors.Is(err, model.ErrNotFound) { - return model.ErrInvalidAuth - } - if err != nil { - return err - } - if g.UserID != u.ID || g.UserEpoch != u.TokenEpoch { - return model.ErrInvalidAuth - } - if !PasswordChangeable(*u) { - return model.ErrNotAuthorized - } - res, err := checkCredentials(ctx, s.checkers(tx), u.UserName, current) - if errors.Is(err, model.ErrInvalidAuth) { - return ErrCurrentPasswordMismatch - } - if err != nil { - return err - } - if !res.PasswordLocal { - return ErrPasswordManagedExternally - } - oldEpoch := u.TokenEpoch - u.NewPassword = newPassword - if err := tx.User().Put(ctx, u); err != nil { - return err - } - updated, err := tx.User().Get(ctx, u.ID) - if err != nil { - return err - } - keep := "" - if revokeOthers { - keep = p.GrantID - } - if err := tx.Grant().SetEpoch(ctx, u.ID, oldEpoch, updated.TokenEpoch, keep); err != nil { - return err - } - return tx.Grant().DeleteStaleEpochs(ctx, u.ID, updated.TokenEpoch) - }) -} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go deleted file mode 100644 index 6482122c7..000000000 --- a/core/apiauth/service_test.go +++ /dev/null @@ -1,463 +0,0 @@ -package apiauth - -import ( - "context" - "errors" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"} - -var _ = Describe("Service", func() { - var ctx context.Context - var svc *Service - var now time.Time - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - now = time.Now().UTC().Truncate(time.Second) - svc = New(realDS) - svc.SetClock(func() time.Time { return now }) - }) - - Describe("Login", func() { - It("creates a grant storing all, the user's epoch and the client metadata", func() { - u := createUser(ctx, "pw", false) - issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - Expect(issued.Secret).To(HavePrefix("ndg_")) - Expect(issued.User.ID).To(Equal(u.ID)) - Expect(issued.User.Password).To(BeEmpty()) - Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeAll})) - Expect(issued.Grant.Provider).To(Equal("password")) - Expect(issued.Grant.Name).To(Equal("Living room")) - Expect(issued.Grant.UserEpoch).To(Equal(u.TokenEpoch)) - - stored, err := realDS.Grant().FindBySecretHash(ctx, hashSecret(issued.Secret)) - Expect(err).ToNot(HaveOccurred()) - Expect(stored.ID).To(Equal(issued.Grant.ID)) - }) - - It("defaults the grant name to the client", func() { - u := createUser(ctx, "pw", false) - issued, err := svc.Login(ctx, u.UserName, "pw", ClientMeta{Client: "OnlyClient"}, nil) - Expect(err).ToNot(HaveOccurred()) - Expect(issued.Grant.Name).To(Equal("OnlyClient")) - }) - - It("accepts the username in any case", func() { - u := createUser(ctx, "pw", false) - issued, err := svc.Login(ctx, strings.ToUpper(u.UserName), "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - Expect(issued.User.ID).To(Equal(u.ID)) - }) - - It("stores only known requested scopes", func() { - u := createUser(ctx, "pw", false) - issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{"read", "future", "admin"}) - Expect(err).ToNot(HaveOccurred()) - Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeRead})) - }) - - It("fails with ErrInvalidAuth for bad credentials", func() { - u := createUser(ctx, "pw", false) - _, err := svc.Login(ctx, u.UserName, "wrong", meta, nil) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - }) - - Describe("Setup", func() { - It("refuses when users exist", func() { - createUser(ctx, "pw", false) - _, err := svc.Setup(ctx, "newadmin", "pw", meta, nil) - Expect(err).To(MatchError(auth.ErrSetupComplete)) - }) - // The empty-database path is covered end to end in server/apiv1, which owns a fresh DB. - }) - - Describe("Authenticate", func() { - It("resolves the secret to its user and the grant's expanded scopes", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u, "pw", nil) - Expect(p.User.ID).To(Equal(u.ID)) - Expect(p.GrantID).To(Equal(issued.Grant.ID)) - Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) - }) - - It("carries only the scopes stored on a narrow grant", func() { - u := createUser(ctx, "pw", false) - _, p := login(ctx, svc, u, "pw", []string{ScopePassword}) - Expect(p.Scopes).To(Equal([]string{ScopePassword})) - }) - - It("records the first use with the client IP", func() { - u := createUser(ctx, "pw", false) - issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - _, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9") - Expect(err).ToNot(HaveOccurred()) - g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(g.LastUsedAt).ToNot(BeNil()) - Expect(g.LastUsedIP).To(Equal("10.0.0.9")) - }) - - It("records use again only after the touch interval", func() { - u := createUser(ctx, "pw", false) - issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - _, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.1") - Expect(err).ToNot(HaveOccurred()) - - now = now.Add(touchInterval - time.Second) - _, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.2") - Expect(err).ToNot(HaveOccurred()) - g, _ := realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(g.LastUsedIP).To(Equal("10.0.0.1")) - - now = now.Add(2 * time.Second) - _, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.3") - Expect(err).ToNot(HaveOccurred()) - g, _ = realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(g.LastUsedIP).To(Equal("10.0.0.3")) - Expect(g.LastUsedAt.Equal(now)).To(BeTrue()) - }) - - It("rejects unknown secrets", func() { - _, err := svc.Authenticate(ctx, "ndg_unknown", "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("deletes and rejects a grant idle for 90 days, including one never used", func() { - u := createUser(ctx, "pw", false) - issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - now = now.Add(IdleExpiry + time.Second) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - _, err = realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("keeps an idle grant that a concurrent request renewed before the delete ran", func() { - u := createUser(ctx, "pw", false) - issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - renewedAt := now.Add(IdleExpiry - time.Minute) - racing := New(hookDS{DataStore: realDS, beforeDeleteIdle: func() { - Expect(realDS.Grant().Touch(ctx, issued.Grant.ID, "10.0.0.2", renewedAt, renewedAt)).To(Succeed()) - }}) - now = now.Add(IdleExpiry + time.Second) - racing.SetClock(func() time.Time { return now }) - - _, err := racing.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - g, err := realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(g.LastUsedIP).To(Equal("10.0.0.2")) - }) - - It("rejects and deletes a grant whose epoch is behind the user's", func() { - u := createUser(ctx, "pw", false) - issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil) - u.NewPassword = "changed-elsewhere" - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - _, err = realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not delete a kept grant when the password changed between reading the grant and the user", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u, "pw", nil) - racing := New(hookDS{DataStore: realDS, afterFind: func() { - Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - }}) - racing.SetClock(func() time.Time { return now }) - - _, err := racing.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - _, err = realDS.Grant().Get(ctx, p.GrantID) - Expect(err).ToNot(HaveOccurred()) - }) - - It("drops admin from a grant once its user is no longer an admin", func() { - saved := KnownScopes - KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin} - DeferCleanup(func() { KnownScopes = saved }) - u := createUser(ctx, "pw", true) - issued, p := login(ctx, svc, u, "pw", nil) - Expect(p.Scopes).To(ContainElement(ScopeAdmin)) - - u.IsAdmin = false - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - demoted, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead})) - }) - - It("rejects the secret after its user is deleted, and the grant row is gone", func() { - u := createUser(ctx, "pw", false) - issued, _ := login(ctx, svc, u, "pw", nil) - Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed()) - _, err := realDS.Grant().Get(ctx, issued.Grant.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("leaves a login that raced a password change with a dead grant", func() { - u := createUser(ctx, "pw", false) - reached, release := make(chan struct{}), make(chan struct{}) - svc.SetCheckers(func(ds model.DataStore) []CredentialChecker { - return []CredentialChecker{pausingChecker{inner: dbChecker{ds: ds}, reached: reached, release: release}} - }) - var issued *Issued - var loginErr error - done := make(chan struct{}) - go func() { - defer GinkgoRecover() - defer close(done) - issued, loginErr = svc.Login(ctx, u.UserName, "pw", meta, nil) - }() - <-reached // credentials (and the old epoch) were read - u.NewPassword = "changed-meanwhile" - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - close(release) - <-done - - Expect(loginErr).ToNot(HaveOccurred()) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - }) - - Describe("grant management", func() { - It("lists the user's grants and marks the current one", func() { - u := createUser(ctx, "pw", false) - first, _ := login(ctx, svc, u, "pw", nil) - _, p := login(ctx, svc, u, "pw", nil) - grants, total, err := svc.ListGrants(ctx, p, 0, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(Equal(int64(2))) - Expect(grants).To(HaveLen(2)) - Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID)) - }) - - It("lists only grants on the user's current epoch", func() { - u := createUser(ctx, "pw", false) - login(ctx, svc, u, "pw", nil) - u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - issued, p := login(ctx, svc, u, "reset-by-admin", nil) - - grants, total, err := svc.ListGrants(ctx, p, 0, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(Equal(int64(1))) - Expect(grants).To(HaveLen(1)) - Expect(grants[0].ID).To(Equal(issued.Grant.ID)) - }) - - It("logs out, and succeeds again when the grant is already gone", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u, "pw", nil) - Expect(svc.Logout(ctx, p)).To(Succeed()) - _, err := svc.Authenticate(ctx, issued.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - - Expect(svc.Logout(ctx, p)).To(Succeed()) - }) - - It("refuses to revoke another user's grant", func() { - alice := createUser(ctx, "pw", false) - bob := createUser(ctx, "pw", false) - aliceGrant, _ := login(ctx, svc, alice, "pw", nil) - _, bobP := login(ctx, svc, bob, "pw", nil) - Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound)) - _, err := svc.Authenticate(ctx, aliceGrant.Secret, "") - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects the secret after its grant is revoked", func() { - u := createUser(ctx, "pw", false) - other, _ := login(ctx, svc, u, "pw", nil) - _, p := login(ctx, svc, u, "pw", nil) - Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed()) - _, err := svc.Authenticate(ctx, other.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - }) - - Describe("ChangePassword", func() { - It("revokes other grants by default and keeps the caller's", func() { - u := createUser(ctx, "pw", false) - other, _ := login(ctx, svc, u, "pw", nil) - mine, p := login(ctx, svc, u, "pw", nil) - Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed()) - - _, err := svc.Authenticate(ctx, mine.Secret, "") - Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, other.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - - _, err = svc.Login(ctx, u.UserName, "pw2", meta, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("keeps every grant, the caller's included, when revokeOthers is false", func() { - u := createUser(ctx, "pw", false) - other, _ := login(ctx, svc, u, "pw", nil) - mine, p := login(ctx, svc, u, "pw", nil) - Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed()) - _, err := svc.Authenticate(ctx, other.Secret, "") - Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, mine.Secret, "") - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects a wrong current password without changing anything", func() { - u := createUser(ctx, "pw", false) - _, p := login(ctx, svc, u, "pw", nil) - err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true) - Expect(err).To(MatchError(ErrCurrentPasswordMismatch)) - _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("is forbidden for non-admins when user editing is off", func() { - conf.Server.EnableUserEditing = false - u := createUser(ctx, "pw", false) - _, p := login(ctx, svc, u, "pw", nil) - err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - - It("does not revive grants killed by an earlier reset when keeping grants", func() { - u := createUser(ctx, "pw", false) - killed, _ := login(ctx, svc, u, "pw", nil) - u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented - Expect(realDS.User().Put(ctx, &u)).To(Succeed()) - - _, p2 := login(ctx, svc, u, "reset-by-admin", nil) - Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed()) - - _, err := svc.Authenticate(ctx, killed.Secret, "") - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rejects a caller whose grant was revoked before the change ran", func() { - u := createUser(ctx, "pw", false) - _, p := login(ctx, svc, u, "pw", nil) - Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed()) - err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects a caller naming another user's grant", func() { - alice := createUser(ctx, "pw", false) - bob := createUser(ctx, "pw", false) - _, aliceP := login(ctx, svc, alice, "pw", nil) - bobGrant, _ := login(ctx, svc, bob, "pw", nil) - forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID} - err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true) - Expect(err).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rolls back the password and epoch when a grant update fails", func() { - u := createUser(ctx, "pw", false) - issued, p := login(ctx, svc, u, "pw", nil) - failing := New(failingEpochDS{realDS}) - failing.SetClock(func() time.Time { return now }) - - err := failing.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true) - Expect(err).To(MatchError(ContainSubstring("boom"))) - - reloaded, _ := realDS.User().Get(ctx, u.ID) - Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch)) - _, err = svc.Login(ctx, u.UserName, "pw", meta, nil) - Expect(err).ToNot(HaveOccurred()) - _, err = svc.Authenticate(ctx, issued.Secret, "") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("PasswordChangeable", func() { - It("follows EnableUserEditing for non-admins only", func() { - conf.Server.EnableUserEditing = false - Expect(PasswordChangeable(model.User{IsAdmin: true})).To(BeTrue()) - Expect(PasswordChangeable(model.User{})).To(BeFalse()) - conf.Server.EnableUserEditing = true - Expect(PasswordChangeable(model.User{})).To(BeTrue()) - }) - }) -}) - -// hookDS runs its optional callbacks inside grant lookups, to land a concurrent change mid-Authenticate. -type hookDS struct { - model.DataStore - afterFind func() - beforeDeleteIdle func() -} - -func (d hookDS) Grant() model.GrantRepository { - return hookGrants{GrantRepository: d.DataStore.Grant(), hooks: d} -} - -type hookGrants struct { - model.GrantRepository - hooks hookDS -} - -func (g hookGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) { - found, err := g.GrantRepository.FindBySecretHash(ctx, hash) - if g.hooks.afterFind != nil { - g.hooks.afterFind() - } - return found, err -} - -func (g hookGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) { - if g.hooks.beforeDeleteIdle != nil { - g.hooks.beforeDeleteIdle() - } - return g.GrantRepository.DeleteIdle(ctx, idleSince) -} - -type pausingChecker struct { - inner CredentialChecker - reached, release chan struct{} -} - -func (c pausingChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) { - res, err := c.inner.Check(ctx, username, password) - close(c.reached) - <-c.release - return res, err -} - -// failingEpochDS makes SetEpoch fail inside WithTxImmediate, to prove the whole change rolls back. -type failingEpochDS struct{ model.DataStore } - -func (f failingEpochDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { - return f.DataStore.WithTxImmediate(func(tx model.DataStore) error { - return block(failingEpochTx{tx}) - }, scope...) -} - -type failingEpochTx struct{ model.DataStore } - -func (f failingEpochTx) Grant() model.GrantRepository { return failingGrants{f.DataStore.Grant()} } - -type failingGrants struct{ model.GrantRepository } - -func (failingGrants) SetEpoch(context.Context, string, int, int, string) error { - return errors.New("boom") -} diff --git a/core/archiver.go b/core/archiver.go index 6f362322a..60eb44858 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -35,13 +35,14 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { - return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork} +func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { + return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork} } type archiver struct { ds model.DataStore ms stream.MediaStreamer + decider stream.TranscodeDecider shares Share artwork artwork.Artwork } @@ -78,8 +79,9 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { - file := a.albumFilename(mf, format, isMultiDisc, folder) - if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + req := a.resolveRequest(ctx, &mf, format, bitrate) + file := a.albumFilename(mf, req.Format, isMultiDisc, folder) + if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { // Stop iterating: continuing would just rack up more // rejections from the limiter. Close finalises whatever // tracks were already written; the rejected one is not @@ -204,8 +206,9 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { - file := a.playlistFilename(mf, format, idx) - if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + req := a.resolveRequest(ctx, &mf, format, bitrate) + file := a.playlistFilename(mf, req.Format, idx) + if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { // Abort the whole archive: continuing would silently emit // empty zip entries since the headers are already written. _ = z.Close() @@ -251,7 +254,14 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } -func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { +func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request { + if format == "" || format == "raw" { + return stream.Request{Format: "raw"} + } + return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0) +} + +func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error { path := mf.AbsolutePath() // Open the source before writing the zip entry header so a rejection @@ -259,13 +269,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med // archive. var r io.ReadCloser var err error - if format != "raw" && format != "" { - r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + if req.Format != "raw" { + r, err = a.ms.NewStream(ctx, &mf, req) } else { r, err = os.Open(path) } if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err) return err } defer func() { diff --git a/core/archiver_test.go b/core/archiver_test.go index 4e00ce78c..178d1b6b9 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -26,6 +26,7 @@ var _ = Describe("Archiver", func() { var ( arch core.Archiver ms *mockMediaStreamer + dc *fakeDecider ds *mockDataStore sh *mockShare ca *mockCoverArt @@ -33,10 +34,11 @@ var _ = Describe("Archiver", func() { BeforeEach(func() { ms = &mockMediaStreamer{} + dc = &fakeDecider{} sh = &mockShare{} ds = &mockDataStore{} ca = &mockCoverArt{images: map[string][]byte{}} - arch = core.NewArchiver(ms, ds, sh, ca) + arch = core.NewArchiver(ms, dc, ds, sh, ca) }) Context("ZipAlbum", func() { @@ -66,6 +68,23 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3")) }) + + It("streams the request resolved by the transcode decider and names the entry after its format", func() { + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil) + ds.On("MediaFile").Return(mfRepo) + resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2} + dc.resolved = &resolved + ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once() + + out := new(bytes.Buffer) + Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) + ms.AssertExpectations(GinkgoT()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus")) + }) }) Context("ZipArtist", func() { @@ -296,6 +315,30 @@ var _ = Describe("Archiver", func() { }) Context("ZipPlaylist", func() { + It("names the entries and the M3U lines after the resolved format", func() { + pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{ + {MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}}, + }} + plRepo := &mockPlaylistRepository{} + plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) + ds.On("Playlist").Return(plRepo) + dc.resolved = &stream.Request{Format: "opus", BitRate: 128} + ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil) + + out := new(bytes.Buffer) + Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).ToNot(HaveOccurred()) + Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus")) + m3u, err := zr.File[1].Open() + Expect(err).ToNot(HaveOccurred()) + defer m3u.Close() + content, err := io.ReadAll(m3u) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus")) + }) + It("zips a playlist correctly", func() { tracks := []model.PlaylistTrack{ {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}}, @@ -571,6 +614,19 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } +// fakeDecider echoes the legacy format/bitrate unless a resolved request is set. +type fakeDecider struct { + stream.TranscodeDecider + resolved *stream.Request +} + +func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { + if f.resolved != nil { + return *f.resolved + } + return stream.Request{Format: format, BitRate: bitRate, Offset: offset} +} + type mockShare struct { mock.Mock core.Share diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 40baa2495..fb07332fe 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } } - albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false). - GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) + tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false) + if tracks == nil { + return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID) + } + albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if err != nil { return resolution{}, err } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index da144d8e2..2a36531bb 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -707,6 +707,16 @@ var _ = Describe("resolveItem", func() { Expect(err).To(HaveOccurred()) Expect(res).To(Equal(resolution{})) }) + + It("returns an error when the playlist tracks cannot be loaded", func() { + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}}) + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"}) + Expect(err).To(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) }) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 28e51958c..4be99f92e 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -4,9 +4,11 @@ import ( "bytes" "cmp" "context" + "fmt" "io" "math" "math/rand/v2" + "runtime/debug" "sync" "time" @@ -244,7 +246,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) trace := &ChainTrace{} ctx = withTrace(ctx, trace) - out, got, retryIn := w.proc.acquire(ctx, item) + out, got, retryIn := w.safeAcquire(ctx, item) queue := w.proc.ds.ArtworkQueue() switch out { @@ -286,6 +288,20 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc return out, got } +// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an +// unrecovered panic would crash the server, and the still-queued row would crash it again on restart. +func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { + defer func() { + if r := recover(); r != nil { + log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID, + "imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack())) + traceStage(ctx, "panic", fmt.Errorf("%v", r)) + out, got, retryIn = outcomeFailed, nil, 0 + } + }() + return w.proc.acquire(ctx, item) +} + // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item // that never resolved has no row to update, and creating one would settle it absent. func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index a6c07b763..80ca68bc3 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -142,6 +142,18 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla return v.MockPlaylistRepo.Get(ctx, id) } +type panickingAlbumRepo struct { + *tests.MockAlbumRepo + panicID string +} + +func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) { + if id == r.panicID { + panic("boom") + } + return r.MockAlbumRepo.Get(ctx, id) +} + func adminUserRepo() *tests.MockedUserRepo { repo := tests.CreateMockUserRepo() Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) @@ -278,6 +290,36 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("fails an item that panics, without stopping the rest of the batch", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + albums := tests.CreateMockAlbumRepo() + albums.SetData(model.Albums{ + {ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}}, + {ID: "alok", Name: "Album", FolderIDs: []string{"f1"}}, + }) + ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"} + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed()) + Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(2)) + + it := findQueued(queueRepo, "al", "alboom") + Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped") + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + Expect(it.Trace).To(ContainSubstring("boom")) + + Expect(findQueued(queueRepo, "al", "alok")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + }) + It("reschedules past the provider's requested delay when it exceeds the backoff", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) diff --git a/core/auth/auth.go b/core/auth/auth.go index 1d521451a..1bdc917da 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -53,7 +53,7 @@ func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) str log.Info(ctx, "Creating new JWT secret", "key", key) return createNewSecret(ctx, ds, key) } - if secret, err = utils.Decrypt(ctx, EncryptionKey(), secret); err != nil { + if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err) return createNewSecret(ctx, ds, key) } @@ -171,12 +171,11 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { func createNewSecret(ctx context.Context, ds model.DataStore, key string) string { secret := id.NewRandom() - encSecret, err := utils.Encrypt(ctx, EncryptionKey(), secret) + encSecret, err := utils.Encrypt(ctx, getEncKey(), secret) if err != nil { log.Error(ctx, "Could not encrypt JWT secret", err) return secret } - ctx = log.WithSecrets(ctx, encSecret) if err := ds.Property().Put(ctx, key, encSecret); err != nil { log.Error(ctx, "Could not save JWT secret in DB", err) } @@ -196,7 +195,7 @@ func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) { return jwtauth.VerifyToken(TokenAuth, tokenStr) } -func EncryptionKey() []byte { +func getEncKey() []byte { key := cmp.Or( conf.Server.PasswordEncryptionKey, consts.DefaultEncryptionKey, diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 05da7ec65..c86dcd08c 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -15,7 +15,6 @@ import ( ) func TestAuth(t *testing.T) { - tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Auth Test Suite") diff --git a/core/auth/first_admin.go b/core/auth/first_admin.go deleted file mode 100644 index a082e6222..000000000 --- a/core/auth/first_admin.go +++ /dev/null @@ -1,54 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -var ErrSetupComplete = errors.New("setup already complete") - -// CreateFirstAdmin counts and inserts in one locked transaction, so racing setups cannot both win. -// then, if not nil, runs in that same transaction with the new user. -func CreateFirstAdmin(ctx context.Context, ds model.DataStore, username, password string, then func(tx model.DataStore, u *model.User) error) (*model.User, error) { - var created *model.User - err := ds.WithTxImmediate(func(tx model.DataStore) error { - count, err := tx.User().CountAll(ctx) - if err != nil { - return fmt.Errorf("counting users: %w", err) - } - if count > 0 { - return ErrSetupComplete - } - log.Warn(ctx, "Creating initial user", "user", username) - u := model.User{ - ID: id.NewRandom(), - UserName: username, - Name: cases.Title(language.Und).String(username), - NewPassword: password, - IsAdmin: true, - LastLoginAt: new(time.Now()), - } - if err := tx.User().Put(ctx, &u); err != nil { - return fmt.Errorf("creating initial user: %w", err) - } - if created, err = tx.User().Get(ctx, u.ID); err != nil { - return err - } - if then != nil { - return then(tx, created) - } - return nil - }) - if err != nil { - return nil, err - } - return created, nil -} diff --git a/core/auth/first_admin_test.go b/core/auth/first_admin_test.go deleted file mode 100644 index 0cbbcf575..000000000 --- a/core/auth/first_admin_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package auth_test - -import ( - "context" - "errors" - "path/filepath" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("CreateFirstAdmin", Ordered, func() { - var ctx context.Context - var ds model.DataStore - - BeforeAll(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "first-admin.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000" - DeferCleanup(db.Init(GinkgoT().Context())) - ds = persistence.New(db.Db()) - }) - - BeforeEach(func() { - ctx = GinkgoT().Context() - _, err := db.Db().ExecContext(ctx, "delete from user") - Expect(err).ToNot(HaveOccurred()) - }) - - create := func(name string) (*model.User, error) { - return auth.CreateFirstAdmin(ctx, ds, name, "secret", nil) - } - - It("creates an admin with a title-cased name and returns it with its id", func() { - u, err := create("john") - Expect(err).ToNot(HaveOccurred()) - Expect(u.ID).ToNot(BeEmpty()) - Expect(u.IsAdmin).To(BeTrue()) - Expect(u.Name).To(Equal("John")) - - stored, err := ds.User().FindByUsernameWithPassword(ctx, "john") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.Password).To(Equal("secret")) - }) - - It("refuses once any user exists", func() { - _, err := create("first") - Expect(err).ToNot(HaveOccurred()) - _, err = create("second") - Expect(err).To(MatchError(auth.ErrSetupComplete)) - }) - - It("runs then in the same transaction, rolling the user back when it fails", func() { - boom := errors.New("boom") - var seen string - _, err := auth.CreateFirstAdmin(ctx, ds, "john", "secret", func(tx model.DataStore, u *model.User) error { - seen = u.ID - Expect(tx.User().CountAll(ctx)).To(Equal(int64(1))) - return boom - }) - Expect(err).To(MatchError(boom)) - Expect(seen).ToNot(BeEmpty()) - Expect(ds.User().CountAll(ctx)).To(BeZero()) - }) - - It("lets exactly one of two concurrent setups win", func() { - var wg sync.WaitGroup - errs := make([]error, 2) - for i, name := range []string{"racer-a", "racer-b"} { - wg.Add(1) - go func() { - defer GinkgoRecover() - defer wg.Done() - _, errs[i] = auth.CreateFirstAdmin(ctx, slowCountDS{ds}, name, "secret", nil) - }() - } - wg.Wait() - Expect(errs).To(ContainElement(BeNil())) - Expect(errs).To(ContainElement(MatchError(auth.ErrSetupComplete))) - Expect(ds.User().CountAll(ctx)).To(Equal(int64(1))) - }) -}) - -type slowCountDS struct{ model.DataStore } - -func (d slowCountDS) User() model.UserRepository { return slowCountUsers{d.DataStore.User()} } - -func (d slowCountDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { - return d.DataStore.WithTxImmediate(func(tx model.DataStore) error { return block(slowCountDS{tx}) }, scope...) -} - -type slowCountUsers struct{ model.UserRepository } - -// Holds the transaction open after counting, so an unlocked count would interleave with the other racer. -func (u slowCountUsers) CountAll(ctx context.Context, opts ...model.QueryOptions) (int64, error) { - n, err := u.UserRepository.CountAll(ctx, opts...) - time.Sleep(50 * time.Millisecond) - return n, err -} diff --git a/core/inspect.go b/core/inspect.go index 01ec33760..c60459b88 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -15,7 +15,7 @@ type InspectOutput struct { MappedTags *model.MediaFile `json:"mappedTags,omitempty"` } -func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) { +func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) { path, file := filepath.Split(filePath) s, err := storage.For(path) @@ -39,12 +39,22 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e return nil, model.ErrNotFound } - md := metadata.New(path, tag) + md := metadata.New(scannerPath(lib, filePath), tag) result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: new(md.ToMediaFile(libraryId, folderId)), + MappedTags: new(md.ToMediaFile(lib, folderId)), } return result, nil } + +// scannerPath returns the path the scanner uses for the file (relative to its library), so +// folder-based PIDs match the DB. Files outside the library keep their absolute path. +func scannerPath(lib model.Library, filePath string) string { + absPath, err := filepath.Abs(filePath) + if err != nil || lib.Path == "" { + return filePath + } + return model.LibraryRelativePath(lib.Path, absPath) +} diff --git a/core/inspect_test.go b/core/inspect_test.go new file mode 100644 index 000000000..0ac90990c --- /dev/null +++ b/core/inspect_test.go @@ -0,0 +1,45 @@ +package core_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Inspect", func() { + var fixtures string + + BeforeEach(func() { + var err error + fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures")) + Expect(err).ToNot(HaveOccurred()) + }) + + It("maps the file with the library-relative path the scanner uses", func() { + lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} + out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3")) + Expect(out.MappedTags.LibraryID).To(Equal(2)) + }) + + It("gives the same IDs for relative and absolute paths", func() { + lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} + abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "") + Expect(err).ToNot(HaveOccurred()) + Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID)) + Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID)) + }) + + It("keeps the given path for a file outside the library", func() { + filePath := filepath.Join(fixtures, "test.mp3") + out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "") + Expect(err).ToNot(HaveOccurred()) + Expect(out.MappedTags.Path).To(Equal(filePath)) + }) +}) diff --git a/core/library.go b/core/library.go index 628ee4b7b..f1153da26 100644 --- a/core/library.go +++ b/core/library.go @@ -2,10 +2,12 @@ package core import ( "context" + "errors" "fmt" "io/fs" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/slice" @@ -200,23 +203,22 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity } pathChanged := originalLib.Path != lib.Path + pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) || + (updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack) err = r.LibraryRepository.Put(ctx, lib, cols...) if err != nil { return r.mapError(err) } - // Restart watcher and trigger scan if path was updated - if pathChanged { - if r.watcher != nil { - if err := r.watcher.Watch(ctx, lib); err != nil { - log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) - } + if pathChanged && r.watcher != nil { + if err := r.watcher.Watch(ctx, lib); err != nil { + log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) } + } - if r.scanner != nil { - go r.triggerScan(ctx, lib, "updated") - } + if (pathChanged || pidChanged) && r.scanner != nil { + go r.triggerScan(ctx, lib, "updated") } // Send library refresh event to all clients @@ -325,6 +327,15 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library } } + library.PIDAlbum = strings.TrimSpace(library.PIDAlbum) + library.PIDTrack = strings.TrimSpace(library.PIDTrack) + if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil { + validationErrors["pidAlbum"] = err.Error() + } + if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil { + validationErrors["pidTrack"] = err.Error() + } + if len(validationErrors) > 0 { return &rest.ValidationError{Errors: validationErrors} } @@ -332,6 +343,11 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library return nil } +// updatesColumn reports whether an update with these columns writes col. No columns means all of them. +func updatesColumn(cols []string, col string) bool { + return len(cols) == 0 || slices.Contains(cols, col) +} + func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { // Validate path format if !filepath.IsAbs(library.Path) { @@ -399,11 +415,27 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in return nil } +var scanWaitInterval = time.Second + func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { + // Runs in its own goroutine and outlives the HTTP request + ctx = context.WithoutCancel(ctx) + + // A running scan loaded the libraries before this change, and would reject a new request + for { + status, err := r.scanner.Status(ctx) + if err != nil || !status.Scanning { + break + } + time.Sleep(scanWaitInterval) + } + log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) start := time.Now() - warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library - if err != nil { + warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full + if errors.Is(err, model.ErrAlreadyScanning) { + log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name) + } else if err != nil { log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) } else { log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) diff --git a/core/library_test.go b/core/library_test.go index 5402eac22..e6ebb1974 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -322,6 +322,37 @@ var _ = Describe("Library Service", func() { }) }) + Describe("PID validation", func() { + pidError := func(err error, field string) string { + var validationErr *rest.ValidationError + Expect(errors.As(err, &validationErr)).To(BeTrue()) + return validationErr.Errors[field] + } + + It("rejects an unknown attribute in the album PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"}) + Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`)) + }) + + It("rejects albumid in the album PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"}) + Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid")) + }) + + It("rejects an unknown attribute in the track PID", func() { + _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"}) + Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`)) + }) + + It("trims spaces", func() { + library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "} + _, err := repo.Save(ctx, library) + Expect(err).ToNot(HaveOccurred()) + Expect(library.PIDAlbum).To(Equal("folder")) + Expect(library.PIDTrack).To(BeEmpty()) + }) + }) + Describe("Path Validation", func() { Context("Create operation", func() { It("fails when path is not absolute", func() { @@ -679,6 +710,48 @@ var _ = Describe("Library Service", func() { }, "100ms", "10ms").Should(Equal(0)) }) + It("triggers scan when updating the library PID config", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) + + library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} + Expect(repo.Update(ctx, "1", library)).To(Succeed()) + + Eventually(func() int { + return scanner.GetScanAllCallCount() + }, "1s", "10ms").Should(Equal(1)) + // A quick scan: the scanner itself rescans this library in full + Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse()) + }) + + It("does not trigger scan when the PID fields were not sent", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}}) + + // The REST layer decodes a missing pidAlbum as "". Only the sent fields count. + library := model.Library{ID: 1, Name: "Renamed", Path: tempDir} + Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed()) + + Consistently(func() int { + return scanner.GetScanAllCallCount() + }, "100ms", "10ms").Should(Equal(0)) + }) + + It("waits for a running scan before triggering a new one", func() { + libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) + scanner.SetScanning(true) + + library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} + Expect(repo.Update(ctx, "1", library)).To(Succeed()) + + Consistently(func() int { + return scanner.GetScanAllCallCount() + }, "200ms", "20ms").Should(Equal(0)) + + scanner.SetScanning(false) + Eventually(func() int { + return scanner.GetScanAllCallCount() + }, "3s", "20ms").Should(Equal(1)) + }) + It("does not trigger scan when library creation fails", func() { // Try to create library with invalid data (empty name) library := &model.Library{Path: tempDir} diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 4a78a7f3f..1d2ff5df4 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "runtime/debug" + "slices" "strings" "sync" "sync/atomic" @@ -269,9 +270,13 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { if err != nil { log.Trace(ctx, "Error reading radios count", err) } - data.Library.Libraries, err = c.ds.Library().CountAll(ctx) + libs, err := c.ds.Library().GetAll(ctx) if err != nil { - log.Trace(ctx, "Error reading libraries count", err) + log.Trace(ctx, "Error reading libraries", err) + } + data.Library.Libraries = int64(len(libs)) + if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) { + data.Config.HasCustomPID = true } data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, diff --git a/core/playlists/import.go b/core/playlists/import.go index 658bd92dc..b5991b095 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -78,8 +78,8 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde if err != nil { return nil, err } - matcher := newLibraryMatcher(libs) - lib, ok := matcher.findLibrary(dir) + matcher := model.NewLibraryMatcher(libs) + lib, ok := matcher.FindLibrary(dir) if !ok { return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) } diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index 9610e9dbb..ab95b8850 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -1,13 +1,11 @@ package playlists import ( - "cmp" "context" "fmt" "io" "net/url" "path/filepath" - "slices" "strings" "time" @@ -156,61 +154,9 @@ func (r pathResolution) ToQualifiedString() (string, error) { return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil } -// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching. -type libraryMatcher struct { - libraries model.Libraries - cleanedPaths []string -} - -// findLibraryForPath finds which library contains the given absolute path. -// Returns library ID and path, or 0 and empty string if not found. -func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { - lib, ok := lm.findLibrary(absolutePath) - if !ok { - return 0, "" - } - return lib.ID, filepath.Clean(lib.Path) -} - -// findLibrary checks if the absolute path is under any of the library paths. -func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { - // Check sorted libraries (longest path first) to find the best match - for i, cleanLibPath := range lm.cleanedPaths { - // Check if absolutePath is under this library path - if strings.HasPrefix(absolutePath, cleanLibPath) { - // Ensure it's a proper path boundary (not just a prefix) - if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { - return lm.libraries[i], true - } - } - } - return model.Library{}, false -} - -// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). -// This ensures correct matching when library paths are prefixes of each other. -// Example: /music-classical must be checked before /music -// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical -func newLibraryMatcher(libs model.Libraries) *libraryMatcher { - // Sort libraries by path length (descending) to ensure longest paths match first. - slices.SortFunc(libs, func(i, j model.Library) int { - return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending - }) - - // Pre-clean all library paths once for efficient matching - cleanedPaths := make([]string, len(libs)) - for i, lib := range libs { - cleanedPaths[i] = filepath.Clean(lib.Path) - } - return &libraryMatcher{ - libraries: libs, - cleanedPaths: cleanedPaths, - } -} - // pathResolver handles path resolution logic for playlist imports. type pathResolver struct { - matcher *libraryMatcher + matcher *model.LibraryMatcher } // newPathResolver creates a pathResolver with libraries loaded from the datastore. @@ -219,7 +165,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er if err != nil { return nil, err } - matcher := newLibraryMatcher(libs) + matcher := model.NewLibraryMatcher(libs) return &pathResolver{matcher: matcher}, nil } @@ -246,14 +192,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu // a pathResolution with the library information. Returns an invalid resolution if // the path is not found in any library. func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { - libID, libPath := r.matcher.findLibraryForPath(absolutePath) - if libID == 0 { + lib, ok := r.matcher.FindLibrary(absolutePath) + if !ok { return pathResolution{valid: false} } return pathResolution{ absolutePath: absolutePath, - libraryPath: libPath, - libraryID: libID, + libraryPath: filepath.Clean(lib.Path), + libraryID: lib.ID, valid: true, } } @@ -288,7 +234,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // Local paths (file://, absolute, or relative) are resolved to an absolute path // and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string { +func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -308,7 +254,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher return "" } - lib, ok := matcher.findLibrary(localPath) + lib, ok := matcher.FindLibrary(localPath) // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { return "" diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go index b6a3a96f9..ced6c2b16 100644 --- a/core/playlists/parse_m3u_test.go +++ b/core/playlists/parse_m3u_test.go @@ -9,187 +9,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("libraryMatcher", func() { - var ds *tests.MockDataStore - var mockLibRepo *tests.MockLibraryRepo - ctx := context.Background() - - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") - mockLibRepo = &tests.MockLibraryRepo{} - ds = &tests.MockDataStore{ - MockedLibrary: mockLibRepo, - } - }) - - // Helper function to create a libraryMatcher from the mock datastore - createMatcher := func(ds model.DataStore) *libraryMatcher { - libs, err := ds.Library().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - return newLibraryMatcher(libs) - } - - Describe("Longest library path matching", func() { - It("matches the longest library path when multiple libraries share a prefix", func() { - // Setup libraries with prefix conflicts - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - {ID: 2, Path: "/music-classical"}, - {ID: 3, Path: "/music-classical/opera"}, - }) - - matcher := createMatcher(ds) - - // Test that longest path matches first and returns correct library ID - testCases := []struct { - path string - expectedLibID int - expectedLibPath string - }{ - {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"}, - {"/music-classical/track.mp3", 2, "/music-classical"}, - {"/music/track.mp3", 1, "/music"}, - {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"}, - } - - for _, tc := range testCases { - libID, libPath := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID) - Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath) - } - }) - - It("handles libraries with similar prefixes but different structures", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/home/user/music"}, - {ID: 2, Path: "/home/user/music-backup"}, - }) - - matcher := createMatcher(ds) - - // Test that music-backup library is matched correctly - libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3") - Expect(libID).To(Equal(2)) - Expect(libPath).To(Equal("/home/user/music-backup")) - - // Test that music library is still matched correctly - libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/home/user/music")) - }) - - It("matches path that is exactly the library root", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - {ID: 2, Path: "/music-classical"}, - }) - - matcher := createMatcher(ds) - - // Exact library path should match - libID, libPath := matcher.findLibraryForPath("/music-classical") - Expect(libID).To(Equal(2)) - Expect(libPath).To(Equal("/music-classical")) - }) - - It("handles complex nested library structures", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/media"}, - {ID: 2, Path: "/media/audio"}, - {ID: 3, Path: "/media/audio/classical"}, - {ID: 4, Path: "/media/audio/classical/baroque"}, - }) - - matcher := createMatcher(ds) - - testCases := []struct { - path string - expectedLibID int - expectedLibPath string - }{ - {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"}, - {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"}, - {"/media/audio/rock/track.mp3", 2, "/media/audio"}, - {"/media/video/movie.mp4", 1, "/media"}, - } - - for _, tc := range testCases { - libID, libPath := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) - Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath) - } - }) - }) - - Describe("Edge cases", func() { - It("handles empty library list", func() { - mockLibRepo.SetData([]model.Library{}) - - matcher := createMatcher(ds) - Expect(matcher).ToNot(BeNil()) - - // Should not match anything - libID, libPath := matcher.findLibraryForPath("/music/track.mp3") - Expect(libID).To(Equal(0)) - Expect(libPath).To(BeEmpty()) - }) - - It("handles single library", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - }) - - matcher := createMatcher(ds) - - libID, libPath := matcher.findLibraryForPath("/music/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/music")) - }) - - It("handles libraries with special characters in paths", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music[test]"}, - {ID: 2, Path: "/music(backup)"}, - }) - - matcher := createMatcher(ds) - Expect(matcher).ToNot(BeNil()) - - // Special characters should match literally - libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3") - Expect(libID).To(Equal(1)) - Expect(libPath).To(Equal("/music[test]")) - }) - }) - - Describe("Path matching order", func() { - It("ensures longest paths match first", func() { - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/a"}, - {ID: 2, Path: "/ab"}, - {ID: 3, Path: "/abc"}, - }) - - matcher := createMatcher(ds) - - // Verify that longer paths match correctly (not cut off by shorter prefix) - testCases := []struct { - path string - expectedLibID int - }{ - {"/abc/file.mp3", 3}, - {"/ab/file.mp3", 2}, - {"/a/file.mp3", 1}, - } - - for _, tc := range testCases { - libID, _ := matcher.findLibraryForPath(tc.path) - Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) - } - }) - }) -}) - var _ = Describe("pathResolver", func() { var ds *tests.MockDataStore var mockLibRepo *tests.MockLibraryRepo diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 686838565..e2ce00a1b 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -76,6 +76,17 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) { return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name))) } +// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them. +var _ fs.ReadLinkFS = (*localFS)(nil) + +func (lfs *localFS) ReadLink(name string) (string, error) { + return fs.ReadLink(lfs.FS, name) +} + +func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) { + return fs.Lstat(lfs.FS, name) +} + func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { res, err := lfs.extractor.Parse(path...) if err != nil { diff --git a/db/migrations/20260926045200_create_api_grant.sql b/db/migrations/20260926045200_create_api_grant.sql deleted file mode 100644 index 84fec2622..000000000 --- a/db/migrations/20260926045200_create_api_grant.sql +++ /dev/null @@ -1,23 +0,0 @@ --- +goose Up --- +goose StatementBegin -create table api_grant ( - id varchar not null primary key, - user_id varchar not null references user(id) on delete cascade, - name varchar not null, - client varchar not null, - client_version varchar not null default '', - scopes varchar not null default '', - provider varchar not null, - secret_hash varchar not null unique, - user_epoch integer not null default 0, - created_at datetime not null, - last_used_at datetime, - last_used_ip varchar not null default '' -); -create index api_grant_user_id on api_grant(user_id); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -drop table api_grant; --- +goose StatementEnd diff --git a/db/migrations/20260929221042_add_library_pid_columns.sql b/db/migrations/20260929221042_add_library_pid_columns.sql new file mode 100644 index 000000000..487512287 --- /dev/null +++ b/db/migrations/20260929221042_add_library_pid_columns.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +alter table library add column pid_album varchar default '' not null; +alter table library add column pid_track varchar default '' not null; +alter table library add column scanned_pid_album varchar default '' not null; +alter table library add column scanned_pid_track varchar default '' not null; + +-- Every library was scanned with the global PID config, so seed it as their scanned config. +-- This way the upgrade does not trigger a full rescan. +update library set + scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''), + scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), ''); + +delete from property where id in ('PIDAlbum', 'PIDTrack'); +-- +goose StatementEnd + +-- +goose Down +SELECT 1; diff --git a/go.mod b/go.mod index 12cc95e73..e96b8c8b3 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,6 @@ require ( github.com/mattn/go-sqlite3 v1.14.52 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/oapi-codegen/runtime v1.7.0 github.com/onsi/ginkgo/v2 v2.33.0 github.com/onsi/gomega v1.44.0 github.com/pelletier/go-toml/v2 v2.4.3 @@ -75,7 +74,6 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index ed9f32f13..fe6dbbc3f 100644 --- a/go.sum +++ b/go.sum @@ -6,18 +6,14 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8= github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw= github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= @@ -140,7 +136,6 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8 github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -193,10 +188,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= -github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= -github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= @@ -264,7 +255,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -273,7 +263,6 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/log/log.go b/log/log.go index eb6f81cb8..da1d7622e 100644 --- a/log/log.go +++ b/log/log.go @@ -72,10 +72,7 @@ const ( type contextKey string -const ( - loggerCtxKey = contextKey("logger") - secretsCtxKey = contextKey("secrets") -) +const loggerCtxKey = contextKey("logger") type levelPath struct { path string @@ -191,34 +188,6 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { return ctx } -// Shorter values could match unrelated log text, or the [REDACTED] marker itself. -const minSecretLen = 8 - -// WithSecrets returns a context whose log entries have every occurrence of values replaced by -// [REDACTED], when redacting is enabled. Values shorter than minSecretLen are ignored. -func WithSecrets(ctx context.Context, values ...string) context.Context { - if ctx == nil { - ctx = context.Background() - } - secrets := slices.Clone(secretsFrom(ctx)) - for _, v := range values { - if len(v) >= minSecretLen { - secrets = append(secrets, v) - } - } - // Longest first, so a secret containing another is not left partly visible. - slices.SortStableFunc(secrets, func(a, b string) int { return cmp.Compare(len(b), len(a)) }) - return context.WithValue(ctx, secretsCtxKey, secrets) -} - -func secretsFrom(ctx context.Context) []string { - if ctx == nil { - return nil - } - secrets, _ := ctx.Value(secretsCtxKey).([]string) - return secrets -} - // SetDefaultLogger swaps the process-wide logger and returns the previous one, // so tests can restore the original (with its hooks and formatter) on cleanup. func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { @@ -320,12 +289,6 @@ func parseArgs(args []any) (*logrus.Entry, string) { if err != nil { l = createNewLogger() } else { - switch ctx := args[0].(type) { - case context.Context: - l = l.WithContext(ctx) - case *http.Request: - l = l.WithContext(ctx.Context()) - } args = args[1:] } } diff --git a/log/log_test.go b/log/log_test.go index 0e6628adb..184ff57db 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -1,14 +1,11 @@ package log import ( - "bytes" "context" "encoding/json" "errors" - "fmt" "net/http" "net/http/httptest" - "runtime" "testing" "time" @@ -96,9 +93,9 @@ var _ = Describe("Logger", func() { It("logs source file and line number, if requested", func() { SetLogSourceLine(true) - _, _, line, _ := runtime.Caller(0) Error("A crash happened") - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring(fmt.Sprintf("/log/log_test.go:%d", line+1))) + // NOTE: This assertion breaks if the line number above changes + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) @@ -112,26 +109,6 @@ var _ = Describe("Logger", func() { Error("Simple Message", "key1", t) Expect(hook.LastEntry().Data["key1"]).To(Equal("nil")) }) - - It("passes the call's context to hooks", func() { - ctx := WithSecrets(GinkgoT().Context(), "s3cr3t-value") - Error(ctx, "Simple Message") - Expect(hook.LastEntry().Context).To(Equal(ctx)) - - Error(httptest.NewRequest("get", "/", nil).WithContext(ctx), "Simple Message") - Expect(hook.LastEntry().Context).To(Equal(ctx)) - }) - - It("redacts the context's secrets when redacting is on", func() { - l.AddHook(redacted) - ctx := WithSecrets(NewContext(GinkgoT().Context(), "user", "admin"), "s3cr3t-value") - - var buf bytes.Buffer - l.SetOutput(&buf) - Error(ctx, "Saving s3cr3t-value", "args", map[string]any{"value": "s3cr3t-value"}) - Expect(buf.String()).ToNot(ContainSubstring("s3cr3t-value")) - Expect(buf.String()).To(ContainSubstring("user=admin")) - }) }) Describe("Levels", func() { diff --git a/log/redactrus.go b/log/redactrus.go index b8ad0ddb4..6e17243e7 100755 --- a/log/redactrus.go +++ b/log/redactrus.go @@ -7,7 +7,6 @@ import ( "fmt" "reflect" "regexp" - "strings" "github.com/sirupsen/logrus" ) @@ -36,7 +35,6 @@ func (h *Hook) Fire(e *logrus.Entry) error { if err := h.initRedaction(); err != nil { return err } - redactSecrets(e) for _, re := range h.redactionKeys { // Redact based on key matching in Data fields for k, v := range e.Data { @@ -49,8 +47,7 @@ func (h *Hook) Fire(e *logrus.Entry) error { } switch reflect.TypeOf(v).Kind() { case reflect.String: - // Via reflect: named string types (e.g. enums) have Kind String but fail v.(string). - e.Data[k] = re.ReplaceAllString(reflect.ValueOf(v).String(), "$1[REDACTED]$2") + e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2") continue case reflect.Map: s := fmt.Sprintf("%+v", v) @@ -66,36 +63,6 @@ func (h *Hook) Fire(e *logrus.Entry) error { return nil } -// redactSecrets hides the values marked with WithSecrets in the context the entry was logged with. -func redactSecrets(e *logrus.Entry) { - secrets := secretsFrom(e.Context) - if len(secrets) == 0 { - return - } - hide := func(s string) string { - for _, secret := range secrets { - s = strings.ReplaceAll(s, secret, "[REDACTED]") - } - return s - } - e.Message = hide(e.Message) - for k, v := range e.Data { - if v == nil { - continue - } - // fmt.Sprint renders like the text formatter and survives typed-nil errors; []byte is written raw. - var s string - if b, ok := v.([]byte); ok { - s = string(b) - } else { - s = fmt.Sprint(v) - } - if hidden := hide(s); hidden != s { - e.Data[k] = hidden - } - } -} - func (h *Hook) initRedaction() error { if len(h.redactionKeys) == 0 { for _, redactionKey := range h.RedactionList { diff --git a/log/redactrus_test.go b/log/redactrus_test.go index 6b9d71f89..36a19e2f5 100755 --- a/log/redactrus_test.go +++ b/log/redactrus_test.go @@ -1,8 +1,6 @@ package log import ( - "errors" - "net/url" "testing" "github.com/sirupsen/logrus" @@ -159,85 +157,3 @@ func TestEntryMessage(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message) } - -type namedString string - -func TestFireRedactsNamedStringTypes(t *testing.T) { - hook := &Hook{RedactionList: []string{"(secret=)[^&]+"}} - e := &logrus.Entry{Data: logrus.Fields{"code": namedString("not_found"), "url": namedString("/x?secret=abc")}} - - assert.NotPanics(t, func() { _ = hook.Fire(e) }) - assert.Equal(t, "not_found", e.Data["code"]) - assert.Equal(t, "/x?secret=[REDACTED]", e.Data["url"]) -} - -func TestFireRedactsContextSecrets(t *testing.T) { - ctx := WithSecrets(t.Context(), "s3cr3t-value") - ctx = WithSecrets(ctx, "", "other-secret") - e := &logrus.Entry{ - Context: ctx, - Message: "value s3cr3t-value in message", - Data: logrus.Fields{ - "str": "has s3cr3t-value", - "named": namedString("named other-secret"), - "args": map[string]any{"p0": "s3cr3t-value", "p1": "plain"}, - "error": errors.New("failed with other-secret"), - "num": 42, - "clean": namedString("untouched"), - }, - } - - assert.Nil(t, (&Hook{}).Fire(e)) - assert.Equal(t, "value [REDACTED] in message", e.Message) - assert.Equal(t, "has [REDACTED]", e.Data["str"]) - assert.Equal(t, "named [REDACTED]", e.Data["named"]) - assert.Equal(t, "map[p0:[REDACTED] p1:plain]", e.Data["args"]) - assert.Equal(t, "failed with [REDACTED]", e.Data["error"]) - assert.Equal(t, 42, e.Data["num"]) - assert.Equal(t, namedString("untouched"), e.Data["clean"]) -} - -func TestFireRedactsContextSecretsInAnyValueType(t *testing.T) { - ctx := WithSecrets(t.Context(), "s3cr3t-value") - var nilErr *url.Error - e := &logrus.Entry{ - Context: ctx, - Data: logrus.Fields{ - "slice": []any{"s3cr3t-value", 1}, - "struct": struct{ A string }{"s3cr3t-value"}, - "bytes": []byte("has s3cr3t-value"), - "nilErr": nilErr, - }, - } - - assert.NotPanics(t, func() { _ = (&Hook{}).Fire(e) }) - assert.Equal(t, "[[REDACTED] 1]", e.Data["slice"]) - assert.Equal(t, "{[REDACTED]}", e.Data["struct"]) - assert.Equal(t, "has [REDACTED]", e.Data["bytes"]) - assert.Equal(t, nilErr, e.Data["nilErr"]) -} - -func TestFireWithoutContextSecretsLeavesEntryUnchanged(t *testing.T) { - args := map[string]any{"p0": "value"} - e := &logrus.Entry{Context: t.Context(), Message: "value", Data: logrus.Fields{"str": "value", "args": args}} - - assert.Nil(t, (&Hook{}).Fire(e)) - assert.Equal(t, "value", e.Message) - assert.Equal(t, logrus.Fields{"str": "value", "args": args}, e.Data) -} - -func TestFireRedactsLongerSecretsFirst(t *testing.T) { - ctx := WithSecrets(t.Context(), "abcdefgh", "abcdefghijkl") - e := &logrus.Entry{Context: ctx, Message: "abcdefghijkl"} - - assert.Nil(t, (&Hook{}).Fire(e)) - assert.Equal(t, "[REDACTED]", e.Message) -} - -func TestFireIgnoresShortSecrets(t *testing.T) { - ctx := WithSecrets(t.Context(), "abc") - e := &logrus.Entry{Context: ctx, Message: "abc in UPDATE ... abc"} - - assert.Nil(t, (&Hook{}).Fire(e)) - assert.Equal(t, "abc in UPDATE ... abc", e.Message) -} diff --git a/model/datastore.go b/model/datastore.go index 1175d212d..6ded8c575 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -37,7 +37,6 @@ type DataStore interface { Plugin() PluginRepository Artwork() ArtworkRepository ArtworkQueue() ArtworkQueueRepository - Grant() GrantRepository WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error diff --git a/model/grant.go b/model/grant.go deleted file mode 100644 index ecbf30e41..000000000 --- a/model/grant.go +++ /dev/null @@ -1,67 +0,0 @@ -package model - -import ( - "context" - "database/sql/driver" - "fmt" - "strings" - "time" -) - -type Grant struct { - ID string `structs:"id" json:"id"` - UserID string `structs:"user_id" json:"userId"` - Name string `structs:"name" json:"name"` - Client string `structs:"client" json:"client"` - ClientVersion string `structs:"client_version" json:"clientVersion"` - Scopes Scopes `structs:"scopes" json:"scopes"` - Provider string `structs:"provider" json:"provider"` - SecretHash string `structs:"secret_hash" json:"-"` - UserEpoch int `structs:"user_epoch" json:"-"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt"` - LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"` -} - -func (g Grant) LastActivity() time.Time { - if g.LastUsedAt != nil { - return *g.LastUsedAt - } - return g.CreatedAt -} - -type Grants []Grant - -// Scopes is stored as a single space-separated column. -type Scopes []string - -func (s Scopes) Value() (driver.Value, error) { - return strings.Join(s, " "), nil -} - -func (s *Scopes) Scan(src any) error { - switch v := src.(type) { - case string: - *s = strings.Fields(v) - case []byte: - *s = strings.Fields(string(v)) - case nil: - *s = nil - default: - return fmt.Errorf("cannot scan %T into Scopes", src) - } - return nil -} - -type GrantRepository interface { - Put(ctx context.Context, g *Grant) error - Get(ctx context.Context, id string) (*Grant, error) - FindBySecretHash(ctx context.Context, hash string) (*Grant, error) - GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (Grants, error) - CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) - DeleteForUser(ctx context.Context, userID, id string) error - DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error - SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error - Touch(ctx context.Context, id, ip string, at, notSince time.Time) error - DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) -} diff --git a/model/grant_test.go b/model/grant_test.go deleted file mode 100644 index dd59a2422..000000000 --- a/model/grant_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package model_test - -import ( - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Grant", func() { - Describe("LastActivity", func() { - created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - - It("is the creation time for a grant never used", func() { - Expect(model.Grant{CreatedAt: created}.LastActivity()).To(Equal(created)) - }) - - It("is the last use once the grant was used", func() { - used := created.Add(time.Hour) - Expect(model.Grant{CreatedAt: created, LastUsedAt: &used}.LastActivity()).To(Equal(used)) - }) - }) -}) diff --git a/model/library.go b/model/library.go index 1e33222ac..e80d22c89 100644 --- a/model/library.go +++ b/model/library.go @@ -1,10 +1,13 @@ package model import ( + "cmp" "context" + "strings" "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/utils/slice" ) @@ -27,6 +30,38 @@ type Library struct { TotalSize int64 `json:"totalSize" db:"total_size"` TotalDuration float64 `json:"totalDuration" db:"total_duration"` DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` + PIDAlbum string `json:"pidAlbum" db:"pid_album"` + PIDTrack string `json:"pidTrack" db:"pid_track"` + ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"` + ScannedPIDTrack string `json:"-" db:"scanned_pid_track"` +} + +// PIDConfig holds the persistent ID specs used to compute track and album IDs. +type PIDConfig struct { + Track string + Album string +} + +// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to +// the global config. +func (l Library) EffectivePID() PIDConfig { + return PIDConfig{ + Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track), + Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album), + } +} + +// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished +// scan of this library. A library that was never scanned counts as changed. +func (l Library) PIDChanged() bool { + pid := l.EffectivePID() + return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track) +} + +// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be +// rescanned in full. A library that never finished a scan has nothing to regroup. +func (l Library) NeedsPIDRescan() bool { + return !l.LastScanAt.IsZero() && l.PIDChanged() } const ( @@ -59,6 +94,8 @@ type LibraryRepository interface { // TODO These methods should be moved to a core service ScanBegin(ctx context.Context, id int, fullScan bool) error ScanEnd(ctx context.Context, id int) error + // SetScannedPID records the PID specs used by the last finished scan of the library + SetScannedPID(ctx context.Context, id int, pid PIDConfig) error ScanInProgress(ctx context.Context) (bool, error) RefreshStats(ctx context.Context, id int) error } diff --git a/model/library_matcher.go b/model/library_matcher.go new file mode 100644 index 000000000..83af96f9f --- /dev/null +++ b/model/library_matcher.go @@ -0,0 +1,57 @@ +package model + +import ( + "cmp" + "path/filepath" + "slices" + "strings" +) + +// LibraryMatcher finds the library that contains an absolute path. +type LibraryMatcher struct { + libraries Libraries + cleanedPaths []string +} + +// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music. +func NewLibraryMatcher(libs Libraries) *LibraryMatcher { + libs = slices.Clone(libs) + slices.SortFunc(libs, func(i, j Library) int { + return cmp.Compare(len(j.Path), len(i.Path)) + }) + cleanedPaths := make([]string, len(libs)) + for i, lib := range libs { + cleanedPaths[i] = filepath.Clean(lib.Path) + } + return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths} +} + +// FindLibrary returns the library whose path contains absolutePath. +func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) { + for i, libPath := range lm.cleanedPaths { + // A cleaned path only ends with a separator when it is a filesystem root + if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) || + absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) { + return lm.libraries[i], true + } + } + return Library{}, false +} + +// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it +// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged. +func LibraryRelativePath(libPath, path string) string { + if !filepath.IsAbs(path) { + return path + } + // The library root may be relative (e.g. the default "./music"); it resolves against the same cwd + absLib, err := filepath.Abs(libPath) + if err != nil { + return path + } + rel, err := filepath.Rel(absLib, path) + if err != nil || !filepath.IsLocal(rel) { + return path + } + return filepath.ToSlash(rel) +} diff --git a/model/library_matcher_test.go b/model/library_matcher_test.go new file mode 100644 index 000000000..09e6f7e33 --- /dev/null +++ b/model/library_matcher_test.go @@ -0,0 +1,91 @@ +package model_test + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LibraryMatcher", func() { + // Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does + find := func(libs model.Libraries, path string) int { + for i := range libs { + libs[i].Path = filepath.FromSlash(libs[i].Path) + } + lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path)) + if !ok { + return 0 + } + return lib.ID + } + + DescribeTable("matches the longest library path", + func(libs model.Libraries, path string, expectedID int) { + Expect(find(libs, path)).To(Equal(expectedID)) + }, + Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3), + Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2), + Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1), + Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2), + Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3), + Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2), + Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1), + Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1), + Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1), + Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2), + ) + + It("does not match a path outside every library", func() { + Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero()) + }) + + It("does not match anything without libraries", func() { + Expect(find(nil, "/music/track.mp3")).To(BeZero()) + }) + + It("does not reorder the caller's libraries", func() { + libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}} + model.NewLibraryMatcher(libs) + Expect(libs.IDs()).To(Equal([]int{1, 2})) + }) +}) + +var _ = Describe("LibraryRelativePath", func() { + // Paths are built with filepath so the "absolute" cases stay absolute on every OS + // (a Unix-style "/foo" is not absolute on Windows). + libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) + outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) + + It("returns a relative path unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) + }) + + It("rebases an absolute target when the library root is relative", func() { + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) + }) + + It("rebases an absolute path that equals the library root to '.'", func() { + Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal(".")) + }) + + It("rebases an absolute path under the library root", func() { + Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("handles a trailing slash on the library path", func() { + Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("leaves an absolute path outside the library root unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside)) + }) + + It("returns an empty path unchanged", func() { + Expect(model.LibraryRelativePath(libRoot, "")).To(Equal("")) + }) +}) diff --git a/model/library_test.go b/model/library_test.go new file mode 100644 index 000000000..4e799e13f --- /dev/null +++ b/model/library_test.go @@ -0,0 +1,73 @@ +package model_test + +import ( + "encoding/json" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Library PID config", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "global_album" + conf.Server.PID.Track = "global_track" + }) + + Describe("EffectivePID", func() { + It("falls back to the global config", func() { + Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"})) + }) + It("uses the library overrides", func() { + lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"} + Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"})) + }) + }) + + Describe("PIDChanged", func() { + It("is false when the scanned specs match, ignoring case", func() { + lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeFalse()) + }) + It("is true when the album override differs from the scanned spec", func() { + lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true when only the track spec changed", func() { + lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true when the global config changed for a library without overrides", func() { + lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"} + Expect(lib.PIDChanged()).To(BeTrue()) + }) + It("is true for a library that was never scanned", func() { + Expect(model.Library{}.PIDChanged()).To(BeTrue()) + }) + }) + + Describe("NeedsPIDRescan", func() { + It("is false for a library that never finished a scan", func() { + Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse()) + }) + It("is true for a scanned library whose PID config changed", func() { + lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} + Expect(lib.NeedsPIDRescan()).To(BeTrue()) + }) + It("is false for a scanned library whose PID config did not change", func() { + lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} + Expect(lib.NeedsPIDRescan()).To(BeFalse()) + }) + }) + + It("does not expose the scanned specs in JSON", func() { + data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"}) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`)) + Expect(string(data)).ToNot(ContainSubstring("secret_")) + }) +}) diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 6d12feba9..2135824d2 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -8,15 +8,14 @@ import ( "math" "strconv" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" ) -func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { +func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile { mf := model.MediaFile{ - LibraryID: libID, + LibraryID: lib.ID, FolderID: folderID, Tags: maps.Clone(md.tags), } @@ -84,8 +83,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.AlbumArtist = md.mapDisplayAlbumArtist(mf) // Persistent IDs - mf.PID = md.trackPID(mf) - mf.AlbumID = md.albumID(mf, conf.Server.PID.Album) + pid := lib.EffectivePID() + mf.PID = md.trackPID(mf, pid) + mf.AlbumID = md.albumID(mf, pid.Album) // BFR These IDs will go away once the UI handle multiple participants. // BFR For Legacy Subsonic compatibility, we will set them in the API handlers diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index baaf8fab5..c19398841 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -30,9 +30,23 @@ var _ = Describe("ToMediaFile", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(1, "folderID") + return md.ToMediaFile(model.Library{ID: 1}, "folderID") } + Describe("Persistent IDs", func() { + It("uses the library PID config for the album ID and for albumid in the track spec", func() { + props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}} + md = metadata.New("Jazz/Loose/01.mp3", props) + + byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID") + byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID") + + Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID)) + Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder"))) + Expect(byFolder.PID).ToNot(Equal(byTags.PID)) + }) + }) + Describe("Dates", func() { It("should parse properly tagged dates ", func() { mf = toMediaFile(model.RawTags{ diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index ec66e12b9..db652fb8b 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -38,7 +38,7 @@ var _ = Describe("Participants", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(1, "folderID") + return md.ToMediaFile(model.Library{ID: 1}, "folderID") } Describe("ARTIST(S) tags", func() { diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index c84d93981..a1a675006 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -323,7 +323,7 @@ var _ = Describe("Metadata", func() { tag: {tagValue}, } md = metadata.New(filePath, props) - return md.ToMediaFile(0, "0") + return md.ToMediaFile(model.Library{}, "0") } DescribeTable("Gain", diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index db315dc6b..b66ce824a 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -2,11 +2,11 @@ package metadata import ( "cmp" + "errors" "fmt" "path/filepath" "strings" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -22,12 +22,13 @@ type hashFunc = func(...string) string // attributes. Attributes can be either tags or processed values like folder, // albumid, albumartistid, etc. For each field, it gets all its attribute values // and concatenates them, then hashes the result. If a field is empty, it is -// skipped and the function looks for the next field. +// skipped and the function looks for the next field. albumSpec is the album PID +// spec used to resolve the `albumid` attribute. // // Taking hash as a parameter (instead of closing over it in a factory) keeps // mf on the stack: closing over mf would force the whole ~1KB MediaFile to the // heap on every call. -func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string { +func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string { switch spec { case "track_legacy": return legacyTrackID(mf, prependLibId) @@ -41,7 +42,7 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, values := make([]string, len(attributes)) hasValue := false for i, attr := range attributes { - v := getPIDAttr(mf, md, attr, prependLibId, spec, hash) + v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash) if v != "" { hasValue = true } @@ -58,15 +59,15 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, return hash(pid) } -func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string { +func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string { attr = strings.TrimSpace(strings.ToLower(attr)) switch attr { case "albumid": - if spec == conf.Server.PID.Album { + if spec == albumSpec { log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) return "" } - return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash) + return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash) case "folder": return filepath.Dir(mf.Path) case "albumartistid": @@ -79,18 +80,50 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, return md.String(model.TagName(attr)) } -func (md Metadata) trackPID(mf model.MediaFile) string { - return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash) +// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config". +// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them. +func ValidatePIDSpec(spec string, isAlbum bool) error { + switch { + case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy": + return nil + } + for field := range strings.SplitSeq(spec, "|") { + for attr := range strings.SplitSeq(field, ",") { + attr = strings.TrimSpace(strings.ToLower(attr)) + switch attr { + case "": + return fmt.Errorf("empty attribute in %q", spec) + case "albumid": + if isAlbum { + return errors.New("albumid cannot be used in an album PID") + } + case "folder", "albumartistid": + default: + name, ok := model.CanonicalTagName(attr) + if !ok { + return fmt.Errorf("unknown attribute %q", attr) + } + if isAlbum && string(name) != attr { + return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr) + } + } + } + } + return nil +} + +func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string { + return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash) } func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { - return computePID(mf, md, pidConf, true, id.NewHash) + return computePID(mf, md, pidConf, pidConf, true, id.NewHash) } // BFR Must be configurable? func (md Metadata) artistID(name string) string { mf := model.MediaFile{AlbumArtist: name} - return computePID(mf, md, "albumartistid", false, id.NewHash) + return computePID(mf, md, "albumartistid", "", false, id.NewHash) } func (md Metadata) mapTrackTitle() string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 8e38bbd42..9f6eaf1f4 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -3,8 +3,7 @@ package metadata import ( "strings" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -13,16 +12,18 @@ import ( var _ = Describe("getPID", func() { var ( - md Metadata - mf model.MediaFile - sum hashFunc + md Metadata + mf model.MediaFile + sum hashFunc + albumSpec string ) getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - return computePID(mf, md, spec, prependLibId, sum) + return computePID(mf, md, spec, albumSpec, prependLibId, sum) } BeforeEach(func() { sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } + albumSpec = consts.DefaultAlbumPID }) Context("attributes are tags", func() { @@ -66,8 +67,7 @@ var _ = Describe("getPID", func() { Context("calculated attributes", func() { BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" + albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" }) When("field is title", func() { It("should return the pid", func() { @@ -121,8 +121,8 @@ var _ = Describe("getPID", func() { When("albumid configuration refers to albumid recursively", func() { It("should avoid infinite recursion", func() { // Reproduce the issue from #4920 - conf.Server.PID.Album = "albumid,album,albumversion,releasedate" - spec := conf.Server.PID.Album + albumSpec = "albumid,album,albumversion,releasedate" + spec := albumSpec md.tags = map[model.TagName][]string{ "album": {"Album Name"}, "albumversion": {"Version"}, @@ -205,8 +205,7 @@ var _ = Describe("getPID", func() { }) When("prependLibId is true with nested albumid", func() { It("should handle nested albumid calls correctly", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "album" + albumSpec = "album" spec := "albumid" md.tags = map[model.TagName][]string{"album": {"Test Album"}} mf.AlbumArtist = "Test Artist" @@ -306,3 +305,34 @@ var _ = Describe("getPID", func() { }) }) }) + +var _ = Describe("ValidatePIDSpec", func() { + DescribeTable("accepts valid specs", + func(spec string, isAlbum bool) { + Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed()) + }, + Entry("empty, meaning the global config", "", true), + Entry("default album spec", consts.DefaultAlbumPID, true), + Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false), + Entry("folder", "folder", true), + Entry("album legacy", "album_legacy", true), + Entry("track legacy", "track_legacy", false), + Entry("computed attributes", "albumartistid,album|title", true), + Entry("albumid in a track spec", "albumid,title", false), + Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true), + ) + + DescribeTable("rejects invalid specs", + func(spec string, isAlbum bool, msg string) { + Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg))) + }, + Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`), + Entry("empty field", "album||title", true, "empty attribute"), + Entry("empty attribute", "album,,title", true, "empty attribute"), + Entry("trailing separator", "album|", true, "empty attribute"), + Entry("albumid in an album spec", "albumid,album", true, "albumid"), + Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`), + Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`), + Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`), + ) +}) diff --git a/model/scanner.go b/model/scanner.go index 36c9007fb..d22c3d0d6 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -2,12 +2,15 @@ package model import ( "context" + "errors" "fmt" "strconv" "strings" "time" ) +var ErrAlreadyScanning = errors.New("already scanning") + // ScanTarget represents a specific folder within a library to be scanned. // NOTE: This struct is used as a map key, so it should only contain comparable types. type ScanTarget struct { diff --git a/model/tag_mappings.go b/model/tag_mappings.go index ce7d2f37b..5a8168754 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -195,6 +195,28 @@ func TagMappings() map[TagName]TagConf { return mappings } +// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name. +func CanonicalTagName(name string) (TagName, bool) { + tagName, ok := tagNameIndex()[TagName(name).ToLower()] + return tagName, ok +} + +// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases +// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid). +var tagNameIndex = sync.OnceValue(func() map[TagName]TagName { + mappings := TagMappings() + index := make(map[TagName]TagName, len(mappings)) + for name, tag := range mappings { + for _, alias := range tag.Aliases { + index[TagName(alias)] = name + } + } + for name := range mappings { + index[name] = name + } + return index +}) + func TagRolesConf() TagConf { _, cfg := parseMappings() return cfg.Roles diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go index e582c3f2f..91e54e5d4 100644 --- a/model/tag_mappings_test.go +++ b/model/tag_mappings_test.go @@ -192,3 +192,22 @@ var _ = Describe("TagConf", func() { }) }) }) + +var _ = Describe("CanonicalTagName", func() { + DescribeTable("resolves tag names and aliases", + func(name string, expected TagName) { + tagName, ok := CanonicalTagName(name) + Expect(ok).To(BeTrue()) + Expect(tagName).To(Equal(expected)) + }, + Entry("tag name", "album", TagAlbum), + Entry("alias", "talb", TagAlbum), + Entry("mixed case alias", "TALB", TagAlbum), + Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID), + ) + + It("does not resolve an unknown name", func() { + _, ok := CanonicalTagName("nosuchtag") + Expect(ok).To(BeFalse()) + }) +}) diff --git a/persistence/grant_repository.go b/persistence/grant_repository.go deleted file mode 100644 index 349099983..000000000 --- a/persistence/grant_repository.go +++ /dev/null @@ -1,114 +0,0 @@ -package persistence - -import ( - "context" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/pocketbase/dbx" -) - -type grantRepository struct { - sqlRepository -} - -func NewGrantRepository(db dbx.Builder) model.GrantRepository { - r := &grantRepository{} - r.db = db - r.tableName = "api_grant" - return r -} - -const grantLastActivity = "COALESCE(last_used_at, created_at)" - -func (r *grantRepository) Put(ctx context.Context, g *model.Grant) error { - if g.ID == "" { - g.ID = id.NewRandom() - } - if g.CreatedAt.IsZero() { - g.CreatedAt = time.Now() - } - // Stored as UTC: SQLite compares these timestamps as strings. - g.CreatedAt = g.CreatedAt.UTC() - if g.LastUsedAt != nil { - t := g.LastUsedAt.UTC() - g.LastUsedAt = &t - } - values, err := toSQLArgs(*g) - if err != nil { - return err - } - _, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values)) - return err -} - -func (r *grantRepository) Get(ctx context.Context, id string) (*model.Grant, error) { - return r.findOne(ctx, Eq{"id": id}) -} - -func (r *grantRepository) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) { - return r.findOne(ctx, Eq{"secret_hash": hash}) -} - -func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Grant, error) { - var g model.Grant - if err := r.queryOne(ctx, r.newSelect(ctx).Columns("*").Where(cond), &g); err != nil { - return nil, err - } - return &g, nil -} - -func activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer { - return And{Eq{"user_id": userID, "user_epoch": epoch}, Expr(grantLastActivity+" >= ?", idleSince.UTC())} -} - -func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (model.Grants, error) { - sel := r.newSelect(ctx).Columns("*").Where(activeForUser(userID, epoch, idleSince)). - OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id"). - Offset(uint64(offset)).Limit(uint64(limit)) - var res model.Grants - err := r.queryAll(ctx, sel, &res) - return res, err -} - -func (r *grantRepository) CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) { - return r.count(ctx, Select().Where(activeForUser(userID, epoch, idleSince))) -} - -func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error { - n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID})) - if err != nil { - return err - } - if n == 0 { - return model.ErrNotFound - } - return nil -} - -func (r *grantRepository) DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error { - return r.delete(ctx, And{Eq{"user_id": userID}, Lt{"user_epoch": currentEpoch}}) -} - -// SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back. -func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error { - cond := Eq{"user_id": userID, "user_epoch": fromEpoch} - if onlyID != "" { - cond["id"] = onlyID - } - _, err := r.executeSQL(ctx, Update(r.tableName).Set("user_epoch", toEpoch).Where(cond)) - return err -} - -func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error { - upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip). - Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}}) - _, err := r.executeSQL(ctx, upd) - return err -} - -func (r *grantRepository) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) { - return r.executeSQL(ctx, Delete(r.tableName).Where(Expr(grantLastActivity+" < ?", idleSince.UTC()))) -} diff --git a/persistence/grant_repository_test.go b/persistence/grant_repository_test.go deleted file mode 100644 index 2436f8bb4..000000000 --- a/persistence/grant_repository_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("GrantRepository", func() { - var ctx context.Context - var repo model.GrantRepository - var now time.Time - - newGrant := func(userID, hash string) *model.Grant { - return &model.Grant{UserID: userID, Name: "TV", Client: "TestApp", Scopes: model.Scopes{"all"}, - Provider: "password", SecretHash: hash, CreatedAt: now} - } - - BeforeEach(func() { - ctx = log.NewContext(GinkgoT().Context()) - repo = NewGrantRepository(GetDBXBuilder()) - now = time.Now().UTC().Truncate(time.Second) - DeferCleanup(func() { - _, _ = GetDBXBuilder().NewQuery("delete from api_grant").Execute() - }) - }) - - It("stores a grant and finds it by id and by secret hash", func() { - g := newGrant(adminUser.ID, "hash-1") - g.Scopes = model.Scopes{"read", "password"} - Expect(repo.Put(ctx, g)).To(Succeed()) - Expect(g.ID).ToNot(BeEmpty()) - - byID, err := repo.Get(ctx, g.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(byID.Scopes).To(Equal(model.Scopes{"read", "password"})) - Expect(byID.LastUsedAt).To(BeNil()) - Expect(byID.LastUsedIP).To(BeEmpty()) - - byHash, err := repo.FindBySecretHash(ctx, "hash-1") - Expect(err).ToNot(HaveOccurred()) - Expect(byHash.ID).To(Equal(g.ID)) - }) - - It("returns ErrNotFound for unknown ids and hashes", func() { - _, err := repo.Get(ctx, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = repo.FindBySecretHash(ctx, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("lists and counts only the user's non-idle grants on the given epoch by lastUsedAt, never-used ones last", func() { - old := newGrant(adminUser.ID, "h-old") - old.CreatedAt = now.Add(-100 * 24 * time.Hour) - usedEarly := newGrant(adminUser.ID, "h-used-early") - usedEarly.CreatedAt = now.Add(-10 * time.Hour) - earlyUse := now.Add(-5 * time.Hour) - usedEarly.LastUsedAt = &earlyUse - usedLate := newGrant(adminUser.ID, "h-used-late") - usedLate.CreatedAt = now.Add(-10 * time.Hour) - lateUse := now.Add(-time.Hour) - usedLate.LastUsedAt = &lateUse - freshNeverUsed := newGrant(adminUser.ID, "h-fresh") // newer than both uses, but never used - other := newGrant(regularUser.ID, "h-other") - staleEpoch := newGrant(adminUser.ID, "h-stale-epoch") - staleEpoch.UserEpoch = 1 - for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other, staleEpoch} { - Expect(repo.Put(ctx, g)).To(Succeed()) - } - idleSince := now.Add(-90 * 24 * time.Hour) - - list, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 0, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(3)) - Expect([]string{list[0].ID, list[1].ID, list[2].ID}).To(Equal([]string{usedLate.ID, usedEarly.ID, freshNeverUsed.ID})) - - Expect(repo.CountForUser(ctx, adminUser.ID, 0, idleSince)).To(Equal(int64(3))) - - page, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 1, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(page).To(HaveLen(1)) - Expect(page[0].ID).To(Equal(usedEarly.ID)) - }) - - It("deletes a grant only for its owner", func() { - g := newGrant(adminUser.ID, "h-own") - Expect(repo.Put(ctx, g)).To(Succeed()) - Expect(repo.DeleteForUser(ctx, regularUser.ID, g.ID)).To(MatchError(model.ErrNotFound)) - Expect(repo.DeleteForUser(ctx, adminUser.ID, g.ID)).To(Succeed()) - _, err := repo.Get(ctx, g.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("moves epochs forward", func() { - keep := newGrant(adminUser.ID, "h-keep") - stay := newGrant(adminUser.ID, "h-stay") - Expect(repo.Put(ctx, keep)).To(Succeed()) - Expect(repo.Put(ctx, stay)).To(Succeed()) - - Expect(repo.SetEpoch(ctx, adminUser.ID, 0, 3, keep.ID)).To(Succeed()) - kept, err := repo.Get(ctx, keep.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(kept.UserEpoch).To(Equal(3)) - stayed, _ := repo.Get(ctx, stay.ID) - Expect(stayed.UserEpoch).To(Equal(0)) - - Expect(repo.SetEpoch(ctx, adminUser.ID, 3, 4, "")).To(Succeed()) - kept, _ = repo.Get(ctx, keep.ID) - Expect(kept.UserEpoch).To(Equal(4)) - }) - - It("never moves a grant that is not on fromEpoch", func() { - stale := newGrant(adminUser.ID, "h-stale") // left behind by an earlier password change - stale.UserEpoch = 1 - current := newGrant(adminUser.ID, "h-current") - current.UserEpoch = 2 - Expect(repo.Put(ctx, stale)).To(Succeed()) - Expect(repo.Put(ctx, current)).To(Succeed()) - - Expect(repo.SetEpoch(ctx, adminUser.ID, 2, 3, "")).To(Succeed()) - got, _ := repo.Get(ctx, stale.ID) - Expect(got.UserEpoch).To(Equal(1)) - got, _ = repo.Get(ctx, current.ID) - Expect(got.UserEpoch).To(Equal(3)) - }) - - It("deletes only the user's grants on an epoch before the current one", func() { - older := newGrant(adminUser.ID, "h-older") - older.UserEpoch = 1 - previous := newGrant(adminUser.ID, "h-previous") - previous.UserEpoch = 4 - current := newGrant(adminUser.ID, "h-current") - current.UserEpoch = 5 - otherUser := newGrant(regularUser.ID, "h-other-user") - otherUser.UserEpoch = 1 - for _, g := range []*model.Grant{older, previous, current, otherUser} { - Expect(repo.Put(ctx, g)).To(Succeed()) - } - - Expect(repo.DeleteStaleEpochs(ctx, adminUser.ID, 5)).To(Succeed()) - - for _, g := range []*model.Grant{older, previous} { - _, err := repo.Get(ctx, g.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - } - for _, g := range []*model.Grant{current, otherUser} { - _, err := repo.Get(ctx, g.ID) - Expect(err).ToNot(HaveOccurred()) - } - }) - - It("touches a never-used grant, then throttles until notSince passes", func() { - g := newGrant(adminUser.ID, "h-touch") - Expect(repo.Put(ctx, g)).To(Succeed()) - - Expect(repo.Touch(ctx, g.ID, "10.0.0.1", now, now.Add(-5*time.Minute))).To(Succeed()) - got, _ := repo.Get(ctx, g.ID) - Expect(got.LastUsedAt).ToNot(BeNil()) - Expect(got.LastUsedAt.UTC()).To(BeTemporally("==", now)) - Expect(got.LastUsedIP).To(Equal("10.0.0.1")) - - later := now.Add(time.Minute) - Expect(repo.Touch(ctx, g.ID, "10.0.0.2", later, later.Add(-5*time.Minute))).To(Succeed()) - got, _ = repo.Get(ctx, g.ID) - Expect(got.LastUsedIP).To(Equal("10.0.0.1")) - - muchLater := now.Add(6 * time.Minute) - Expect(repo.Touch(ctx, g.ID, "10.0.0.3", muchLater, muchLater.Add(-5*time.Minute))).To(Succeed()) - got, _ = repo.Get(ctx, g.ID) - Expect(got.LastUsedIP).To(Equal("10.0.0.3")) - }) - - It("deletes idle grants, using created_at for never-used ones", func() { - idle := newGrant(adminUser.ID, "h-idle") - idle.CreatedAt = now.Add(-100 * 24 * time.Hour) - usedRecently := newGrant(adminUser.ID, "h-used-recently") - usedRecently.CreatedAt = now.Add(-100 * 24 * time.Hour) - recentUse := now.Add(-time.Hour) - usedRecently.LastUsedAt = &recentUse - Expect(repo.Put(ctx, idle)).To(Succeed()) - Expect(repo.Put(ctx, usedRecently)).To(Succeed()) - - n, err := repo.DeleteIdle(ctx, now.Add(-90*24*time.Hour)) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - _, err = repo.Get(ctx, usedRecently.ID) - Expect(err).ToNot(HaveOccurred()) - }) - - It("deletes a user's grants when the user is deleted", func() { - users := NewUserRepository(GetDBXBuilder()) - u := model.User{ID: "grant-owner", UserName: "grant-owner", NewPassword: "pw"} - Expect(users.Put(ctx, &u)).To(Succeed()) - g := newGrant(u.ID, "h-cascade") - Expect(repo.Put(ctx, g)).To(Succeed()) - - Expect(users.Delete(request.WithUser(ctx, adminUser), u.ID)).To(Succeed()) - _, err := repo.Get(ctx, g.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - }) -}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index bf6b8995e..85da65cbc 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -93,6 +93,8 @@ func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpd "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, + "pid_album": l.PIDAlbum, + "pid_track": l.PIDTrack, }, colsToUpdate...) cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) @@ -176,6 +178,15 @@ func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error { return err } +func (r *libraryRepository) SetScannedPID(ctx context.Context, id int, pid model.PIDConfig) error { + sq := Update(r.tableName). + Set("scanned_pid_album", pid.Album). + Set("scanned_pid_track", pid.Track). + Where(Eq{"id": id}) + _, err := r.executeSQL(ctx, sq) + return err +} + func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) { query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}}) count, err := r.count(ctx, query) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 0ff470861..bf485a06f 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -270,6 +270,38 @@ var _ = Describe("LibraryRepository", func() { }) }) + Describe("PID config", func() { + It("stores the overrides, and Put never touches the scanned specs", func() { + lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"} + Expect(repo.Put(ctx, lib)).To(Succeed()) + Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed()) + + // An update coming from the REST API has no scanned specs. It must not clear them + update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"} + Expect(repo.Put(ctx, update)).To(Succeed()) + + saved, err := repo.Get(ctx, lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.PIDAlbum).To(BeEmpty()) + Expect(saved.PIDTrack).To(Equal("title")) + Expect(saved.ScannedPIDAlbum).To(Equal("folder")) + Expect(saved.ScannedPIDTrack).To(Equal("title")) + }) + + It("keeps the overrides when a partial update does not send them", func() { + lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"} + Expect(repo.Put(ctx, lib)).To(Succeed()) + + Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed()) + + saved, err := repo.Get(ctx, lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Name).To(Equal("Renamed")) + Expect(saved.PIDAlbum).To(Equal("folder")) + Expect(saved.PIDTrack).To(Equal("title")) + }) + }) + Describe("Delete", func() { var adminRepo model.LibraryRepository var artistRepo model.ArtistRepository diff --git a/persistence/persistence.go b/persistence/persistence.go index 9656be178..44e944bff 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -38,7 +37,6 @@ type SQLStore struct { plugin func() model.PluginRepository artwork func() model.ArtworkRepository artworkQueue func() model.ArtworkQueueRepository - grant func() model.GrantRepository } // Repositories are built on first use, so a transaction store only pays for the ones its block touches. @@ -66,7 +64,6 @@ func newSQLStore(db dbx.Builder) *SQLStore { plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }), artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }), artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }), - grant: sync.OnceValue(func() model.GrantRepository { return NewGrantRepository(db) }), } } @@ -158,10 +155,6 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository { return s.artworkQueue() } -func (s *SQLStore) Grant() model.GrantRepository { - return s.grant() -} - func scopeLabel(scope []string) string { if len(scope) > 0 { return scope[0] @@ -278,10 +271,6 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }), trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }), trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }), - trace(ctx, "purge idle API grants", func() error { - _, err := s.grant().DeleteIdle(ctx, time.Now().Add(-consts.APIv1GrantIdleExpiry)) - return err - }), ) if err != nil { return fmt.Errorf("tidying up database: %w", err) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index dc6134793..84fdffa4d 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -1,9 +1,7 @@ package persistence import ( - "bytes" "context" - "os" "path/filepath" "testing" "time" @@ -350,22 +348,19 @@ var _ = BeforeSuite(func() { } }) -// captureTraceLogs sends trace logs, SQL included, to a buffer for the rest of the spec. -func captureTraceLogs() *bytes.Buffer { - buf := &bytes.Buffer{} - log.SetOutput(buf) - log.SetLevel(log.LevelTrace) - DeferCleanup(func() { - log.SetOutput(os.Stderr) - log.SetLevel(log.LevelFatal) - }) - return buf -} - func GetDBXBuilder() *dbx.DB { return dbx.NewFromDB(db.Db(), db.Dialect) } +// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track. +func storedRows(playlistID string) []string { + GinkgoHelper() + var rows []string + Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id"). + Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed()) + return rows +} + // collectCursor takes the cursor's underlying func type so the named cursor types // (model.AlbumCursor, ...) infer T. func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 41b75266c..1a1b05a5c 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -281,7 +281,7 @@ func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId stri } // addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns -// the library check: every caller, including a full replace through Put, goes through it. +// the library and existence check: every caller, including a full replace through Put, goes through it. func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) { mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds) if err != nil { @@ -305,12 +305,9 @@ func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, s return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId}) } -// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked -// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. +// keepAccessible drops ids the caller cannot read or that match no track, preserving order and duplicates. +// Chunked because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) { - if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) { - return mediaFileIds, nil - } accessible := make(map[string]struct{}, len(mediaFileIds)) for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 93d37c928..b7be0588b 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -469,6 +469,26 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("removeOrphans", func() { + It("deletes rows pointing to no track and renumbers the rest", func() { + pls := model.Playlist{Name: "Orphans", OwnerID: "userid"} + Expect(repo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) }) + // Rows as an older version stored them, with an unknown id between real tracks + _, err := GetDBXBuilder().NewQuery(`INSERT INTO playlist_tracks (playlist_id, media_file_id, id) + VALUES ({:id}, '1001', 1), ({:id}, 'no-such-track', 2), ({:id}, '1002', 3)`). + Bind(dbx.Params{"id": pls.ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + Expect(repo.(*playlistRepository).removeOrphans(ctx)).To(Succeed()) + + Expect(storedRows(pls.ID)).To(Equal([]string{"1:1001", "2:1002"})) + got, err := repo.Get(ctx, pls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.SongCount).To(Equal(2)) + }) + }) + // Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist" // -- the public image route serving a share -- must elevate, or a private playlist looks gone. Describe("Exists visibility", func() { diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 392446cef..587410305 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -213,7 +213,7 @@ func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []str if err != nil || inserted == n { return inserted, err } - // The shift above reserved a slot per requested id, so ids dropped by the library filter + // The shift above reserved a slot per requested id, so ids dropped by keepAccessible // leave a hole. Close it. return inserted, r.playlistRepo.renumber(ctx, r.playlistId) } diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 119c57116..29911ebd4 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -83,12 +83,14 @@ var _ = Describe("PlaylistTrackRepository", func() { Describe("Insert", func() { var tracks model.PlaylistTrackRepository + var plsID string BeforeEach(func() { plsRepo := NewPlaylistRepository(GetDBXBuilder()) pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) + plsID = pls.ID tracks = plsRepo.Tracks(ctx, pls.ID, false) Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) @@ -123,6 +125,40 @@ var _ = Describe("PlaylistTrackRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) }) + + It("drops ids that match no track, keeping positions contiguous", func() { + Expect(tracks.Insert(ctx, []string{"no-such-track", songComeTogether.ID}, 1)).To(Equal(1)) + + Expect(storedRows(plsID)).To(Equal([]string{ + "1:" + songComeTogether.ID, "2:" + songDayInALife.ID, "3:" + songRadioactivity.ID, + })) + }) + }) + + Describe("Add", func() { + var tracks model.PlaylistTrackRepository + var plsID string + + BeforeEach(func() { + plsRepo := NewPlaylistRepository(GetDBXBuilder()) + pls := model.Playlist{Name: "Add", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) + plsID = pls.ID + tracks = plsRepo.Tracks(ctx, pls.ID, false) + }) + + It("drops ids that match no track and counts only the tracks added", func() { + Expect(tracks.Add(ctx, []string{songDayInALife.ID, "no-such-track", songComeTogether.ID, "!!not-an-id!!"})).To(Equal(2)) + + Expect(storedRows(plsID)).To(Equal([]string{"1:" + songDayInALife.ID, "2:" + songComeTogether.ID})) + }) + + It("adds nothing when no id matches a track", func() { + Expect(tracks.Add(ctx, []string{"no-such-track", "!!not-an-id!!"})).To(BeZero()) + + Expect(storedRows(plsID)).To(BeEmpty()) + }) }) Describe("Reorder", func() { diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index eac174c5b..880b315ec 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -33,26 +33,4 @@ var _ = Describe("Property Repository", func() { It("returns a default value if property does not exist", func() { Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default")) }) - - It("hides values marked as secrets from the SQL log, but still logs the property id", func() { - logs := captureTraceLogs() - insertCtx := log.WithSecrets(ctx, "inserted-secret") - Expect(pr.Put(insertCtx, "secret-prop", "inserted-secret")).To(Succeed()) - updateCtx := log.WithSecrets(ctx, "updated-secret") - Expect(pr.Put(updateCtx, "secret-prop", "updated-secret")).To(Succeed()) - - Expect(logs.String()).To(ContainSubstring("INSERT INTO property")) - Expect(logs.String()).To(ContainSubstring("UPDATE property")) - Expect(logs.String()).To(ContainSubstring("secret-prop")) - Expect(logs.String()).ToNot(ContainSubstring("inserted-secret")) - Expect(logs.String()).ToNot(ContainSubstring("updated-secret")) - }) - - It("logs the values of unmarked property writes", func() { - logs := captureTraceLogs() - Expect(pr.Put(ctx, "plain-prop", "plain-value")).To(Succeed()) - - Expect(logs.String()).To(ContainSubstring("plain-prop")) - Expect(logs.String()).To(ContainSubstring("plain-value")) - }) }) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 26f7b19d4..20b4e5125 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -119,7 +119,6 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error { u.UpdatedAt = time.Now() if u.NewPassword != "" { _ = r.encryptPassword(ctx, u) - ctx = log.WithSecrets(ctx, u.NewPassword) } values, err := toSQLArgs(*u) if err != nil { @@ -400,7 +399,6 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { key := keyTo32Bytes(conf.Server.PasswordEncryptionKey) keySum := fmt.Sprintf("%x", sha256.Sum256(key)) - ctx = log.WithSecrets(ctx, keySum) props := NewPropertyRepository(r.db) savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey) @@ -434,8 +432,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { u.NewPassword = u.Password if err := r.encryptPassword(ctx, &u); err == nil { upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) - userCtx := log.WithSecrets(ctx, u.NewPassword) - _, err = r.executeSQL(userCtx, upd) + _, err = r.executeSQL(ctx, upd) if err != nil { log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) } else { diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 156d43253..0e776fc3a 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -2,16 +2,12 @@ package persistence import ( "context" - "crypto/sha256" "errors" - "fmt" "slices" "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -21,7 +17,6 @@ import ( "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("UserRepository", func() { @@ -79,28 +74,6 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) - It("never logs the stored password, on insert or update, but still logs the user name", func() { - logs := captureTraceLogs() - storedPassword := func(id string) string { - var enc string - Expect(GetDBXBuilder().NewQuery("select password from user where id = {:id}"). - Bind(dbx.Params{"id": id}).Row(&enc)).To(Succeed()) - return enc - } - u := model.User{ID: "u-logged", UserName: "logged-user-name", NewPassword: "first-secret"} - Expect(repo.Put(ctx, &u)).To(Succeed()) - inserted := storedPassword(u.ID) - u.NewPassword = "second-secret" - Expect(repo.Put(ctx, &u)).To(Succeed()) - updated := storedPassword(u.ID) - - Expect(logs.String()).To(ContainSubstring("INSERT INTO user")) - Expect(logs.String()).To(ContainSubstring("UPDATE user")) - Expect(logs.String()).To(ContainSubstring("logged-user-name")) - for _, secret := range []string{inserted, updated, "first-secret", "second-secret"} { - Expect(logs.String()).ToNot(ContainSubstring(secret)) - } - }) It("persists and reads back the scrobble filter", func() { usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} @@ -123,33 +96,6 @@ var _ = Describe("UserRepository", func() { }) }) - Describe("initPasswordEncryptionKey", func() { - It("never logs the encryption key checksum, but still logs its property id", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PasswordEncryptionKey = "a-new-password-encryption-key" - keySum := fmt.Sprintf("%x", sha256.Sum256(keyTo32Bytes(conf.Server.PasswordEncryptionKey))) - previousKey := encKey - DeferCleanup(func() { encKey = previousKey }) - tx, err := GetDBXBuilder().Begin() - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _ = tx.Rollback() }) - _, err = tx.NewQuery("delete from user").Execute() - Expect(err).ToNot(HaveOccurred()) - txRepo := NewUserRepository(tx).(*userRepository) - Expect(txRepo.Put(ctx, &model.User{ID: "u-rekey", UserName: "rekeyed-user", NewPassword: "rekeyed-password"})).To(Succeed()) - - logs := captureTraceLogs() - Expect(txRepo.initPasswordEncryptionKey(ctx)).To(Succeed()) - - Expect(logs.String()).To(ContainSubstring("UPDATE user")) - Expect(logs.String()).To(ContainSubstring(consts.PasswordsEncryptedKey)) - Expect(logs.String()).ToNot(ContainSubstring(keySum)) - var rekeyed string - Expect(tx.NewQuery("select password from user where id = 'u-rekey'").Row(&rekeyed)).To(Succeed()) - Expect(logs.String()).ToNot(ContainSubstring(rekeyed)) - }) - }) - Describe("validatePasswordChange", func() { var loggedUser *model.User diff --git a/resources/hosting/elfhosted.svg b/resources/hosting/elfhosted.svg new file mode 100644 index 000000000..86bc86df9 --- /dev/null +++ b/resources/hosting/elfhosted.svg @@ -0,0 +1,77 @@ +Deploy on ElfHosted + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Deploy on ElfHosted diff --git a/resources/hosting/pikapods.svg b/resources/hosting/pikapods.svg new file mode 100644 index 000000000..a3865feea --- /dev/null +++ b/resources/hosting/pikapods.svg @@ -0,0 +1,25 @@ +Run on PikaPods + +Run on PikaPods Button + + + + + + + + + + + + + + + + + + diff --git a/resources/hosting/zenith.svg b/resources/hosting/zenith.svg new file mode 100644 index 000000000..0b5b11539 --- /dev/null +++ b/resources/hosting/zenith.svg @@ -0,0 +1,16 @@ +Deploy with Zenith + + + + + + + + + + + + + + + diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index f0d8a0e06..1a0c1ab8a 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -328,11 +328,22 @@ "totalDuration": "Duração", "defaultNewUsers": "Padrão para Novos Usuários", "createdAt": "Data de Criação", - "updatedAt": "Últ. Atualização" + "updatedAt": "Últ. Atualização", + "pidAlbum": "Agrupamento de álbuns", + "pidTrack": "Identificação das faixas" }, "sections": { "basic": "Informações Básicas", - "statistics": "Estatísticas" + "statistics": "Estatísticas", + "pid": "IDs Persistentes" + }, + "pid": { + "global": "Usar configuração global (%{value})", + "folder": "Pasta (um álbum por pasta)", + "custom": "Personalizado", + "spec": "Especificação do PID", + "help": "Tags e atributos que identificam um item. Consulte a sintaxe na documentação:", + "docs": "IDs Persistentes" }, "actions": { "scan": "Scanear Biblioteca", @@ -362,7 +373,9 @@ "messages": { "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.", "scanInProgress": "Scan em progresso...", - "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário" + "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário", + "pidChangeTitle": "Alterar os IDs persistentes?", + "pidChangeConfirm": "Ao salvar, os álbuns desta biblioteca serão reagrupados e as faixas serão identificadas novamente. Um scan completo da biblioteca começará imediatamente. As marcações como favoritas, as classificações e as contagens de reprodução das faixas serão mantidas. Os favoritos e as classificações dos álbuns serão transferidos para os novos álbuns quando um álbum antigo corresponder a um novo." } }, "plugin": { @@ -647,6 +660,11 @@ "options": { "theme": "Tema", "language": "Língua", + "dateFormat": "Formato de data", + "dateFormats": { + "language": "Igual à língua", + "browser": "Igual ao navegador" + }, "defaultView": "Tela inicial", "desktop_notifications": "Notificações", "lastfmScrobbling": "Enviar scrobbles para Last.fm", diff --git a/scanner/controller.go b/scanner/controller.go index 5eed6c58d..1b13c1846 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -25,7 +25,7 @@ import ( ) var ( - ErrAlreadyScanning = errors.New("already scanning") + ErrAlreadyScanning = model.ErrAlreadyScanning ) func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, @@ -304,14 +304,14 @@ func LockForMaintenance() (func(), bool) { return scanMaintenanceMux.Unlock, true } -// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted -// full scan in one of the included libraries. +// EffectiveFullScan reports whether a scan was requested as full, will resume an interrupted full scan, +// or will rescan a library in full because its PID config changed, in one of the included libraries. func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { if fullScan { return true } return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { - return library.FullScanInProgress + return library.FullScanInProgress || library.NeedsPIDRescan() }) } diff --git a/scanner/controller_test.go b/scanner/controller_test.go index bdcb99eda..974540e32 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -2,6 +2,7 @@ package scanner_test import ( "context" + "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -70,14 +71,21 @@ var _ = Describe("EffectiveFullScan", func() { var ds *tests.MockDataStore BeforeEach(func() { + pid := model.Library{}.EffectivePID() libraries := &tests.MockLibraryRepo{} libraries.SetData(model.Libraries{ - {ID: 1, FullScanInProgress: true}, - {ID: 2}, + {ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, + {ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, }) ds = &tests.MockDataStore{MockedLibrary: libraries} }) + It("detects a library that needs a full rescan for a PID change", func() { + targets := []model.ScanTarget{{LibraryID: 3, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(GinkgoT().Context(), ds, false, targets)).To(BeTrue()) + }) + It("detects an interrupted full scan in a targeted library", func() { targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 7b6a6b097..4edaecacd 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -40,6 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) + state.markFailed(lib.ID) continue } jobs = append(jobs, job) @@ -51,12 +52,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor } type scanJob struct { - lib model.Library - fs storage.MusicFS - lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library - targetFolders []string // Specific folders to scan (including all descendants) - lock sync.Mutex - numFolders atomic.Int64 + lib model.Library + fs storage.MusicFS + lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library + targetFolders []string // Specific folders to scan (including all descendants) + prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one + lock sync.Mutex + numFolders atomic.Int64 } func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { @@ -77,16 +79,32 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full return nil, fmt.Errorf("getting fs for library: %w", err) } + pid := lib.EffectivePID() + if lib.NeedsPIDRescan() { + msg := "Scanner: PID config changed, rescanning library in full" + if len(targetFolders) > 0 { + msg = "Scanner: PID config changed, rescanning target folders in full" + } + log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders, + "album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack) + fullScan = true + } + var prevAlbumPIDConf string + if lib.ScannedPIDAlbum != pid.Album { + prevAlbumPIDConf = lib.ScannedPIDAlbum + } + // Ensure FullScanInProgress reflects the current scan request. // This is important when resuming an interrupted quick scan as a full scan: // the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly. lib.FullScanInProgress = lib.FullScanInProgress || fullScan return &scanJob{ - lib: lib, - fs: fsys, - lastUpdates: lastUpdates, - targetFolders: targetFolders, + lib: lib, + fs: fsys, + lastUpdates: lastUpdates, + targetFolders: targetFolders, + prevAlbumPIDConf: prevAlbumPIDConf, }, nil } @@ -122,14 +140,13 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry { // The phaseFolders struct implements the phase interface, providing methods to produce // folder entries, process folders, persist changes to the database, and log the results. type phaseFolders struct { - jobs []*scanJob - ds model.DataStore - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early - stopWalk context.CancelCauseFunc - state *scanState - prevAlbumPIDConf string - imageChanges *imageChangeCollector + jobs []*scanJob + ds model.DataStore + ctx context.Context //nolint:containedctx // phase runs under a single scan ctx + walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early + stopWalk context.CancelCauseFunc + state *scanState + imageChanges *imageChangeCollector } func (p *phaseFolders) description() string { @@ -138,12 +155,6 @@ func (p *phaseFolders) description() string { func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { return ppl.NewProducer(func(put func(entry *folderEntry)) error { - var err error - p.prevAlbumPIDConf, err = p.ds.Property().DefaultGet(p.ctx, consts.PIDAlbumKey, "") - if err != nil { - return fmt.Errorf("getting album PID conf: %w", err) - } - // TODO Parallelize multiple job when we have multiple libraries var total int64 var totalChanged int64 @@ -173,7 +184,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { // Check if folder is outdated if folder.isOutdated() { - if !p.state.fullScan { + if !folder.job.lib.FullScanInProgress { // Ancestor folders need a row even with no files of their own: artwork // resolution climbs them, and an image added later needs a state to diff. if folder.isEmpty() && folder.isNew() { @@ -239,7 +250,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { for afPath, af := range entry.audioFiles { fullPath := path.Join(entry.path, afPath) dbTrack, foundInDB := dbTracks[fullPath] - if !foundInDB || p.state.fullScan { + if !foundInDB || entry.job.lib.FullScanInProgress { filesToImport[fullPath] = dbTrack } else { info, err := af.Info() @@ -289,18 +300,18 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string } for filePath, info := range allInfo { md := metadata.New(filePath, info) - track := md.ToMediaFile(entry.job.lib.ID, entry.id) + track := md.ToMediaFile(entry.job.lib, entry.id) tracks = append(tracks, track) for _, t := range track.Tags.FlattenAll() { uniqueTags[t.ID] = t } // Keep track of any album ID changes, to reassign annotations later - prevAlbumID := "" + prevAlbumID := track.AlbumID if prev := toImport[filePath]; prev != nil { prevAlbumID = prev.AlbumID - } else { - prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf) + } else if entry.job.prevAlbumPIDConf != "" { + prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf) } _, ok := entry.albumIDMap[track.AlbumID] if prevAlbumID != track.AlbumID && !ok { @@ -453,7 +464,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en if len(queueItems) > 0 { queue := tx.ArtworkQueue() enqueue := queue.Enqueue - if p.state.fullScan { + if entry.job.lib.FullScanInProgress { enqueue = queue.EnqueueIfMissing } if err := enqueue(ctx, queueItems...); err != nil { diff --git a/scanner/scanner.go b/scanner/scanner.go index cd2fe3c8d..305c443f4 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -4,13 +4,11 @@ import ( "context" "fmt" "maps" - "path/filepath" "slices" "sync/atomic" "time" ppl "github.com/google/go-pipeline/pkg/pipeline" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -32,6 +30,7 @@ type scanState struct { libraries model.Libraries // Store libraries list for consistency across phases targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection + failedLibs map[int]bool // Libraries that could not be scanned in this run } func (s *scanState) sendProgress(info *ProgressInfo) { @@ -48,29 +47,15 @@ func (s *scanState) sendWarning(msg string) { s.sendProgress(&ProgressInfo{Warning: msg}) } -func (s *scanState) sendError(err error) { - s.sendProgress(&ProgressInfo{Error: err.Error()}) +func (s *scanState) markFailed(libID int) { + if s.failedLibs == nil { + s.failedLibs = map[int]bool{} + } + s.failedLibs[libID] = true } -// libraryRelativePath rebases an absolute scan target path onto the library root, since the -// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside -// the library root, are returned unchanged. -func libraryRelativePath(libPath, folderPath string) string { - if !filepath.IsAbs(folderPath) { - return folderPath - } - // The library root may be relative (e.g. the default "./music"); it must be made absolute - // to match against an absolute target, and it resolves against the same cwd as the scanner's fs. - absLib, err := filepath.Abs(libPath) - if err != nil { - return folderPath - } - rel, err := filepath.Rel(absLib, folderPath) - if err != nil || !filepath.IsLocal(rel) { - return folderPath - } - // The scanner's fs.FS is an io/fs, which always uses forward slashes. - return filepath.ToSlash(rel) +func (s *scanState) sendError(err error) { + s.sendProgress(&ProgressInfo{Error: err.Error()}) } func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { @@ -104,7 +89,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] }) for _, target := range targets { - folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath) + folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath) if folderPath == "" { folderPath = "." } @@ -137,6 +122,10 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // if there was a full scan in progress, force a full scan if !state.fullScan { for _, lib := range state.libraries { + // A pending PID rescan already restarts in full through its own job + if lib.NeedsPIDRescan() { + continue + } if lib.FullScanInProgress { log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) state.fullScan = true @@ -215,10 +204,13 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt var successfulLibs []model.Library for _, lib := range state.libraries { - if lib.LastScanStartedAt.IsZero() { + // A library with a changed PID config restarts its scan: resuming would skip the folders that + // the interrupted scan already processed with the old config + pidRescan := lib.NeedsPIDRescan() + if lib.LastScanStartedAt.IsZero() || pidRescan { // This is a new scan - mark it as started err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan) + return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan || pidRescan) }, "scanner: begin library scan") if err != nil { log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) @@ -340,11 +332,12 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil { return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) } - if err := tx.Property().Put(ctx, consts.PIDTrackKey, conf.Server.PID.Track); err != nil { - return fmt.Errorf("updating track PID conf: %w", err) - } - if err := tx.Property().Put(ctx, consts.PIDAlbumKey, conf.Server.PID.Album); err != nil { - return fmt.Errorf("updating album PID conf: %w", err) + // A selective scan covers only part of the library, so the rest may still use the old PID + // config. A library that could not be scanned did not apply it either. + if !state.isSelectiveScan() && !state.failedLibs[lib.ID] { + if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil { + return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err) + } } if state.changesDetected.Load() { log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go index 0778bd6ec..e8abb7c7d 100644 --- a/scanner/scanner_internal_test.go +++ b/scanner/scanner_internal_test.go @@ -4,8 +4,6 @@ package scanner import ( "context" "errors" - "os" - "path/filepath" "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" @@ -13,43 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("libraryRelativePath", func() { - // Paths are built with filepath so the "absolute" cases stay absolute on every OS - // (a Unix-style "/foo" is not absolute on Windows). - libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) - outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) - - It("returns a relative path unchanged", func() { - Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) - }) - - It("rebases an absolute target when the library root is relative", func() { - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) - }) - - It("rebases an absolute path that equals the library root to '.'", func() { - Expect(libraryRelativePath(libRoot, libRoot)).To(Equal(".")) - }) - - It("rebases an absolute path under the library root", func() { - Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("handles a trailing slash on the library path", func() { - Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("leaves an absolute path outside the library root unchanged", func() { - Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside)) - }) - - It("returns an empty path unchanged", func() { - Expect(libraryRelativePath(libRoot, "")).To(Equal("")) - }) -}) - type mockPhase struct { num int produceFunc func() ppl.Producer[int] diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 546baf756..f6634c875 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -835,4 +835,170 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(lastError).To(BeEmpty()) }) }) + + Context("Per-library PID config", func() { + albumsOf := func(libID int) model.Albums { + // The mock datastore's GC is a no-op, so run the real one to purge the albums left + // empty by a regroup, as the scanner does in production + Expect(ds.RealDS.GC(ctx)).To(Succeed()) + albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + Filters: squirrel.Eq{"library_id": libID, "missing": false}, + Sort: "name", + }) + Expect(err).ToNot(HaveOccurred()) + return albums + } + trackByTitle := func(libID int, title string) model.MediaFile { + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + Filters: squirrel.Eq{"library_id": libID, "title": title}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(1)) + return mfs[0] + } + rockTitles := func() []string { + mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib1.ID}}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(mfs, func(mf model.MediaFile) string { return mf.Title }) + } + // changeRockInDB edits the rock track in the DB only. A full rescan of the rock library would + // restore the title from the file tags, a quick scan leaves it alone. + changeRockInDB := func() { + _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Changed In DB' where library_id = ?", lib1.ID) + Expect(err).ToNot(HaveOccurred()) + } + // changeBlueTrainInDB does the same for one jazz track + changeBlueTrainInDB := func() { + _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Blue Train In DB' where library_id = ? and title = 'Blue Train'", lib2.ID) + Expect(err).ToNot(HaveOccurred()) + } + + BeforeEach(func() { + beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969}) + _ = createFS("rock", fstest.MapFS{ + "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), + }) + + miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959}) + coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960}) + blueTrain := template(_t{"albumartist": "John Coltrane", "album": "Blue Train", "year": 1957}) + _ = createFS("jazz", fstest.MapFS{ + "Loose/01 - So What.mp3": miles(track(1, "So What")), + "Loose/02 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), + "Coltrane/Blue Train/01 - Blue Train.mp3": blueTrain(track(1, "Blue Train")), + }) + }) + + It("regroups only the library whose PID config changed, keeping annotations", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + Expect(albumsOf(lib2.ID)).To(HaveLen(3)) + + // Star Blue Train, to check the star follows the album to its new ID + oldBlueTrain := trackByTitle(lib2.ID, "Blue Train") + Expect(ds.Album().SetStar(ctx, true, oldBlueTrain.AlbumID)).To(Succeed()) + changeRockInDB() + + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Jazz is grouped by folder now: "Loose" is one album + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + Expect(trackByTitle(lib2.ID, "So What").AlbumID).To(Equal(trackByTitle(lib2.ID, "Giant Steps").AlbumID)) + + newBlueTrain := trackByTitle(lib2.ID, "Blue Train") + Expect(newBlueTrain.AlbumID).ToNot(Equal(oldBlueTrain.AlbumID)) + album, err := ds.Album().Get(ctx, newBlueTrain.AlbumID) + Expect(err).ToNot(HaveOccurred()) + Expect(album.Starred).To(BeTrue()) + + // Rock only got a quick scan + Expect(rockTitles()).To(ConsistOf("Changed In DB")) + + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) + Expect(jazz.PIDChanged()).To(BeFalse()) + rock, err := ds.Library().Get(ctx, lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rock.PIDChanged()).To(BeFalse()) + }) + + It("rescans only libraries that follow the global config", func() { + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, true)).To(Succeed()) + changeRockInDB() + changeBlueTrainInDB() + + conf.Server.PID.Album = "album" + Expect(runScanner(ctx, false)).To(Succeed()) + + // Rock follows the global config, so it was rescanned in full and its title restored + Expect(rockTitles()).To(ConsistOf("Come Together")) + // Jazz has its own override, so it only got a quick scan + trackByTitle(lib2.ID, "Blue Train In DB") + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) + }) + + It("restarts an interrupted scan when the PID config changed meanwhile", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Simulate a quick scan of jazz that was interrupted after it had processed every folder: + // the folders were updated after the (old) scan start time + _, err := db.Db().ExecContext(ctx, "update library set last_scan_started_at = ?, full_scan_in_progress = false where id = ?", + time.Now().Add(-time.Hour), lib2.ID) + Expect(err).ToNot(HaveOccurred()) + + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Every folder was revisited with the new config + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + }) + + It("does not turn an interrupted PID rescan into a full scan of every library", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + changeRockInDB() + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + + // Simulate a PID full scan of jazz that was interrupted + Expect(ds.Library().ScanBegin(ctx, lib2.ID, true)).To(Succeed()) + Expect(runScanner(ctx, false)).To(Succeed()) + + // Rock only got a quick scan, jazz was rescanned with the new config + Expect(rockTitles()).To(ConsistOf("Changed In DB")) + Expect(albumsOf(lib2.ID)).To(HaveLen(2)) + }) + + It("does not record the PID config for a library that could not be scanned", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + broken := model.Library{Name: "Broken", Path: "unregistered:///music", PIDAlbum: "folder"} + Expect(ds.Library().Put(ctx, &broken)).To(Succeed()) + + // The scan reports an error for the broken library, and still finishes the others + _ = runScanner(ctx, false) + + reloaded, err := ds.Library().Get(ctx, broken.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.PIDChanged()).To(BeTrue()) + }) + + It("does not record the PID config after a selective scan", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + lib2.PIDAlbum = "folder" + Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: lib2.ID, FolderPath: "Loose"}}) + Expect(err).ToNot(HaveOccurred()) + + jazz, err := ds.Library().Get(ctx, lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazz.PIDChanged()).To(BeTrue()) + }) + }) }) diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 1864a6a44..d39cad5f6 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -43,6 +43,13 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<- continue } + // A full walk never descends into symlinked folders when following is disabled, so a + // target reached through one (e.g. a watcher event for a new link) is skipped too. + if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) { + log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath) + continue + } + // Create checker and push patterns from root to this folder checker := newIgnoreChecker(job.fs) err = checker.PushAllParents(ctx, folderPath) @@ -225,6 +232,18 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, return fileInfo.IsDir(), nil } +// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link. +// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected. +func isSymlinkedPath(fsys fs.FS, folderPath string) bool { + for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) { + info, err := fs.Lstat(fsys, p) + if err == nil && info.Mode()&fs.ModeSymlink != 0 { + return true + } + } + return false +} + const maxSymlinkHops = 40 // resolveEntryName returns the name to classify the entry by, and whether to diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 43939e5c2..0c2aba3e0 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "io/fs" + "maps" "os" "path/filepath" + "slices" "testing/fstest" "github.com/navidrome/navidrome/conf" @@ -260,6 +262,44 @@ var _ = Describe("walk_dir_tree", func() { // Folders not in targets should remain in lastUpdates Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3"))) }) + + // #6292: a watcher event for a new folder symlink makes the link itself a scan target + Context("symlinked target folders (production local storage FS)", func() { + BeforeEach(func() { + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed()) + job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}} + }) + + walkTargets := func(targets ...string) map[string]*folderEntry { + results, err := walkDirTree(ctx, job, targets...) + Expect(err).ToNot(HaveOccurred()) + folders := map[string]*folderEntry{} + for folder := range results { + folders[folder.path] = folder + } + return folders + } + + DescribeTable("with FollowSymlinks disabled", + func(target string, expected ...string) { + conf.Server.Scanner.FollowSymlinks = false + Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected)) + }, + Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"), + Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"), + Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"), + ) + + It("walks a symlinked target when FollowSymlinks is enabled", func() { + conf.Server.Scanner.FollowSymlinks = true + folders := walkTargets("Wolfgang Amadeus Mozart") + Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1")) + Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3")) + }) + }) }) }) @@ -433,8 +473,8 @@ var _ = Describe("walk_dir_tree", func() { }) // Regression for #5752: the production localFS must resolve file symlinks. - // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based - // resolution is not available and full OS-level resolution is required. + // fs.ReadLink-based resolution can't follow targets outside the library + // root, so full OS-level resolution is required. Context("production local storage FS", func() { var libRoot string var musicFS storage.MusicFS @@ -460,12 +500,7 @@ var _ = Describe("walk_dir_tree", func() { Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed()) Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed()) - u, err := storage.LocalPathToURL(libRoot) - Expect(err).ToNot(HaveOccurred()) - s, err := storage.For(u.String()) - Expect(err).ToNot(HaveOccurred()) - musicFS, err = s.FS() - Expect(err).ToNot(HaveOccurred()) + musicFS = newLocalMusicFS(libRoot) }) walkRoot := func() *folderEntry { @@ -700,6 +735,17 @@ func getDirEntry(baseDir, name string) os.DirEntry { panic(fmt.Sprintf("Could not find %s in %s", name, baseDir)) } +// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot +func newLocalMusicFS(libRoot string) storage.MusicFS { + u, err := storage.LocalPathToURL(libRoot) + Expect(err).ToNot(HaveOccurred()) + s, err := storage.For(u.String()) + Expect(err).ToNot(HaveOccurred()) + musicFS, err := s.FS() + Expect(err).ToNot(HaveOccurred()) + return musicFS +} + // mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks type mockMusicFS struct { storage.MusicFS diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 6acd95cfe..a75de3d8d 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -1,53 +1,32 @@ package apiv1 import ( - "cmp" "errors" "net/http" "runtime/debug" "slices" "strings" - "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/core/apiauth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) -const maxBodyBytes = 1 << 20 - type Router struct { http.Handler - ds model.DataStore - auth *apiauth.Service + ds model.DataStore } func New(ds model.DataStore) *Router { - rt := &Router{ds: ds, auth: apiauth.New(ds)} + rt := &Router{ds: ds} rt.Handler = rt.routes() return rt } -var gateRulesV1 = gateRules{ - limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true}, - noScope: map[string]bool{"getCapabilities": true}, - noStore: map[string]bool{"login": true, "setupFirstAdmin": true}, -} - func (rt *Router) routes() http.Handler { r := chi.NewRouter() - doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) - if err != nil { - log.Fatal("API v1: cannot load the embedded OpenAPI spec", err) - } - g, err := newGate(doc, r, rt.auth, gateRulesV1) - if err != nil { - log.Fatal("API v1: the embedded OpenAPI spec breaks the security rules", err) - } - r.Use(referenceIDMiddleware, problemRecoverer, headAsGet(r), middleware.RequestSize(maxBodyBytes), g.handler) + r.Use(problemRecoverer, headAsGet(r)) r.NotFound(func(w http.ResponseWriter, req *http.Request) { writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint") }) @@ -61,18 +40,11 @@ func (rt *Router) routes() http.Handler { strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{ RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { - if tooLarge(err) { - writeProblem(w, req, ClientError(err, tooLargeDetail)) - return - } - writeProblemStatus(w, req, http.StatusBadRequest, ProblemCodeValidation, "request body is not valid JSON") + writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error()) }, ResponseErrorHandlerFunc: writeProblem, }) HandlerWithOptions(strict, ChiServerOptions{BaseRouter: r, ErrorHandlerFunc: bindingErrorHandler}) - if err := g.checkRoutes(); err != nil { - log.Fatal("API v1: routes and the embedded OpenAPI spec disagree", err) - } return r } @@ -118,18 +90,9 @@ func headAsGet(mux chi.Routes) func(http.Handler) http.Handler { } } -// routeMethod is the method chi dispatches on, which headAsGet sets to GET for a HEAD only GET serves. -func routeMethod(req *http.Request) string { - if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RouteMethod != "" { - return rctx.RouteMethod - } - return req.Method -} - -// routePath must pick the same path chi's routeHTTP dispatches on, or the gate could vet a different route. func routePath(req *http.Request) string { if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { return rctx.RoutePath } - return cmp.Or(req.URL.RawPath, req.URL.Path, "/") + return req.URL.Path } diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index 9afd22465..4bffd47fc 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -7,29 +7,21 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "net/http" - "time" "github.com/go-chi/chi/v5" - "github.com/oapi-codegen/runtime" ) // Defines values for ProblemCode. const ( - ProblemCodeForbidden ProblemCode = "forbidden" - ProblemCodeInsufficientScope ProblemCode = "insufficient_scope" - ProblemCodeInternal ProblemCode = "internal" - ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" - ProblemCodeNotFound ProblemCode = "not_found" - ProblemCodePasswordManagedExternally ProblemCode = "password_managed_externally" - ProblemCodePayloadTooLarge ProblemCode = "payload_too_large" - ProblemCodeRateLimited ProblemCode = "rate_limited" - ProblemCodeSetupComplete ProblemCode = "setup_complete" - ProblemCodeUnauthorized ProblemCode = "unauthorized" - ProblemCodeUnavailable ProblemCode = "unavailable" - ProblemCodeValidation ProblemCode = "validation" + ProblemCodeForbidden ProblemCode = "forbidden" + ProblemCodeInternal ProblemCode = "internal" + ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" + ProblemCodeNotFound ProblemCode = "not_found" + ProblemCodeUnauthorized ProblemCode = "unauthorized" + ProblemCodeUnavailable ProblemCode = "unavailable" + ProblemCodeValidation ProblemCode = "validation" ) // Valid indicates whether the value is a known member of the ProblemCode enum. @@ -37,22 +29,12 @@ func (e ProblemCode) Valid() bool { switch e { case ProblemCodeForbidden: return true - case ProblemCodeInsufficientScope: - return true case ProblemCodeInternal: return true case ProblemCodeMethodNotAllowed: return true case ProblemCodeNotFound: return true - case ProblemCodePasswordManagedExternally: - return true - case ProblemCodePayloadTooLarge: - return true - case ProblemCodeRateLimited: - return true - case ProblemCodeSetupComplete: - return true case ProblemCodeUnauthorized: return true case ProblemCodeUnavailable: @@ -64,192 +46,32 @@ func (e ProblemCode) Valid() bool { } } -// Defines values for Scope. +// Defines values for ServerInfoLoginMethods. const ( - ScopeAll Scope = "all" - ScopePassword Scope = "password" - ScopeRead Scope = "read" + ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" ) -// Valid indicates whether the value is a known member of the Scope enum. -func (e Scope) Valid() bool { +// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum. +func (e ServerInfoLoginMethods) Valid() bool { switch e { - case ScopeAll: - return true - case ScopePassword: - return true - case ScopeRead: + case ServerInfoLoginMethodsPassword: return true default: return false } } -// AuthUser The user a grant belongs to. -type AuthUser struct { - // Id User id. - Id string `json:"id"` - - // IsAdmin Whether the user is an administrator. - IsAdmin bool `json:"isAdmin"` - - // Name Display name. - Name string `json:"name"` - - // PasswordChangeable Whether `POST /auth/password` can change this user's password. Clients hide "change password" when false. - PasswordChangeable bool `json:"passwordChangeable"` - - // UserName Login name. - UserName string `json:"userName"` -} - -// Capabilities Capability modules this server implements, keyed by module. Keys are optional; a missing key means the -// module is not implemented. New modules are added as new optional keys. These are server facts, not what -// the calling grant may use. -type Capabilities struct { - // Core The mandatory core module. - Core *CoreCapability `json:"core,omitempty"` - - // Password The password login module (login, first-admin setup, password change). - Password *PasswordCapability `json:"password,omitempty"` -} - -// CoreCapability The mandatory core module. -type CoreCapability struct { - // Version Module version. Bumped only on semantic change. - Version int `json:"version"` -} - -// CredentialsRequest Username, password and client description for a login or first-admin setup. -type CredentialsRequest struct { - // Client Name of the client app. - Client string `json:"client"` - - // ClientVersion Version of the client app. - ClientVersion *string `json:"clientVersion,omitempty"` - - // Name Label for this grant. Defaults to `client`. - Name *string `json:"name,omitempty"` - - // Password Password. - Password string `json:"password"` - - // Scopes Scopes the grant may hold. Omit for `all`. - Scopes *[]ScopeRequest `json:"scopes,omitempty"` - - // Username Login name. - Username string `json:"username"` -} - -// Grant A long-lived grant held by one client of one user. -type Grant struct { - // Client Name of the client app that holds the grant. - Client string `json:"client"` - - // ClientVersion Version of the client app, when it sent one. - ClientVersion *string `json:"clientVersion"` - - // CreatedAt When the grant was created. - CreatedAt time.Time `json:"createdAt"` - - // Current True for the grant that made this request. - Current bool `json:"current"` - - // Id Grant id. - Id string `json:"id"` - - // LastUsedAt When the grant was last used, at a coarse granularity. Null until first use. - LastUsedAt *time.Time `json:"lastUsedAt"` - - // LastUsedIp Client IP of the last use. Null until first use. - LastUsedIp *string `json:"lastUsedIp"` - - // Name Label shown to the user. - Name string `json:"name"` - - // Provider How the grant was created, for example `password` or `setup`. Free-form; new values may appear. - Provider string `json:"provider"` - - // Scopes Scopes this grant carries. - Scopes []Scope `json:"scopes"` -} - -// GrantCreated Returned by every login method. The secret is shown only here; store it and never parse it. -type GrantCreated struct { - // Grant The new grant. - Grant Grant `json:"grant"` - - // Secret Opaque grant secret. Send it as `Authorization: Bearer `. - Secret string `json:"secret"` - - // User The user the grant belongs to. - User AuthUser `json:"user"` -} - -// GrantList A page of the caller's grants. -type GrantList struct { - // Items Grants on this page, by last use, most recent first; never-used grants last. - Items []Grant `json:"items"` - - // Limit Maximum number of items in this page. - Limit int `json:"limit"` - - // Offset Zero-based index of the first returned item. - Offset int `json:"offset"` - - // Total Total number of grants. - Total int `json:"total"` -} - -// LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered. -// Keys are optional on purpose: discovery is read by clients of any version against servers of any -// version, so new methods are added as new optional keys. Clients ignore keys they do not know. -type LoginMethods struct { - // Password Username and password login (`POST /auth/login`). No settings yet. - Password *PasswordLoginMethod `json:"password,omitempty"` -} - -// LogoutResponse Result of a logout. -type LogoutResponse struct { - // LogoutUrl Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do. - LogoutUrl *string `json:"logoutUrl"` -} - -// PasswordCapability The password login module (login, first-admin setup, password change). -type PasswordCapability struct { - // Version Module version. Bumped only on semantic change. - Version int `json:"version"` -} - -// PasswordChangeRequest Change the caller's own password. -type PasswordChangeRequest struct { - // CurrentPassword The current password. - CurrentPassword string `json:"currentPassword"` - - // NewPassword The new password. - NewPassword string `json:"newPassword"` - - // RevokeOtherGrants Revoke every other grant of the user. The calling grant always survives. Default true. - RevokeOtherGrants *bool `json:"revokeOtherGrants,omitempty"` -} - -// PasswordLoginMethod Username and password login (`POST /auth/login`). No settings yet. -type PasswordLoginMethod = map[string]interface{} - // Problem RFC 9457 problem details, returned for every 4xx and 5xx response. type Problem struct { // Code Machine-readable error code, and the value clients switch on. New codes may be added. Code ProblemCode `json:"code"` - // Detail Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients. + // Detail Human-readable explanation specific to this occurrence. Omitted for internal errors. Detail *string `json:"detail,omitempty"` // Errors Per-field failures. Present only when `code` is `validation`. Errors *[]ValidationError `json:"errors,omitempty"` - // ReferenceId Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request. - ReferenceId *string `json:"referenceId,omitempty"` - // Status HTTP status code of this response. Status int `json:"status"` @@ -265,19 +87,10 @@ type Problem struct { // ProblemCode Machine-readable error code, and the value clients switch on. New codes may be added. type ProblemCode string -// Scope A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on -// grants and means every scope the user is entitled to, now and in future releases. New scopes may be added. -type Scope string - -// ScopeRequest A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working. -type ScopeRequest = string - // ServerInfo Public server description. Everything an add-server screen needs before login. type ServerInfo struct { - // LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered. - // Keys are optional on purpose: discovery is read by clients of any version against servers of any - // version, so new methods are added as new optional keys. Clients ignore keys they do not know. - LoginMethods LoginMethods `json:"loginMethods"` + // LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. + LoginMethods []ServerInfoLoginMethods `json:"loginMethods"` // Name Human-readable server product name. Name string `json:"name"` @@ -292,6 +105,9 @@ type ServerInfo struct { SpecVersion string `json:"specVersion"` } +// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. +type ServerInfoLoginMethods string + // ValidationError One field-level validation failure. type ValidationError struct { // Field Name of the offending query parameter, path parameter, or body field (dotted for nested). @@ -301,77 +117,11 @@ type ValidationError struct { Message string `json:"message"` } -// LimitParam defines model for limit. -type LimitParam = int - -// OffsetParam defines model for offset. -type OffsetParam = int - -// BadRequest RFC 9457 problem details, returned for every 4xx and 5xx response. -type BadRequest = Problem - -// Conflict RFC 9457 problem details, returned for every 4xx and 5xx response. -type Conflict = Problem - -// Forbidden RFC 9457 problem details, returned for every 4xx and 5xx response. -type Forbidden = Problem - // InternalError RFC 9457 problem details, returned for every 4xx and 5xx response. type InternalError = Problem -// NotFound RFC 9457 problem details, returned for every 4xx and 5xx response. -type NotFound = Problem - -// PayloadTooLarge RFC 9457 problem details, returned for every 4xx and 5xx response. -type PayloadTooLarge = Problem - -// TooManyRequests RFC 9457 problem details, returned for every 4xx and 5xx response. -type TooManyRequests = Problem - -// Unauthorized RFC 9457 problem details, returned for every 4xx and 5xx response. -type Unauthorized = Problem - -// ListGrantsParams defines parameters for ListGrants. -type ListGrantsParams struct { - // OffsetParam Zero-based index of the first item to return. - OffsetParam *OffsetParam `form:"offset,omitempty" json:"offset,omitempty"` - - // LimitParam Maximum number of items to return. - LimitParam *LimitParam `form:"limit,omitempty" json:"limit,omitempty"` -} - -// LoginJSONRequestBody defines body for Login for application/json ContentType. -type LoginJSONRequestBody = CredentialsRequest - -// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType. -type ChangePasswordJSONRequestBody = PasswordChangeRequest - -// SetupFirstAdminJSONRequestBody defines body for SetupFirstAdmin for application/json ContentType. -type SetupFirstAdminJSONRequestBody = CredentialsRequest - // ServerInterface represents all server handlers. type ServerInterface interface { - // ListGrants List my grants - // (GET /auth/grants) - ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) - // RevokeGrant Revoke one of my grants - // (DELETE /auth/grants/{id}) - RevokeGrant(w http.ResponseWriter, r *http.Request, id string) - // Login Log in with a password - // (POST /auth/login) - Login(w http.ResponseWriter, r *http.Request) - // Logout Log out - // (POST /auth/logout) - Logout(w http.ResponseWriter, r *http.Request) - // ChangePassword Change my password - // (POST /auth/password) - ChangePassword(w http.ResponseWriter, r *http.Request) - // SetupFirstAdmin Create the first admin - // (POST /auth/setup) - SetupFirstAdmin(w http.ResponseWriter, r *http.Request) - // GetCapabilities List implemented capability modules - // (GET /capabilities) - GetCapabilities(w http.ResponseWriter, r *http.Request) // GetServerInfo Describe the server // (GET /server) GetServerInfo(w http.ResponseWriter, r *http.Request) @@ -381,48 +131,6 @@ type ServerInterface interface { type Unimplemented struct{} -// ListGrants List my grants -// (GET /auth/grants) -func (_ Unimplemented) ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) { - w.WriteHeader(http.StatusNotImplemented) -} - -// RevokeGrant Revoke one of my grants -// (DELETE /auth/grants/{id}) -func (_ Unimplemented) RevokeGrant(w http.ResponseWriter, r *http.Request, id string) { - w.WriteHeader(http.StatusNotImplemented) -} - -// Login Log in with a password -// (POST /auth/login) -func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// Logout Log out -// (POST /auth/logout) -func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// ChangePassword Change my password -// (POST /auth/password) -func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// SetupFirstAdmin Create the first admin -// (POST /auth/setup) -func (_ Unimplemented) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// GetCapabilities List implemented capability modules -// (GET /capabilities) -func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - // GetServerInfo Describe the server // (GET /server) func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -438,148 +146,6 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler -// ListGrants operation middleware -func (siw *ServerInterfaceWrapper) ListGrants(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // Parameter object where we will unmarshal all parameters from the context - var params ListGrantsParams - - // ------------- Optional query parameter "offset" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "offset", r.URL.Query(), ¶ms.OffsetParam, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "offset"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err}) - } - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.LimitParam, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) - } - return - } - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListGrants(w, r, params) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// RevokeGrant operation middleware -func (siw *ServerInterfaceWrapper) RevokeGrant(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id string - - err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: r.URL.RawPath == ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) - return - } - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.RevokeGrant(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// Login operation middleware -func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.Login(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// Logout operation middleware -func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.Logout(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// ChangePassword operation middleware -func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ChangePassword(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// SetupFirstAdmin operation middleware -func (siw *ServerInterfaceWrapper) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.SetupFirstAdmin(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// GetCapabilities operation middleware -func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetCapabilities(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - // GetServerInfo operation middleware func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -707,27 +273,6 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl ErrorHandlerFunc: options.ErrorHandlerFunc, } - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/auth/grants", wrapper.ListGrants) - }) - r.Group(func(r chi.Router) { - r.Delete(options.BaseURL+"/auth/grants/{id}", wrapper.RevokeGrant) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/auth/login", wrapper.Login) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/auth/logout", wrapper.Logout) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/auth/password", wrapper.ChangePassword) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/auth/setup", wrapper.SetupFirstAdmin) - }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities) - }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) }) @@ -735,738 +280,8 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl return r } -type BadRequestApplicationProblemPlusJSONResponse Problem - -type ConflictApplicationProblemPlusJSONResponse Problem - -type ForbiddenResponseHeaders struct { - WWWAuthenticate *string -} -type ForbiddenApplicationProblemPlusJSONResponse struct { - Body Problem - - Headers ForbiddenResponseHeaders -} - type InternalErrorApplicationProblemPlusJSONResponse Problem -type NotFoundApplicationProblemPlusJSONResponse Problem - -type PayloadTooLargeApplicationProblemPlusJSONResponse Problem - -type TooManyRequestsResponseHeaders struct { - RetryAfter *int -} -type TooManyRequestsApplicationProblemPlusJSONResponse struct { - Body Problem - - Headers TooManyRequestsResponseHeaders -} - -type UnauthorizedResponseHeaders struct { - WWWAuthenticate *string -} -type UnauthorizedApplicationProblemPlusJSONResponse struct { - Body Problem - - Headers UnauthorizedResponseHeaders -} - -type ListGrantsRequestObject struct { - Params ListGrantsParams -} - -type ListGrantsResponseObject interface { - VisitListGrantsResponse(w http.ResponseWriter) error -} - -type ListGrants200JSONResponse GrantList - -func (response ListGrants200JSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - _, err := buf.WriteTo(w) - return err -} - -type ListGrants400ApplicationProblemPlusJSONResponse struct { - BadRequestApplicationProblemPlusJSONResponse -} - -func (response ListGrants400ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - _, err := buf.WriteTo(w) - return err -} - -type ListGrants401ApplicationProblemPlusJSONResponse struct { - UnauthorizedApplicationProblemPlusJSONResponse -} - -func (response ListGrants401ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(401) - _, err := buf.WriteTo(w) - return err -} - -type ListGrants403ApplicationProblemPlusJSONResponse struct { - ForbiddenApplicationProblemPlusJSONResponse -} - -func (response ListGrants403ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(403) - _, err := buf.WriteTo(w) - return err -} - -type ListGrants500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response ListGrants500ApplicationProblemPlusJSONResponse) VisitListGrantsResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - -type RevokeGrantRequestObject struct { - Id string `json:"id"` -} - -type RevokeGrantResponseObject interface { - VisitRevokeGrantResponse(w http.ResponseWriter) error -} - -type RevokeGrant204Response struct { -} - -func (response RevokeGrant204Response) VisitRevokeGrantResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type RevokeGrant400ApplicationProblemPlusJSONResponse struct { - BadRequestApplicationProblemPlusJSONResponse -} - -func (response RevokeGrant400ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - _, err := buf.WriteTo(w) - return err -} - -type RevokeGrant401ApplicationProblemPlusJSONResponse struct { - UnauthorizedApplicationProblemPlusJSONResponse -} - -func (response RevokeGrant401ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(401) - _, err := buf.WriteTo(w) - return err -} - -type RevokeGrant403ApplicationProblemPlusJSONResponse struct { - ForbiddenApplicationProblemPlusJSONResponse -} - -func (response RevokeGrant403ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(403) - _, err := buf.WriteTo(w) - return err -} - -type RevokeGrant404ApplicationProblemPlusJSONResponse struct { - NotFoundApplicationProblemPlusJSONResponse -} - -func (response RevokeGrant404ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(404) - _, err := buf.WriteTo(w) - return err -} - -type RevokeGrant500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response RevokeGrant500ApplicationProblemPlusJSONResponse) VisitRevokeGrantResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - -type LoginRequestObject struct { - Body *LoginJSONRequestBody -} - -type LoginResponseObject interface { - VisitLoginResponse(w http.ResponseWriter) error -} - -type Login200ResponseHeaders struct { - CacheControl *string -} - -type Login200JSONResponse struct { - Body GrantCreated - Headers Login200ResponseHeaders -} - -func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - if response.Headers.CacheControl != nil { - w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) - } - w.WriteHeader(200) - _, err := buf.WriteTo(w) - return err -} - -type Login400ApplicationProblemPlusJSONResponse struct { - BadRequestApplicationProblemPlusJSONResponse -} - -func (response Login400ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - _, err := buf.WriteTo(w) - return err -} - -type Login401ApplicationProblemPlusJSONResponse struct { - UnauthorizedApplicationProblemPlusJSONResponse -} - -func (response Login401ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(401) - _, err := buf.WriteTo(w) - return err -} - -type Login413ApplicationProblemPlusJSONResponse struct { - PayloadTooLargeApplicationProblemPlusJSONResponse -} - -func (response Login413ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(413) - _, err := buf.WriteTo(w) - return err -} - -type Login429ApplicationProblemPlusJSONResponse struct { - TooManyRequestsApplicationProblemPlusJSONResponse -} - -func (response Login429ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.RetryAfter != nil { - w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) - } - w.WriteHeader(429) - _, err := buf.WriteTo(w) - return err -} - -type Login500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response Login500ApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - -type LogoutRequestObject struct { -} - -type LogoutResponseObject interface { - VisitLogoutResponse(w http.ResponseWriter) error -} - -type Logout200JSONResponse LogoutResponse - -func (response Logout200JSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - _, err := buf.WriteTo(w) - return err -} - -type Logout401ApplicationProblemPlusJSONResponse struct { - UnauthorizedApplicationProblemPlusJSONResponse -} - -func (response Logout401ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(401) - _, err := buf.WriteTo(w) - return err -} - -type Logout403ApplicationProblemPlusJSONResponse struct { - ForbiddenApplicationProblemPlusJSONResponse -} - -func (response Logout403ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(403) - _, err := buf.WriteTo(w) - return err -} - -type Logout500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response Logout500ApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - -type ChangePasswordRequestObject struct { - Body *ChangePasswordJSONRequestBody -} - -type ChangePasswordResponseObject interface { - VisitChangePasswordResponse(w http.ResponseWriter) error -} - -type ChangePassword204Response struct { -} - -func (response ChangePassword204Response) VisitChangePasswordResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil -} - -type ChangePassword400ApplicationProblemPlusJSONResponse struct { - BadRequestApplicationProblemPlusJSONResponse -} - -func (response ChangePassword400ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - _, err := buf.WriteTo(w) - return err -} - -type ChangePassword401ApplicationProblemPlusJSONResponse struct { - UnauthorizedApplicationProblemPlusJSONResponse -} - -func (response ChangePassword401ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(401) - _, err := buf.WriteTo(w) - return err -} - -type ChangePassword403ApplicationProblemPlusJSONResponse struct { - ForbiddenApplicationProblemPlusJSONResponse -} - -func (response ChangePassword403ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(403) - _, err := buf.WriteTo(w) - return err -} - -type ChangePassword409ApplicationProblemPlusJSONResponse struct { - ConflictApplicationProblemPlusJSONResponse -} - -func (response ChangePassword409ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(409) - _, err := buf.WriteTo(w) - return err -} - -type ChangePassword413ApplicationProblemPlusJSONResponse struct { - PayloadTooLargeApplicationProblemPlusJSONResponse -} - -func (response ChangePassword413ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(413) - _, err := buf.WriteTo(w) - return err -} - -type ChangePassword429ApplicationProblemPlusJSONResponse struct { - TooManyRequestsApplicationProblemPlusJSONResponse -} - -func (response ChangePassword429ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.RetryAfter != nil { - w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) - } - w.WriteHeader(429) - _, err := buf.WriteTo(w) - return err -} - -type ChangePassword500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response ChangePassword500ApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - -type SetupFirstAdminRequestObject struct { - Body *SetupFirstAdminJSONRequestBody -} - -type SetupFirstAdminResponseObject interface { - VisitSetupFirstAdminResponse(w http.ResponseWriter) error -} - -type SetupFirstAdmin201ResponseHeaders struct { - CacheControl *string -} - -type SetupFirstAdmin201JSONResponse struct { - Body GrantCreated - Headers SetupFirstAdmin201ResponseHeaders -} - -func (response SetupFirstAdmin201JSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - if response.Headers.CacheControl != nil { - w.Header().Set("Cache-Control", fmt.Sprint(*response.Headers.CacheControl)) - } - w.WriteHeader(201) - _, err := buf.WriteTo(w) - return err -} - -type SetupFirstAdmin400ApplicationProblemPlusJSONResponse struct { - BadRequestApplicationProblemPlusJSONResponse -} - -func (response SetupFirstAdmin400ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - _, err := buf.WriteTo(w) - return err -} - -type SetupFirstAdmin409ApplicationProblemPlusJSONResponse struct { - ConflictApplicationProblemPlusJSONResponse -} - -func (response SetupFirstAdmin409ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(409) - _, err := buf.WriteTo(w) - return err -} - -type SetupFirstAdmin413ApplicationProblemPlusJSONResponse struct { - PayloadTooLargeApplicationProblemPlusJSONResponse -} - -func (response SetupFirstAdmin413ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(413) - _, err := buf.WriteTo(w) - return err -} - -type SetupFirstAdmin429ApplicationProblemPlusJSONResponse struct { - TooManyRequestsApplicationProblemPlusJSONResponse -} - -func (response SetupFirstAdmin429ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.RetryAfter != nil { - w.Header().Set("Retry-After", fmt.Sprint(*response.Headers.RetryAfter)) - } - w.WriteHeader(429) - _, err := buf.WriteTo(w) - return err -} - -type SetupFirstAdmin500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response SetupFirstAdmin500ApplicationProblemPlusJSONResponse) VisitSetupFirstAdminResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - -type GetCapabilitiesRequestObject struct { -} - -type GetCapabilitiesResponseObject interface { - VisitGetCapabilitiesResponse(w http.ResponseWriter) error -} - -type GetCapabilities200JSONResponse Capabilities - -func (response GetCapabilities200JSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - _, err := buf.WriteTo(w) - return err -} - -type GetCapabilities401ApplicationProblemPlusJSONResponse struct { - UnauthorizedApplicationProblemPlusJSONResponse -} - -func (response GetCapabilities401ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - if response.Headers.WWWAuthenticate != nil { - w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) - } - w.WriteHeader(401) - _, err := buf.WriteTo(w) - return err -} - -type GetCapabilities500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response GetCapabilities500ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - type GetServerInfoRequestObject struct { } @@ -1506,27 +321,6 @@ func (response GetServerInfo500ApplicationProblemPlusJSONResponse) VisitGetServe // StrictServerInterface represents all server handlers. type StrictServerInterface interface { - // ListGrants List my grants - // (GET /auth/grants) - ListGrants(ctx context.Context, request ListGrantsRequestObject) (ListGrantsResponseObject, error) - // RevokeGrant Revoke one of my grants - // (DELETE /auth/grants/{id}) - RevokeGrant(ctx context.Context, request RevokeGrantRequestObject) (RevokeGrantResponseObject, error) - // Login Log in with a password - // (POST /auth/login) - Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) - // Logout Log out - // (POST /auth/logout) - Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) - // ChangePassword Change my password - // (POST /auth/password) - ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error) - // SetupFirstAdmin Create the first admin - // (POST /auth/setup) - SetupFirstAdmin(ctx context.Context, request SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) - // GetCapabilities List implemented capability modules - // (GET /capabilities) - GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) // GetServerInfo Describe the server // (GET /server) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) @@ -1571,199 +365,6 @@ type strictHandler struct { options StrictHTTPServerOptions } -// ListGrants operation middleware -func (sh *strictHandler) ListGrants(w http.ResponseWriter, r *http.Request, params ListGrantsParams) { - var request ListGrantsRequestObject - - request.Params = params - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.ListGrants(ctx, request.(ListGrantsRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "ListGrants") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(ListGrantsResponseObject); ok { - if err := validResponse.VisitListGrantsResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - -// RevokeGrant operation middleware -func (sh *strictHandler) RevokeGrant(w http.ResponseWriter, r *http.Request, id string) { - var request RevokeGrantRequestObject - - request.Id = id - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.RevokeGrant(ctx, request.(RevokeGrantRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "RevokeGrant") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(RevokeGrantResponseObject); ok { - if err := validResponse.VisitRevokeGrantResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - -// Login operation middleware -func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) { - var request LoginRequestObject - - var body LoginJSONRequestBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) - return - } - request.Body = &body - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.Login(ctx, request.(LoginRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "Login") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(LoginResponseObject); ok { - if err := validResponse.VisitLoginResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - -// Logout operation middleware -func (sh *strictHandler) Logout(w http.ResponseWriter, r *http.Request) { - var request LogoutRequestObject - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.Logout(ctx, request.(LogoutRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "Logout") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(LogoutResponseObject); ok { - if err := validResponse.VisitLogoutResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - -// ChangePassword operation middleware -func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { - var request ChangePasswordRequestObject - - var body ChangePasswordJSONRequestBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) - return - } - request.Body = &body - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.ChangePassword(ctx, request.(ChangePasswordRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "ChangePassword") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(ChangePasswordResponseObject); ok { - if err := validResponse.VisitChangePasswordResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - -// SetupFirstAdmin operation middleware -func (sh *strictHandler) SetupFirstAdmin(w http.ResponseWriter, r *http.Request) { - var request SetupFirstAdminRequestObject - - var body SetupFirstAdminJSONRequestBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) - return - } - request.Body = &body - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.SetupFirstAdmin(ctx, request.(SetupFirstAdminRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "SetupFirstAdmin") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(SetupFirstAdminResponseObject); ok { - if err := validResponse.VisitSetupFirstAdminResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetCapabilities operation middleware -func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) { - var request GetCapabilitiesRequestObject - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.GetCapabilities(ctx, request.(GetCapabilitiesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetCapabilities") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(GetCapabilitiesResponseObject); ok { - if err := validResponse.VisitGetCapabilitiesResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - // GetServerInfo operation middleware func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { var request GetServerInfoRequestObject diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go index 3851db3d1..2552e40b0 100644 --- a/server/apiv1/api_test.go +++ b/server/apiv1/api_test.go @@ -3,11 +3,7 @@ package apiv1 import ( "net/http" "net/http/httptest" - "strings" - "github.com/getkin/kin-openapi/openapi3" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/api" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -20,40 +16,6 @@ var _ = Describe("Router", func() { router = New(&tests.MockDataStore{}) }) - It("routes every operation in the embedded spec", func() { - doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) - Expect(err).ToNot(HaveOccurred()) - mux := New(&tests.MockDataStore{}).Handler.(chi.Routes) - for path, item := range doc.Paths.Map() { - for method := range item.Operations() { - Expect(mux.Find(chi.NewRouteContext(), method, path)).To(Equal(path), method+" "+path) - } - } - }) - - It("declares Cache-Control no-store on the success responses of every no-store operation", func() { - doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) - Expect(err).ToNot(HaveOccurred()) - checked := map[string]bool{} - for _, item := range doc.Paths.Map() { - for _, op := range item.Operations() { - if !gateRulesV1.noStore[op.OperationID] { - continue - } - for code, resp := range op.Responses.Map() { - if !strings.HasPrefix(code, "2") { - continue - } - h := resp.Value.Headers["Cache-Control"] - Expect(h).ToNot(BeNil(), op.OperationID+" "+code) - Expect(h.Value.Schema.Value.Enum).To(ConsistOf("no-store"), op.OperationID+" "+code) - checked[op.OperationID] = true - } - } - } - Expect(checked).To(HaveLen(len(gateRulesV1.noStore))) - }) - It("returns a 404 problem for unknown paths", func() { w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -105,19 +67,6 @@ var _ = Describe("Router", func() { Expect(p.Detail).To(BeNil()) }) - It("tags internal errors with a referenceId that is also on the request's log lines", func() { - logs := captureLogs() - w := httptest.NewRecorder() - h := referenceIDMiddleware(problemRecoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - panic("kaboom") - }))) - h.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), http.MethodGet, "/boom", nil)) - p := decodeProblem(w) - Expect(p.ReferenceId).ToNot(BeNil()) - Expect(*p.ReferenceId).To(MatchRegexp(`^[0-9A-Za-z]{22}$`)) - Expect(logs.String()).To(ContainSubstring(*p.ReferenceId)) - }) - It("re-panics http.ErrAbortHandler so the server can drop the connection", func() { Expect(func() { panicking(http.ErrAbortHandler).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/abort", nil)) diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index 00b26abc4..f89244e38 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -2,14 +2,10 @@ package apiv1 import ( "bytes" - "context" - "encoding/json" "errors" "io" "net/http" "net/http/httptest" - "path/filepath" - "strings" "testing" "github.com/getkin/kin-openapi/openapi3" @@ -18,11 +14,7 @@ import ( "github.com/getkin/kin-openapi/routers/gorillamux" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -37,13 +29,7 @@ func TestAPIv1(t *testing.T) { var specRouter routers.Router -// One database for the suite (db.Db() is a process-wide singleton); each spec clears users and grants. var _ = BeforeSuite(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiv1.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000" - DeferCleanup(db.Init(GinkgoT().Context())) - realDS = persistence.New(db.Db()) - doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) Expect(err).ToNot(HaveOccurred()) specRouter, err = gorillamux.NewRouter(doc) @@ -60,64 +46,6 @@ func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder { return w } -// testClient drives a router end to end through serve, so every response is also checked against the spec. -type testClient struct { - ctx context.Context - router http.Handler -} - -func (c testClient) call(method, path, bearer string, body any) *httptest.ResponseRecorder { - if body == nil { - return c.callRaw(method, path, bearer, "") - } - b, _ := json.Marshal(body) - return c.callRaw(method, path, bearer, string(b)) -} - -// callRaw sends body verbatim, for JSON a map cannot express, like keys differing only in case. -func (c testClient) callRaw(method, path, bearer, body string) *httptest.ResponseRecorder { - var req *http.Request - if body != "" { - req = httptest.NewRequestWithContext(c.ctx, method, path, strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - } else { - req = httptest.NewRequestWithContext(c.ctx, method, path, nil) - } - if bearer != "" { - req.Header.Set("Authorization", "Bearer "+bearer) - } - return serve(c.router, req) -} - -func (c testClient) setup() GrantCreated { - w := c.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String()) - var gc GrantCreated - decodeJSON(w, &gc) - return gc -} - -// login signs in as the admin created by setup; nil scopes asks for all of them. -func (c testClient) login(scopes []string) GrantCreated { - body := creds("admin", "pw") - if scopes != nil { - body["scopes"] = scopes - } - w := c.call(http.MethodPost, "/api/v1/auth/login", "", body) - ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String()) - var gc GrantCreated - decodeJSON(w, &gc) - return gc -} - -func creds(user, pw string) map[string]any { - return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"} -} - -func decodeJSON(w *httptest.ResponseRecorder, v any) { - ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String()) -} - func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) { route, pathParams, err := specRouter.FindRoute(req) if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) { diff --git a/server/apiv1/auth_handlers.go b/server/apiv1/auth_handlers.go deleted file mode 100644 index eec4a3dae..000000000 --- a/server/apiv1/auth_handlers.go +++ /dev/null @@ -1,90 +0,0 @@ -package apiv1 - -import ( - "cmp" - "context" - "errors" - - "github.com/navidrome/navidrome/core/apiauth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/slice" -) - -const defaultPageSize = 100 - -// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same. -func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) { - b := *req.Body - issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) - if err != nil { - return nil, err - } - return Login200JSONResponse{Body: toGrantCreated(issued)}, nil -} - -func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) { - b := *req.Body - issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes)) - if err != nil { - return nil, err - } - return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil -} - -func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) { - p, err := principalFrom(ctx) - if err != nil { - return nil, err - } - b := *req.Body - revoke := true - if b.RevokeOtherGrants != nil { - revoke = *b.RevokeOtherGrants - } - err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke) - if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) { - return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}) - } - if err != nil { - return nil, err - } - return ChangePassword204Response{}, nil -} - -func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) { - p, err := principalFrom(ctx) - if err != nil { - return nil, err - } - offset := gg.V(req.Params.OffsetParam) - limit := cmp.Or(gg.V(req.Params.LimitParam), defaultPageSize) - grants, total, err := rt.auth.ListGrants(ctx, p, offset, limit) - if err != nil { - return nil, err - } - items := slice.Map(grants, func(g model.Grant) Grant { return toGrant(g, p.GrantID) }) - return ListGrants200JSONResponse{Items: items, Total: int(total), Offset: offset, Limit: limit}, nil -} - -func (rt *Router) RevokeGrant(ctx context.Context, req RevokeGrantRequestObject) (RevokeGrantResponseObject, error) { - p, err := principalFrom(ctx) - if err != nil { - return nil, err - } - if err := rt.auth.RevokeGrant(ctx, p, req.Id); err != nil { - return nil, err - } - return RevokeGrant204Response{}, nil -} - -func (rt *Router) Logout(ctx context.Context, _ LogoutRequestObject) (LogoutResponseObject, error) { - p, err := principalFrom(ctx) - if err != nil { - return nil, err - } - if err := rt.auth.Logout(ctx, p); err != nil { - return nil, err - } - return Logout200JSONResponse{LogoutUrl: nil}, nil -} diff --git a/server/apiv1/auth_handlers_test.go b/server/apiv1/auth_handlers_test.go deleted file mode 100644 index 48c2748ad..000000000 --- a/server/apiv1/auth_handlers_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package apiv1 - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("auth endpoints", func() { - var ctx context.Context - var api testClient - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 0 - resetDB() - api = testClient{ctx: ctx, router: New(realDS)} - }) - - It("lets exactly one of a v1 setup and a v0 first-admin creation win", func() { - var wg sync.WaitGroup - var v1Code int - var v0Err error - wg.Add(2) - go func() { - defer GinkgoRecover() - defer wg.Done() - v1Code = api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code - }() - go func() { - defer GinkgoRecover() - defer wg.Done() - _, v0Err = auth.CreateFirstAdmin(ctx, realDS, "v0admin", "pw", nil) // what v0 /auth/createAdmin runs - }() - wg.Wait() - Expect(realDS.User().CountAll(ctx)).To(Equal(int64(1))) - Expect(v1Code).To(Or(Equal(http.StatusCreated), Equal(http.StatusConflict))) - Expect(v1Code == http.StatusCreated).ToNot(Equal(v0Err == nil), "exactly one must win") - }) - - It("sets up the first admin once, then answers 409 setup_complete", func() { - gc := api.setup() - Expect(gc.Secret).To(HavePrefix("ndg_")) - Expect(gc.User.IsAdmin).To(BeTrue()) - Expect(gc.Grant.Provider).To(Equal("setup")) - Expect(gc.Grant.Current).To(BeTrue()) - - w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw")) - Expect(w.Code).To(Equal(http.StatusConflict)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete)) - }) - - It("logs in and uses the grant secret on a scoped endpoint", func() { - api.setup() - w := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw")) - Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) - var gc GrantCreated - decodeJSON(w, &gc) - Expect(gc.User.PasswordChangeable).To(BeTrue()) - - w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil) - Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) - var list GrantList - decodeJSON(w, &list) - Expect(list.Total).To(Equal(2)) - Expect(list.Limit).To(Equal(100)) - }) - - It("fails login the same way for an unknown user and a wrong password, with a Bearer challenge", func() { - api.setup() - a := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong")) - b := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw")) - Expect(a.Code).To(Equal(http.StatusUnauthorized)) - Expect(a.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) - Expect(a.Body.String()).To(Equal(b.Body.String())) - }) - - It("treats missing scopes as all scopes, and [] as no scopes", func() { - api.setup() - all := api.login(nil) - Expect(all.Grant.Scopes).To(ConsistOf(ScopeAll)) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", all.Secret, nil).Code).To(Equal(http.StatusOK)) - - none := api.login([]string{}) - Expect(none.Grant.Scopes).To(BeEmpty()) - w := api.call(http.MethodGet, "/api/v1/auth/grants", none.Secret, nil) - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) - }) - - It("drops unknown requested scopes instead of rejecting them", func() { - api.setup() - gc := api.login([]string{"read", "playlists:write"}) - Expect(gc.Grant.Scopes).To(ConsistOf(ScopeRead)) - }) - - It("does not let a grant without read log out or revoke grants", func() { - gc := api.setup() - narrow := api.login([]string{"password"}) - Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.Secret, nil).Code).To(Equal(http.StatusForbidden)) - Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.Secret, nil).Code).To(Equal(http.StatusForbidden)) - }) - - It("logs out: the secret stops working and logoutUrl is null", func() { - gc := api.setup() - w := api.call(http.MethodPost, "/api/v1/auth/logout", gc.Secret, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`)) - - w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) - }) - - It("revokes another grant of the caller, whose secret then stops working", func() { - gc := api.setup() - other := api.login(nil) - Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+other.Grant.Id, gc.Secret, nil).Code).To(Equal(http.StatusNoContent)) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK)) - }) - - It("challenges with invalid_token when the grant is revoked while a password change runs", func() { - gc := api.setup() - revoking := testClient{ctx: ctx, router: New(beforeTxDS{DataStore: realDS, before: func() { - Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed()) - }})} - - w := revoking.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) - Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String()) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) - api.login(nil) - }) - - It("rejects a case-variant scopes key that would widen an explicit empty subset", func() { - api.setup() - w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"})) - }) - - It("rejects a case-variant client key that would skip its length limit", func() { - api.setup() - body := `{"username":"admin","password":"pw","client":"ok","Client":"` + strings.Repeat("x", 60_000) + `"}` - w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", body) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Client", Message: "must match the field name exactly"})) - }) - - DescribeTable("rejects a body with data after its JSON value, without echoing it", - func(path string, needsSecret bool, body string) { - secret := "" - if gc := api.setup(); needsSecret { - secret = gc.Secret - } - w := api.callRaw(http.MethodPost, path, secret, body) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"})) - Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) - }, - Entry("login with a trailing byte", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"}x`), - Entry("login with a second value", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"} {}`), - // This schema has a default, which the validator must not fill in by rewriting the body. - Entry("password change with a trailing byte", "/api/v1/auth/password", true, `{"currentPassword":"hunter2","newPassword":"pw2"}x`), - ) - - It("checks the login body even when Content-Type has a repeated parameter", func() { - api.setup() - body := `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}` - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json; a=1; a=2") - w := serve(api.router, req) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"})) - }) - - It("marks responses carrying a grant secret no-store", func() { - w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw")) - Expect(w.Code).To(Equal(http.StatusCreated)) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - var gc GrantCreated - decodeJSON(w, &gc) - - w = api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw")) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - - w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(BeEmpty()) - }) - - It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() { - gc := api.setup() - Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", gc.Secret, nil).Code).To(Equal(http.StatusNotFound)) - w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), gc.Secret, nil) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"})) - }) - - It("changes the password, keeping the caller and revoking the rest", func() { - gc := api.setup() - other := api.login(nil) - - w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"}) - Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String()) - - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK)) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("keeps every grant when revokeOtherGrants is false", func() { - gc := api.setup() - other := api.login(nil) - - body := map[string]any{"currentPassword": "pw", "newPassword": "pw2", "revokeOtherGrants": false} - w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, body) - Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String()) - - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK)) - Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusOK)) - }) - - It("reports a wrong current password as a field error", func() { - gc := api.setup() - w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "nope", "newPassword": "pw2"}) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - p := decodeProblem(w) - Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"})) - }) - - DescribeTable("rejects bad credential bodies with a field error and no echo", - func(body map[string]any, field string) { - w := api.call(http.MethodPost, "/api/v1/auth/setup", "", body) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(*p.Errors).To(ContainElement(HaveField("Field", field))) - Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) - }, - Entry("missing client", map[string]any{"username": "a", "password": "hunter2"}, "client"), - Entry("empty password", map[string]any{"username": "a", "password": "", "client": "hunter2"}, "password"), - Entry("client too long", map[string]any{"username": "a", "password": "hunter2", "client": strings.Repeat("x", 65)}, "client"), - Entry("bad scope format", map[string]any{"username": "a", "password": "hunter2", "client": "c", "scopes": []string{"NOT OK"}}, "scopes.0"), - ) - - DescribeTable("rejects a body over 1 MiB with 413", - func(body func(string) io.Reader) { - big := `{"username":"a","password":"` + strings.Repeat("a", maxBodyBytes) + `","client":"c"}` - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", body(big)) - req.Header.Set("Content-Type", "application/json") - w := serve(api.router, req) - Expect(w.Code).To(Equal(http.StatusRequestEntityTooLarge)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodePayloadTooLarge)) - }, - Entry("with a declared length", func(s string) io.Reader { return strings.NewReader(s) }), - // io.MultiReader hides the length, so the request has ContentLength -1, like a chunked upload. - Entry("with no declared length", func(s string) io.Reader { return io.MultiReader(strings.NewReader(s)) }), - ) -}) - -// beforeTxDS calls before as each immediate transaction starts; authentication opens none, so it lands after the gate. -type beforeTxDS struct { - model.DataStore - before func() -} - -func (d beforeTxDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { - d.before() - return d.DataStore.WithTxImmediate(block, scope...) -} diff --git a/server/apiv1/db_test.go b/server/apiv1/db_test.go deleted file mode 100644 index 6b292a10b..000000000 --- a/server/apiv1/db_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package apiv1 - -import ( - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" -) - -var realDS model.DataStore - -func resetDB() { - _, _ = db.Db().Exec("delete from api_grant") - _, _ = db.Db().Exec("delete from user") -} diff --git a/server/apiv1/dto.go b/server/apiv1/dto.go deleted file mode 100644 index c65830a6a..000000000 --- a/server/apiv1/dto.go +++ /dev/null @@ -1,71 +0,0 @@ -package apiv1 - -import ( - "context" - - "github.com/navidrome/navidrome/core/apiauth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/slice" -) - -func toScopes(in []string) []Scope { - return slice.Map(in, func(s string) Scope { return Scope(s) }) -} - -// fromScopeRequests keeps nil (all scopes) apart from an empty list (no scopes). -func fromScopeRequests(in *[]ScopeRequest) []string { - if in == nil { - return nil - } - return append([]string{}, *in...) -} - -func nullable(s string) *string { - if s == "" { - return nil - } - return &s -} - -func toGrant(g model.Grant, currentID string) Grant { - return Grant{ - Id: g.ID, - Name: g.Name, - Client: g.Client, - ClientVersion: nullable(g.ClientVersion), - Scopes: toScopes(g.Scopes), - Provider: g.Provider, - CreatedAt: g.CreatedAt, - LastUsedAt: g.LastUsedAt, - LastUsedIp: nullable(g.LastUsedIP), - Current: g.ID == currentID, - } -} - -func toGrantCreated(i *apiauth.Issued) GrantCreated { - return GrantCreated{ - Secret: i.Secret, - Grant: toGrant(i.Grant, i.Grant.ID), - User: AuthUser{ - Id: i.User.ID, - UserName: i.User.UserName, - Name: i.User.Name, - IsAdmin: i.User.IsAdmin, - PasswordChangeable: apiauth.PasswordChangeable(i.User), - }, - } -} - -func clientMeta(c CredentialsRequest) apiauth.ClientMeta { - return apiauth.ClientMeta{Client: c.Client, Name: gg.V(c.Name), ClientVersion: gg.V(c.ClientVersion)} -} - -// principalFrom fails closed if the gate did not attach a principal to the context. -func principalFrom(ctx context.Context) (*apiauth.Principal, error) { - p, ok := apiauth.PrincipalFrom(ctx) - if !ok || p == nil { - return nil, model.ErrInvalidAuth - } - return p, nil -} diff --git a/server/apiv1/dto_test.go b/server/apiv1/dto_test.go deleted file mode 100644 index 49f10f067..000000000 --- a/server/apiv1/dto_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package apiv1 - -import ( - "github.com/navidrome/navidrome/core/apiauth" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("toScopes", func() { - It("only produces scopes the spec's Scope enum allows", func() { - for _, s := range toScopes(append([]string{apiauth.ScopeAll}, apiauth.KnownScopes...)) { - Expect(s.Valid()).To(BeTrue(), "scope %q is missing from the spec's Scope enum", s) - } - }) -}) diff --git a/server/apiv1/gate.go b/server/apiv1/gate.go deleted file mode 100644 index 9d6c20716..000000000 --- a/server/apiv1/gate.go +++ /dev/null @@ -1,422 +0,0 @@ -package apiv1 - -import ( - "bytes" - "cmp" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "maps" - "net/http" - "regexp" - "slices" - "strconv" - "strings" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/getkin/kin-openapi/routers" - "github.com/go-chi/chi/v5" - "github.com/go-chi/httprate" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/apiauth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server" -) - -type authenticator interface { - Authenticate(ctx context.Context, secret, ip string) (*apiauth.Principal, error) -} - -type gateOp struct { - route *routers.Route - public bool - scope string - limited bool - noStore bool -} - -func (o *gateOp) id() string { return o.route.Operation.OperationID } - -type opKey struct{ method, path string } - -type gate struct { - mux chi.Routes - ops map[opKey]*gateOp - auth authenticator - limiter func(http.Handler) http.Handler -} - -// Modules that ride another module's scope; every other module's scope is its own name. -var moduleScope = map[string]string{ - "core": apiauth.ScopeRead, - "transcoding": "streaming", - "custom-tags": apiauth.ScopeRead, - "grouping": apiauth.ScopeRead, - "smart-playlists": "playlists:write", -} - -type gateRules struct { - limited map[string]bool // login-type operations, throttled per client IP - noScope map[string]bool // the only bearerAuth operations allowed without x-scope - noStore map[string]bool // operations whose responses carry a secret -} - -func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) { - g := &gate{mux: mux, ops: map[opKey]*gateOp{}, auth: auth} - ids := map[string]bool{} - for path, item := range doc.Paths.Map() { - for method, op := range item.Operations() { - gop, err := buildGateOp(doc, path, item, method, op, rules) - if err != nil { - return nil, err - } - g.ops[opKey{method, path}] = gop - ids[op.OperationID] = true - } - } - if err := rules.check(ids); err != nil { - return nil, err - } - if conf.Server.AuthRequestLimit > 0 { - g.limiter = server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength, - // Counts are per node, so X-RateLimit-Remaining would mislead clients of a scaled-out server. - httprate.WithResponseHeaders(httprate.ResponseHeaders{RetryAfter: "Retry-After"}), - httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { - writeProblemStatus(w, r, http.StatusTooManyRequests, ProblemCodeRateLimited, "too many requests") - })) - } - return g, nil -} - -// check fails on a rule naming an operation the spec lacks, so a typo cannot silently disable the rule. -func (rules gateRules) check(ids map[string]bool) error { - sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "noStore": rules.noStore} - for name, set := range sets { - for id := range set { - if !ids[id] { - return fmt.Errorf("gate rule %s names unknown operation %s", name, id) - } - } - } - return nil -} - -// buildGateOp enforces the allowed security forms, so a spec edit cannot silently drop a requirement. -func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method string, op *openapi3.Operation, rules gateRules) (*gateOp, error) { - id := op.OperationID - gop := &gateOp{ - route: &routers.Route{Spec: doc, Path: path, PathItem: item, Method: method, Operation: op}, - limited: rules.limited[id], - noStore: rules.noStore[id], - } - if op.Security == nil { - return nil, fmt.Errorf("operation %s must declare security explicitly", id) - } - rawScope, hasScope := op.Extensions["x-scope"] - scope, isString := rawScope.(string) - if hasScope && (!isString || scope == "") { - return nil, fmt.Errorf("operation %s: x-scope must be a non-empty string", id) - } - module, _ := op.Extensions["x-module"].(string) - switch reqs := *op.Security; { - case len(reqs) == 0: - gop.public = true - case len(reqs) == 1 && isScheme(reqs[0], "bearerAuth"): - default: - return nil, fmt.Errorf("operation %s has a security requirement outside the allowed forms", id) - } - if !gop.public && scope == "" && !rules.noScope[id] { - return nil, fmt.Errorf("operation %s: bearerAuth needs x-scope", id) - } - if scope != "" { - if gop.public { - return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id) - } - base := cmp.Or(moduleScope[module], module) - if scope != base && scope != base+":write" { - return nil, fmt.Errorf("operation %s: x-scope %q does not match module %q", id, scope, module) - } - if !slices.Contains(apiauth.KnownScopes, scope) { - return nil, fmt.Errorf("operation %s: unknown x-scope %q", id, scope) - } - } - gop.scope = scope - return gop, nil -} - -// isScheme requires the scheme alone with an empty scope list, as OpenAPI 3.0.3 demands for http schemes. -func isScheme(req openapi3.SecurityRequirement, name string) bool { - scopes, ok := req[name] - return ok && len(req) == 1 && len(scopes) == 0 -} - -// checkRoutes fails when a routed pattern has no spec operation or a spec operation has no route. -func (g *gate) checkRoutes() error { - err := chi.Walk(g.mux, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { - if _, ok := g.ops[opKey{method, route}]; !ok { - return fmt.Errorf("route %s %s is not in the spec", method, route) - } - return nil - }) - if err != nil { - return err - } - for _, op := range g.ops { - if g.mux.Find(chi.NewRouteContext(), op.route.Method, op.route.Path) != op.route.Path { - return fmt.Errorf("spec operation %s (%s %s) has no route", op.id(), op.route.Method, op.route.Path) - } - } - return nil -} - -func (g *gate) handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - method := routeMethod(r) - rctx := chi.NewRouteContext() - pattern := g.mux.Find(rctx, method, routePath(r)) - if pattern == "" { - next.ServeHTTP(w, r) - return - } - op, ok := g.ops[opKey{method, pattern}] - if !ok { - log.Error(r.Context(), "API v1: routed pattern missing from the spec", "method", method, "pattern", pattern) - writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "") - return - } - if op.noStore { - w.Header().Set("Cache-Control", "no-store") - } - serve := func(w http.ResponseWriter, r *http.Request) { - r, ok := g.authorize(w, r, op) - if !ok { - return - } - if !g.validate(w, r, op, rctx) { - return - } - next.ServeHTTP(w, r) - } - if op.limited && g.limiter != nil { - g.limiter(http.HandlerFunc(serve)).ServeHTTP(w, r) - return - } - serve(w, r) - }) -} - -func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*http.Request, bool) { - if op.public { - return r, true - } - secret, ok := bearerToken(r) - if !ok { - writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "") - return r, false - } - p, err := g.auth.Authenticate(r.Context(), secret, server.ClientAddr(r)) - if err != nil { - writeProblem(w, r, err) - return r, false - } - if op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) { - writeProblem(w, r, &scopeError{scope: op.scope}) - return r, false - } - ctx := apiauth.WithPrincipal(request.WithUser(r.Context(), p.User), p) - return r.WithContext(ctx), true -} - -func bearerToken(r *http.Request) (string, bool) { - scheme, token, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") - token = strings.TrimSpace(token) - if !ok || !strings.EqualFold(scheme, "Bearer") || token == "" { - return "", false - } - return token, true -} - -// SkipSettingDefaults: the handlers apply defaults themselves, and the validator must not rewrite the body. -var validationOptions = &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true, SkipSettingDefaults: true} - -func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx *chi.Context) bool { - params := make(map[string]string, len(rctx.URLParams.Keys)) - for i, k := range rctx.URLParams.Keys { - params[k] = rctx.URLParams.Values[i] - } - body, err := readBody(r, op.route.Operation) - if err == nil { - err = openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{ - Request: r, PathParams: params, Route: op.route, Options: validationOptions, - }) - if body != nil { - r.Body = io.NopCloser(bytes.NewReader(body)) - } - } - if tooLarge(err) { - writeProblem(w, r, ClientError(err, tooLargeDetail)) - return false - } - var fields []ValidationError - if err != nil { - fields = sanitizeValidation(err) - } else if fields = jsonBodyFields(body, op.route.Operation); len(fields) == 0 { - return true - } - log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id(), "errors", fields) - writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "the request does not match the API schema", fields...) - return false -} - -// readBody reads a declared body once, so the validator, the JSON checks and the handler all see the same bytes. -func readBody(r *http.Request, op *openapi3.Operation) ([]byte, error) { - if op.RequestBody == nil || r.Body == nil || r.Body == http.NoBody { - return nil, nil - } - data, err := io.ReadAll(r.Body) - if err != nil { - return nil, err - } - r.Body = io.NopCloser(bytes.NewReader(data)) - return data, nil -} - -// jsonBodyFields checks what kin-openapi misses in a JSON body: data after the first value, which Go's decoder -// ignores, and keys that only case-fold to a declared property, which encoding/json decodes into that property. -func jsonBodyFields(data []byte, op *openapi3.Operation) []ValidationError { - if op.RequestBody == nil || op.RequestBody.Value == nil || len(bytes.TrimSpace(data)) == 0 { - return nil - } - // Keyed on the spec, not the request's Content-Type: the handlers decode JSON whatever the header says. - media := op.RequestBody.Value.Content.Get("application/json") - if media == nil || media.Schema == nil { - return nil - } - dec := json.NewDecoder(bytes.NewReader(data)) - var body any - if err := dec.Decode(&body); err != nil { - return []ValidationError{{Field: "", Message: "must be a single JSON value"}} - } - if _, err := dec.Token(); !errors.Is(err, io.EOF) { - return []ValidationError{{Field: "", Message: "must be a single JSON value"}} - } - var out []ValidationError - collectCaseAliases(body, media.Schema.Value, "", &out) - return out -} - -func collectCaseAliases(v any, schema *openapi3.Schema, path string, out *[]ValidationError) { - if schema == nil { - return - } - switch v := v.(type) { - case map[string]any: - for _, key := range slices.Sorted(maps.Keys(v)) { - field := joinField(path, key) - if prop, ok := schema.Properties[key]; ok { - if prop != nil { - collectCaseAliases(v[key], prop.Value, field, out) - } - continue - } - for name := range schema.Properties { - if strings.EqualFold(key, name) { - *out = append(*out, ValidationError{Field: field, Message: "must match the field name exactly"}) - break - } - } - } - case []any: - if schema.Items == nil { - return - } - for i, item := range v { - collectCaseAliases(item, schema.Items.Value, joinField(path, strconv.Itoa(i)), out) - } - } -} - -func joinField(path, name string) string { - if path == "" { - return name - } - return path + "." + name -} - -var missingProperty = regexp.MustCompile(`property "([^"]+)" is missing`) - -// sanitizeValidation keeps only field paths and fixed messages: kin-openapi errors can embed the submitted value. -// It walks wrappers by concrete type, not errors.As, because MultiError.As would skip the RequestError that names the parameter. -func sanitizeValidation(err error) []ValidationError { - var out []ValidationError - var walk func(err error, param string) - walk = func(err error, param string) { - switch e := err.(type) { //nolint:errorlint - case openapi3.MultiError: - for _, child := range e { - walk(child, param) - } - case *openapi3filter.RequestError: - if e.Parameter != nil { - param = e.Parameter.Name - } - switch { - case errors.Is(e.Err, openapi3filter.ErrInvalidRequired), errors.Is(e.Err, openapi3filter.ErrInvalidEmptyValue): - out = append(out, ValidationError{Field: param, Message: "is required"}) - case e.Err != nil: - walk(e.Err, param) - default: - out = append(out, ValidationError{Field: param, Message: "is invalid"}) - } - case *openapi3.SchemaError: - field := strings.Join(e.JSONPointer(), ".") - if field == "" && e.SchemaField == "required" { - if m := missingProperty.FindStringSubmatch(e.Reason); m != nil { - field = m[1] - } - } - switch { - case param != "" && field != "": - field = param + "." + field - case field == "": - field = param - } - out = append(out, ValidationError{Field: field, Message: schemaMessage(e.SchemaField)}) - default: - if inner := errors.Unwrap(err); inner != nil { - walk(inner, param) - return - } - out = append(out, ValidationError{Field: param, Message: "is invalid"}) - } - } - walk(err, "") - return out -} - -func schemaMessage(keyword string) string { - switch keyword { - case "required": - return "is required" - case "maxLength", "maxItems": - return "is too long" - case "minLength", "minItems": - return "is too short" - case "maximum", "exclusiveMaximum": - return "is too large" - case "minimum", "exclusiveMinimum": - return "is too small" - case "pattern", "format": - return "has an invalid format" - case "enum": - return "is not an allowed value" - case "type", "nullable": - return "has the wrong type" - default: - return "is invalid" - } -} diff --git a/server/apiv1/gate_test.go b/server/apiv1/gate_test.go deleted file mode 100644 index f748957fc..000000000 --- a/server/apiv1/gate_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package apiv1 - -import ( - "bytes" - "context" - "maps" - "net/http" - "net/http/httptest" - "os" - "strings" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/apiauth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const gateSpec = ` -openapi: 3.0.3 -info: {title: t, version: "1"} -paths: - /open: - get: {operationId: open, x-module: core, security: [], responses: {'200': {description: ok}}} - /things/{id}: - get: - operationId: getThing - x-module: core - x-scope: read - security: [{bearerAuth: []}] - parameters: [{name: id, in: path, required: true, schema: {type: string, maxLength: 3}}] - responses: {'200': {description: ok}} - /things: - post: - operationId: createThing - x-module: password - x-scope: password - security: [{bearerAuth: []}] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name] - properties: - name: {type: string, maxLength: 5} - tags: {type: array, items: {type: object, properties: {label: {type: string, maxLength: 5}}}} - responses: {'200': {description: ok}} - /caps: - get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}} - /limited: - post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}} -components: - securitySchemes: - bearerAuth: {type: http, scheme: bearer} -` - -type fakeAuth struct { - principal *apiauth.Principal - err error - gotSecret string - gotIP string -} - -func (f *fakeAuth) Authenticate(_ context.Context, secret, ip string) (*apiauth.Principal, error) { - f.gotSecret, f.gotIP = secret, ip - return f.principal, f.err -} - -var testGateRules = gateRules{ - limited: map[string]bool{"limited": true}, - noScope: map[string]bool{"caps": true}, - noStore: map[string]bool{"caps": true}, -} - -var _ = Describe("spec gate", func() { - var ctx context.Context - var fa *fakeAuth - var mux *chi.Mux - var g *gate - var reached string - - build := func(spec string) (*chi.Mux, error) { - doc, err := openapi3.NewLoader().LoadFromData([]byte(spec)) - Expect(err).ToNot(HaveOccurred()) - m := chi.NewRouter() - g, err = newGate(doc, m, fa, testGateRules) - if err != nil { - return nil, err - } - m.Use(headAsGet(m), g.handler) - ok := func(name string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - reached = name - if p, found := apiauth.PrincipalFrom(r.Context()); found { - w.Header().Set("X-User", p.User.ID) - } - w.WriteHeader(http.StatusOK) - } - } - m.Get("/open", ok("open")) - m.Get("/things/{id}", ok("getThing")) - m.Post("/things", ok("createThing")) - m.Get("/caps", ok("caps")) - m.Post("/limited", ok("limited")) - return m, nil - } - - do := func(method, path, auth, body string) *httptest.ResponseRecorder { - var req *http.Request - if body != "" { - req = httptest.NewRequestWithContext(ctx, method, path, strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - } else { - req = httptest.NewRequestWithContext(ctx, method, path, nil) - } - if auth != "" { - req.Header.Set("Authorization", auth) - } - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - return w - } - - BeforeEach(func() { - ctx = GinkgoT().Context() - reached = "" - fa = &fakeAuth{principal: &apiauth.Principal{User: model.User{ID: "u1"}, GrantID: "g1", Scopes: []string{"read"}}} - var err error - mux, err = build(gateSpec) - Expect(err).ToNot(HaveOccurred()) - }) - - It("lets public operations through without a credential", func() { - Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK)) - Expect(reached).To(Equal("open")) - }) - - It("requires a grant secret, with a Bearer challenge", func() { - w := do(http.MethodGet, "/things/1", "", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized)) - Expect(reached).To(BeEmpty()) - }) - - It("accepts the Bearer scheme in any case and trims spaces", func() { - w := do(http.MethodGet, "/things/1", "bearer ndg_secret ", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.gotSecret).To(Equal("ndg_secret")) - Expect(w.Header().Get("X-User")).To(Equal("u1")) - }) - - It("maps auth failures to unauthorized with invalid_token", func() { - fa.err = model.ErrInvalidAuth - w := do(http.MethodGet, "/things/1", "Bearer x", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized)) - }) - - It("rejects a grant without the operation's scope", func() { - w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`) - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope)) - }) - - It("lets any valid grant through an operation with no x-scope", func() { - fa.principal.Scopes = nil - Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK)) - }) - - It("passes the full client address to the authenticator, not the rate-limit /64", func() { - req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/things/1", nil) - req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321" - req.Header.Set("Authorization", "Bearer x") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6")) - }) - - It("marks only the listed operations' responses no-store, errors included", func() { - Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) - fa.err = model.ErrInvalidAuth - Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store")) - fa.err = nil - Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty()) - }) - - It("checks HEAD on a protected GET", func() { - w := do(http.MethodHead, "/things/1", "", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("looks routes up on the raw path, as chi dispatches them", func() { - w := do(http.MethodGet, "/things/a%2Fb", "", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(reached).To(BeEmpty()) - - root := chi.NewRouter() - root.Mount("/music/api/v1", mux) - w = httptest.NewRecorder() - root.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/a%2Fb", nil)) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(reached).To(BeEmpty()) - }) - - It("works when mounted under a base path", func() { - root := chi.NewRouter() - root.Mount("/music/api/v1", mux) - req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/1", nil) - w := httptest.NewRecorder() - root.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("authenticates before validating", func() { - w := do(http.MethodPost, "/things", "", `{"name":"far-too-long"}`) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("returns and logs sanitised validation errors that never echo the value", func() { - logs := captureLogs() - fa.principal.Scopes = []string{"password"} - w := do(http.MethodPost, "/things", "Bearer x", `{"name":"hunter2-secret"}`) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is too long"})) - Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) - Expect(logs.String()).To(ContainSubstring("failed validation")) - Expect(logs.String()).ToNot(ContainSubstring("hunter2")) - }) - - It("reports a missing required body field by name", func() { - fa.principal.Scopes = []string{"password"} - w := do(http.MethodPost, "/things", "Bearer x", `{}`) - Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is required"})) - }) - - DescribeTable("rejects a case variant of a declared body field, which Go would decode into it", - func(body, field string) { - fa.principal.Scopes = []string{"password"} - w := do(http.MethodPost, "/things", "Bearer x", body) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(*p.Errors).To(ConsistOf(ValidationError{Field: field, Message: "must match the field name exactly"})) - Expect(reached).To(BeEmpty()) - }, - Entry("top level", `{"name":"ok","NAME":"much-too-long"}`, "NAME"), - Entry("inside array items", `{"name":"ok","tags":[{"label":"a"},{"label":"b","Label":"much-too-long"}]}`, "tags.1.Label"), - Entry("Unicode case folding", "{\"name\":\"ok\",\"tag\u017f\":null}", "tag\u017f"), - ) - - DescribeTable("rejects data after the first JSON value, which Go's decoder would ignore", - func(body string) { - fa.principal.Scopes = []string{"password"} - w := do(http.MethodPost, "/things", "Bearer x", body) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"})) - Expect(reached).To(BeEmpty()) - }, - Entry("garbage", `{"name":"ok","NAME":"much-too-long"}x`), - Entry("a second value", `{"name":"ok"} {"NAME":"much-too-long"}`), - Entry("a stray bracket", `{"name":"ok"}]`), - ) - - DescribeTable("checks the body whatever parameters the Content-Type carries", - func(contentType string) { - fa.principal.Scopes = []string{"password"} - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/things", strings.NewReader(`{"name":"ok","NAME":"much-too-long"}`)) - req.Header.Set("Content-Type", contentType) - req.Header.Set("Authorization", "Bearer x") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String()) - Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "NAME", Message: "must match the field name exactly"})) - Expect(reached).To(BeEmpty()) - }, - Entry("repeated parameter", "application/json; a=1; a=2"), - Entry("repeated charset", "application/json; charset=utf-8; CHARSET=latin1"), - ) - - It("accepts trailing whitespace after the JSON value", func() { - fa.principal.Scopes = []string{"password"} - Expect(do(http.MethodPost, "/things", "Bearer x", "{\"name\":\"ok\"}\n \t").Code).To(Equal(http.StatusOK)) - }) - - It("allows unknown body fields that do not collide with a declared one", func() { - fa.principal.Scopes = []string{"password"} - w := do(http.MethodPost, "/things", "Bearer x", `{"name":"ok","extra":{"Name":"x"},"tags":[{"label":"a","other":1}]}`) - Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) - Expect(reached).To(Equal("createThing")) - }) - - It("validates path parameters", func() { - w := do(http.MethodGet, "/things/toolong", "Bearer x", "") - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"})) - }) - - It("passes unknown paths through to the router's 404", func() { - Expect(do(http.MethodGet, "/nope", "", "").Code).To(Equal(http.StatusNotFound)) - }) - - It("fails closed for a routed pattern the spec does not know", func() { - mux.Get("/extra", func(w http.ResponseWriter, r *http.Request) { reached = "extra" }) - w := do(http.MethodGet, "/extra", "", "") - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - Expect(reached).To(BeEmpty()) - }) - - It("rate-limits the listed operations with a 429 problem", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 1 - var err error - mux, err = build(gateSpec) - Expect(err).ToNot(HaveOccurred()) - w := do(http.MethodPost, "/limited", "", "") - Expect(w.Code).To(Equal(http.StatusOK)) - expectNoXRateLimitHeaders(w) - w = do(http.MethodPost, "/limited", "", "") - Expect(w.Code).To(Equal(http.StatusTooManyRequests)) - Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) - expectNoXRateLimitHeaders(w) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeRateLimited)) - }) - - DescribeTable("refuses specs that break the security rules", - func(bad string) { - _, err := build(bad) - Expect(err).To(HaveOccurred()) - }, - Entry("missing security", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core,", 1)), - Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)), - Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)), - Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)), - Entry("a scheme other than bearerAuth", strings.Replace( - strings.Replace(gateSpec, " bearerAuth: {type: http, scheme: bearer}\n", " bearerAuth: {type: http, scheme: bearer}\n grantAuth: {type: http, scheme: bearer}\n", 1), - "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{grantAuth: []}]", 1)), - Entry("an undeclared scheme", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)), - Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)), - Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)), - Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)), - Entry("x-scope not in KnownScopes", strings.Replace(gateSpec, "x-module: password\n x-scope: password", "x-module: admin\n x-scope: admin", 1)), - ) - - DescribeTable("refuses rules that name an operation missing from the spec", - func(set func(*gateRules) *map[string]bool) { - doc, err := openapi3.NewLoader().LoadFromData([]byte(gateSpec)) - Expect(err).ToNot(HaveOccurred()) - rules := testGateRules - m := set(&rules) - *m = maps.Clone(*m) - (*m)["typo"] = true - _, err = newGate(doc, chi.NewRouter(), fa, rules) - Expect(err).To(MatchError(ContainSubstring("typo"))) - }, - Entry("limited", func(r *gateRules) *map[string]bool { return &r.limited }), - Entry("noScope", func(r *gateRules) *map[string]bool { return &r.noScope }), - Entry("noStore", func(r *gateRules) *map[string]bool { return &r.noStore }), - ) - - It("checks routes against the spec in both directions", func() { - Expect(g.checkRoutes()).To(Succeed()) - - mux.Get("/extra", func(http.ResponseWriter, *http.Request) {}) - Expect(g.checkRoutes()).To(MatchError(ContainSubstring("GET /extra is not in the spec"))) - - extraOp := strings.Replace(gateSpec, "components:", ` /unrouted: - get: {operationId: unrouted, x-module: core, security: [], responses: {'200': {description: ok}}} -components:`, 1) - _, err := build(extraOp) - Expect(err).ToNot(HaveOccurred()) - Expect(g.checkRoutes()).To(MatchError(ContainSubstring("unrouted"))) - }) -}) - -// captureLogs sends debug logs to a buffer for the rest of the spec. -func captureLogs() *bytes.Buffer { - buf := &bytes.Buffer{} - log.SetOutput(buf) - log.SetLevel(log.LevelDebug) - DeferCleanup(func() { - log.SetOutput(os.Stderr) - log.SetLevel(log.LevelFatal) - }) - return buf -} - -func expectNoXRateLimitHeaders(w *httptest.ResponseRecorder) { - GinkgoHelper() - for _, h := range []string{"X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Increment", "X-RateLimit-Reset"} { - Expect(w.Header().Values(h)).To(BeEmpty(), h) - } -} diff --git a/server/apiv1/oapi-codegen-overlay.yaml b/server/apiv1/oapi-codegen-overlay.yaml deleted file mode 100644 index 36a546287..000000000 --- a/server/apiv1/oapi-codegen-overlay.yaml +++ /dev/null @@ -1,12 +0,0 @@ -overlay: 1.0.0 -info: - title: Go type names for the API v1 server - version: 1.0.0 -actions: - # Ginkgo's dot-imported Offset would clash with a generated Offset type in this package's tests. - - target: $.components.parameters.offset - update: - x-go-name: OffsetParam - - target: $.components.parameters.limit - update: - x-go-name: LimitParam diff --git a/server/apiv1/oapi-codegen.yaml b/server/apiv1/oapi-codegen.yaml index 8301d8088..b9236de1a 100644 --- a/server/apiv1/oapi-codegen.yaml +++ b/server/apiv1/oapi-codegen.yaml @@ -8,7 +8,5 @@ output-options: exclude-operation-ids: - getOpenAPISpecJSON - getOpenAPISpecYAML - overlay: - path: server/apiv1/oapi-codegen-overlay.yaml compatibility: always-prefix-enum-values: true diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go index fe5c8169b..aa6389357 100644 --- a/server/apiv1/problem.go +++ b/server/apiv1/problem.go @@ -3,89 +3,26 @@ package apiv1 import ( "encoding/json" "errors" - "fmt" "net/http" - "github.com/navidrome/navidrome/core/apiauth" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) const problemContentType = "application/problem+json" -type clientError struct { - err error - detail string -} - -func (e *clientError) Error() string { return e.detail } -func (e *clientError) Unwrap() error { return e.err } - -// ClientError marks detail as safe to show clients; err still decides the status and code. -func ClientError(err error, detail string) error { - return &clientError{err: err, detail: detail} -} - -// scopeError names the scope an operation requires, for the insufficient_scope challenge. -type scopeError struct { - scope string -} - -func (e *scopeError) Error() string { return "insufficient scope" } - -const tooLargeDetail = "request body too large" - -func tooLarge(err error) bool { - return errors.As(err, new(*http.MaxBytesError)) -} - -type fieldErrors struct { - fields []ValidationError -} - -func (e *fieldErrors) Error() string { return "validation failed" } -func (e *fieldErrors) Unwrap() error { return model.ErrValidation } - -func validationFailed(fields ...ValidationError) error { - return &fieldErrors{fields: fields} -} - func writeProblem(w http.ResponseWriter, r *http.Request, err error) { status, code := classifyError(err) + detail := err.Error() if status == http.StatusInternalServerError { log.Error(r.Context(), "API v1: unexpected error", "path", r.URL.Path, err) - writeProblemStatus(w, r, status, code, "") - return - } - log.Debug(r.Context(), "API v1: request failed", "path", r.URL.Path, "status", status, "code", code, err) - var se *scopeError - if errors.As(err, &se) { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q`, se.scope)) - } - var detail string - var ce *clientError - if errors.As(err, &ce) { - detail = ce.detail - } - var fe *fieldErrors - if errors.As(err, &fe) { - writeProblemStatus(w, r, status, code, detail, fe.fields...) - return + detail = "" } writeProblemStatus(w, r, status, code, detail) } func classifyError(err error) (int, ProblemCode) { switch { - case tooLarge(err): - return http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge - case errors.As(err, new(*scopeError)): - return http.StatusForbidden, ProblemCodeInsufficientScope - case errors.Is(err, auth.ErrSetupComplete): - return http.StatusConflict, ProblemCodeSetupComplete - case errors.Is(err, apiauth.ErrPasswordManagedExternally): - return http.StatusConflict, ProblemCodePasswordManagedExternally case errors.Is(err, model.ErrNotFound): return http.StatusNotFound, ProblemCodeNotFound case errors.Is(err, model.ErrNotAuthorized): @@ -108,19 +45,6 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code if len(fieldErrors) > 0 { p.Errors = &fieldErrors } - if status == http.StatusInternalServerError { - if ref := referenceIDFrom(r.Context()); ref != "" { - p.ReferenceId = &ref - } - } - // Every 401 carries a Bearer challenge; callers may set a more specific one first. - if status == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" { - challenge := "Bearer" - if _, sent := bearerToken(r); sent { - challenge = `Bearer error="invalid_token"` - } - w.Header().Set("WWW-Authenticate", challenge) - } w.Header().Set("Content-Type", problemContentType) w.WriteHeader(status) if err := json.NewEncoder(w).Encode(p); err != nil { @@ -129,20 +53,20 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code } func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) { - var fieldErrs []ValidationError + var fieldErrors []ValidationError var required *RequiredParamError var invalid *InvalidParamFormatError var tooMany *TooManyValuesForParamError var unmarshal *UnmarshalingParamError switch { case errors.As(err, &required): - fieldErrs = append(fieldErrs, ValidationError{Field: required.ParamName, Message: "is required"}) + fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"}) case errors.As(err, &invalid): - fieldErrs = append(fieldErrs, ValidationError{Field: invalid.ParamName, Message: "has an invalid value"}) + fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()}) case errors.As(err, &tooMany): - fieldErrs = append(fieldErrs, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"}) + fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"}) case errors.As(err, &unmarshal): - fieldErrs = append(fieldErrs, ValidationError{Field: unmarshal.ParamName, Message: "has an invalid value"}) + fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()}) } - writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "invalid request parameters", fieldErrs...) + writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...) } diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go index 95ee46668..256296b3c 100644 --- a/server/apiv1/problem_test.go +++ b/server/apiv1/problem_test.go @@ -1,15 +1,12 @@ package apiv1 import ( - "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" - "github.com/navidrome/navidrome/core/apiauth" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -22,14 +19,12 @@ func decodeProblem(w *httptest.ResponseRecorder) Problem { } var _ = Describe("problem", func() { - var ctx context.Context var w *httptest.ResponseRecorder var r *http.Request BeforeEach(func() { - ctx = GinkgoT().Context() w = httptest.NewRecorder() - r = httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/server", nil) + r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil) }) Describe("writeProblem", func() { @@ -51,68 +46,20 @@ var _ = Describe("problem", func() { Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized), Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation), Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable), - Entry("insufficient scope", &scopeError{scope: "read"}, http.StatusForbidden, ProblemCodeInsufficientScope), - Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete), - Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally), Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal), ) - It("shows detail only for errors marked as client-facing", func() { - writeProblem(w, r, fmt.Errorf("album 123: %w", model.ErrNotFound)) - Expect(decodeProblem(w).Detail).To(BeNil()) - - w = httptest.NewRecorder() - writeProblem(w, r, ClientError(model.ErrNotFound, "album not found")) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeNotFound)) - Expect(*p.Detail).To(Equal("album not found")) - }) - - It("writes field errors from validationFailed", func() { - writeProblem(w, r, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"})) - p := decodeProblem(w) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"})) - }) - - It("writes and logs a problem with debug logging on", func() { - logs := captureLogs() - writeProblem(w, r, model.ErrNotFound) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(logs.String()).To(ContainSubstring("code=not_found")) - }) - - It("adds a Bearer challenge to every 401 unless one is already set", func() { - writeProblem(w, r, model.ErrInvalidAuth) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) - - w = httptest.NewRecorder() - w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) - writeProblem(w, r, model.ErrInvalidAuth) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) - }) - - It("challenges a 401 with invalid_token when the request carried a bearer token", func() { - r.Header.Set("Authorization", "Bearer tok") - writeProblem(w, r, model.ErrInvalidAuth) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`)) - - w = httptest.NewRecorder() - r.Header.Set("Authorization", "Basic dXNlcjpwdw==") - writeProblem(w, r, model.ErrInvalidAuth) - Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer")) - }) - - It("adds the request's referenceId to internal errors only", func() { - r = r.WithContext(withReferenceID(r.Context(), "ref-123")) - writeProblem(w, r, errors.New("boom")) - Expect(*decodeProblem(w).ReferenceId).To(Equal("ref-123")) - - w = httptest.NewRecorder() - writeProblem(w, r, model.ErrNotFound) - Expect(decodeProblem(w).ReferenceId).To(BeNil()) - }) + DescribeTable("keeps the wrapping context as detail for client errors", + func(err error) { + writeProblem(w, r, err) + p := decodeProblem(w) + Expect(p.Status).To(Equal(http.StatusNotFound)) + Expect(p.Detail).ToNot(BeNil()) + Expect(*p.Detail).To(ContainSubstring("album 123")) + }, + Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)), + Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)), + ) It("hides details for internal errors", func() { writeProblem(w, r, errors.New("db password is hunter2")) @@ -148,19 +95,14 @@ var _ = Describe("problem", func() { Expect(p.Errors).ToNot(BeNil()) Expect(*p.Errors).To(HaveLen(1)) Expect((*p.Errors)[0].Field).To(Equal(field)) - Expect((*p.Errors)[0].Message).To(Equal(message)) + Expect((*p.Errors)[0].Message).To(ContainSubstring(message)) }, Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"), - Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "abc": invalid syntax`)}, "offset", "has an invalid value"), - Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "expected a single value"), - Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "has an invalid value"), + Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"), + Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"), + Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"), ) - It("never echoes the submitted value", func() { - bindingErrorHandler(w, r, &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "hunter2": invalid syntax`)}) - Expect(w.Body.String()).ToNot(ContainSubstring("hunter2")) - }) - It("still returns a validation problem for unknown binding errors", func() { bindingErrorHandler(w, r, errors.New("weird")) p := decodeProblem(w) diff --git a/server/apiv1/reference.go b/server/apiv1/reference.go deleted file mode 100644 index bc614878c..000000000 --- a/server/apiv1/reference.go +++ /dev/null @@ -1,29 +0,0 @@ -package apiv1 - -import ( - "context" - "net/http" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" -) - -type referenceIDKey struct{} - -func withReferenceID(ctx context.Context, ref string) context.Context { - return context.WithValue(ctx, referenceIDKey{}, ref) -} - -func referenceIDFrom(ctx context.Context) string { - ref, _ := ctx.Value(referenceIDKey{}).(string) - return ref -} - -// referenceIDMiddleware tags every log line of the request with an id that 500 problems also carry. -func referenceIDMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ref := id.NewRandom() - ctx := log.NewContext(withReferenceID(r.Context(), ref), "referenceId", ref) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} diff --git a/server/apiv1/server_handlers.go b/server/apiv1/server_info.go similarity index 62% rename from server/apiv1/server_handlers.go rename to server/apiv1/server_info.go index f5c159629..458363efe 100644 --- a/server/apiv1/server_handlers.go +++ b/server/apiv1/server_info.go @@ -18,13 +18,6 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec ServerVersion: consts.Version, SpecVersion: api.SpecVersion(), SetupRequired: count == 0, - LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}}, - }, nil -} - -func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) { - return GetCapabilities200JSONResponse{ - Core: &CoreCapability{Version: 1}, - Password: &PasswordCapability{Version: 1}, + LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword}, }, nil } diff --git a/server/apiv1/server_handlers_test.go b/server/apiv1/server_info_test.go similarity index 63% rename from server/apiv1/server_handlers_test.go rename to server/apiv1/server_info_test.go index 59d0dcb3d..b9dae6f8d 100644 --- a/server/apiv1/server_handlers_test.go +++ b/server/apiv1/server_info_test.go @@ -8,7 +8,6 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -44,8 +43,7 @@ var _ = Describe("GET /server", func() { Expect(info.ServerVersion).To(Equal(consts.Version)) Expect(info.SpecVersion).To(Equal(api.SpecVersion())) Expect(info.SetupRequired).To(BeTrue()) - Expect(info.LoginMethods.Password).ToNot(BeNil()) - Expect(w.Body.String()).To(ContainSubstring(`"loginMethods":{"password":{}}`)) + Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword)) }) It("reports setupRequired=false once a user exists", func() { @@ -61,33 +59,3 @@ var _ = Describe("GET /server", func() { Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal)) }) }) - -var _ = Describe("GET /capabilities", func() { - var ctx context.Context - var api testClient - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - resetDB() - api = testClient{ctx: ctx, router: New(realDS)} - }) - - It("needs a grant", func() { - w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("lists core and password for any valid grant, even one with no scopes", func() { - api.setup() - gc := api.login([]string{}) - Expect(gc.Grant.Scopes).To(BeEmpty()) - - w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - var caps Capabilities - decodeJSON(w, &caps) - Expect(caps.Core.Version).To(Equal(1)) - Expect(caps.Password.Version).To(Equal(1)) - }) -}) diff --git a/server/auth.go b/server/auth.go index e772c1da2..3e58359da 100644 --- a/server/auth.go +++ b/server/auth.go @@ -13,6 +13,7 @@ import ( "slices" "strings" "sync" + "time" "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" @@ -25,6 +26,8 @@ import ( "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/gravatar" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) var ( @@ -124,11 +127,16 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) return } - _, err = auth.CreateFirstAdmin(r.Context(), ds, username, password, nil) - if errors.Is(err, auth.ErrSetupComplete) { + c, err := ds.User().CountAll(r.Context()) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + if c > 0 { _ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin") return } + err = createAdminUser(r.Context(), ds, username, password) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) return @@ -137,6 +145,26 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request } } +func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error { + log.Warn(ctx, "Creating initial user", "user", username) + caser := cases.Title(language.Und) + initialUser := model.User{ + ID: id.NewRandom(), + UserName: username, + Name: caser.String(username), + Email: "", + NewPassword: password, + IsAdmin: true, + LastLoginAt: new(time.Now()), + } + err := ds.User().Put(ctx, &initialUser) + if err != nil { + log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err) + return fmt.Errorf("creating initial user: %w", err) + } + return nil +} + func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, password string) (*model.User, error) { u, err := userRepo.FindByUsernameWithPassword(ctx, userName) if errors.Is(err, model.ErrNotFound) { diff --git a/server/auth_test.go b/server/auth_test.go index b7db3a881..1095fafc9 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -74,27 +74,14 @@ var _ = Describe("Auth", func() { }) }) - Describe("CreateFirstAdmin", func() { + Describe("createAdminUser", func() { It("returns the error when the user cannot be saved", func() { - failing := dsWithFailingPut(errors.New("db is down")) - _, err := auth.CreateFirstAdmin(ctx, failing, "johndoe", "secret", nil) + ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}} + err := createAdminUser(context.Background(), ds, "johndoe", "secret") Expect(err).To(MatchError(ContainSubstring("db is down"))) }) }) - Describe("createAdmin when a user already exists", func() { - It("responds 403", func() { - req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"another", "password":"secret"}`)) - resp = httptest.NewRecorder() - Expect(ds.User().Put(ctx, &model.User{UserName: "johndoe", NewPassword: "secret"})).To(Succeed()) - - createAdmin(ds)(resp, req) - - Expect(resp.Code).To(Equal(http.StatusForbidden)) - Expect(resp.Body.String()).To(ContainSubstring("Cannot create another first admin")) - }) - }) - Describe("createAdmin when the user cannot be stored", func() { It("responds 500 rather than falling through to login", func() { failing := dsWithFailingPut(errors.New("db is down")) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 9315ac4f9..48e69cb05 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -158,8 +158,8 @@ and `MusicArtist` queries accept it too, matching artists credited on an album o `Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`; `Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone `isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as -in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome -equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; +in Jellyfin; `Likes` and `Dislikes` are not supported, and `IsFolder`, `IsNotFolder` and +`IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; unrecognized keys are skipped, and `Random` always sorts alone); `StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` returns direct children only (no tracks — no track is a library's direct child). @@ -176,7 +176,7 @@ returns direct children only (no tracks — no track is a library's direct child | Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` | | Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | | Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | -| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | +| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE UserItems/{itemId}/Rating`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET`/`POST UserItems/{itemId}/UserData`, `GET`/`POST Users/{userId}/Items/{itemId}/UserData` | | Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | | Lyrics | `GET Audio/{itemId}/Lyrics` | | Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` | @@ -402,12 +402,20 @@ make test PKG=./server/jellyfin/... in the playlist, so ids change when entries are inserted or removed; re-read the list after an edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work; clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`. -- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating` - (0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends - only `likes` clears the rating. -- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy - `UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback - reporting. +- **Ratings are stored as 0-5 stars.** Jellyfin's 0-10 `Rating` is rounded to the nearest star + (any nonzero value keeps at least one), so it reads back as an even number. `likes=true` stores + 5 stars and `likes=false` 1 star, like Jellyfin's 10 and 1. A rating of 0 clears the rating. +- **`POST .../Rating` needs a value.** Jellyfin clears the rating when `likes` is missing; Navidrome + answers `400` and keeps it, as it does for a malformed or out-of-range value. Use + `DELETE .../Rating` to clear. A numeric `rating` param (0-10) is also accepted when `likes` is + absent. +- **`POST .../UserData` writes only `Rating`, `Likes` and `IsFavorite`.** `Played`, `PlayCount`, + `LastPlayedDate` and `PlaybackPositionTicks` are ignored. A body that is not a single JSON + object, or a `Rating` outside 0-10, is a `400` and nothing is written. +- **Annotation writes can't target another user.** Rating and UserData writes whose `userId` names + a different user get `403`, even for admins; Jellyfin lets admins edit other users' data. +- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed) and `Search/Hints` are not + implemented. Play counts only change through playback reporting. - **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are accepted but never returned. - **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index a589798be..1bb0b7846 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -1,47 +1,52 @@ package jellyfin import ( + "encoding/json" "errors" - "math" + "io" "net/http" + "strconv" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" ) -// resolveAnnotated finds which annotated repo owns id, returning the resource name used in -// refreshResource events. Albums and songs 404 when the user can't access their library; artists -// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time -// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has -// already been written, so callers must return without writing the annotation. -func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) { +type annotatedItem struct { + repo model.AnnotatedRepository + resource string // name used in refreshResource events + ann model.Annotations +} + +// resolveAnnotated finds id's repo and the caller's annotations. Only albums and songs are library-gated +// (artists span libraries; playlists gate in Get). When !ok, the response has already been written. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (item annotatedItem, ok bool) { ctx := r.Context() entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil && !errors.Is(err, model.ErrNotFound) { api.internalError(w, r, err) - return nil, "" + return annotatedItem{}, false } u, _ := request.UserFrom(ctx) switch e := entity.(type) { case *model.Album: if u.HasLibraryAccess(e.LibraryID) { - return api.ds.Album(), "album" + return annotatedItem{api.ds.Album(), "album", e.Annotations}, true } case *model.Artist: - return api.ds.Artist(), "artist" + return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true case *model.MediaFile: if u.HasLibraryAccess(e.LibraryID) { - return api.ds.MediaFile(), "song" + return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true } case *model.Playlist: - return api.ds.Playlist(), "playlist" + return annotatedItem{api.ds.Playlist(), "playlist", e.Annotations}, true } // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. http.Error(w, "Not Found", http.StatusNotFound) - return nil, "" + return annotatedItem{}, false } // getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify @@ -65,65 +70,135 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { api.ok(w, r, data) } -func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { +// userDataChange holds already-validated writes; a nil field leaves that annotation untouched. +type userDataChange struct { + rating *int // Navidrome stars; 0 clears + favorite *bool +} + +// applyUserData writes change for the itemId path param and answers with the item's resulting +// UserData, as Jellyfin does. +func (api *Router) applyUserData(w http.ResponseWriter, r *http.Request, change userDataChange) { id, ok := itemIDParam(w, r, "itemId") if !ok { return } - repo, resource := api.resolveAnnotated(w, r, id) - if repo == nil { + item, ok := api.resolveAnnotated(w, r, id) + if !ok { return } - if err := repo.SetStar(r.Context(), starred, id); err != nil { - api.internalError(w, r, err) - return + ctx := r.Context() + if change.rating != nil { + if err := item.repo.SetRating(ctx, *change.rating, id); err != nil { + api.internalError(w, r, err) + return + } + item.ann.Rating = *change.rating } - api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) - encodedID := dto.EncodeID(id) - api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) + if change.favorite != nil { + if err := item.repo.SetStar(ctx, *change.favorite, id); err != nil { + api.internalError(w, r, err) + return + } + item.ann.Starred = *change.favorite + } + if change.rating != nil || change.favorite != nil { + api.broker.SendMessage(ctx, (&events.RefreshResource{}).With(item.resource, id)) + } + api.ok(w, r, dto.UserData(item.ann, id)) +} + +// applyCallerUserData is applyUserData for routes that may name a userId: only the caller's own +// annotations can be written, so naming another user is refused. Undecodable ids are ignored. +func (api *Router) applyCallerUserData(w http.ResponseWriter, r *http.Request, change userDataChange) { + raw := chi.URLParam(r, "userId") + if raw == "" { + raw = r.URL.Query().Get("userid") + } + if target, ok := dto.DecodeID(raw); ok { + if u, _ := request.UserFrom(r.Context()); target != u.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + api.applyUserData(w, r, change) +} + +func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { + api.applyUserData(w, r, userDataChange{favorite: new(true)}) } -func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) } func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { - api.setFavorite(w, r, false) + api.applyUserData(w, r, userDataChange{favorite: new(false)}) } -func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - repo, resource := api.resolveAnnotated(w, r, id) - if repo == nil { - return - } - if err := repo.SetRating(r.Context(), rating, id); err != nil { - api.internalError(w, r, err) - return - } - api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) - encodedID := dto.EncodeID(id) - d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} - if rating > 0 { - jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData - d.Rating = &jfRating - } - api.ok(w, r, d) -} - -// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to -// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since -// SetRating(0) is the delete path. +// setRating reads Jellyfin's likes flag, or the numeric rating extension older clients send. Unlike +// Jellyfin, a missing or malformed value is a 400 rather than a clear: clearing is DELETE's job. func (api *Router) setRating(w http.ResponseWriter, r *http.Request) { - jfRating := req.Params(r).Float64Or("rating", 0) - jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating - rating := int(math.Round(jfRating / 2)) - if jfRating > 0 { - rating = max(rating, 1) + q := r.URL.Query() + var stars int + switch { + case q.Has("likes"): + likes, err := strconv.ParseBool(q.Get("likes")) + if err != nil { + http.Error(w, "likes must be true or false", http.StatusBadRequest) + return + } + stars = dto.StarsFromLikes(likes) + case q.Has("rating"): + rating, err := strconv.ParseFloat(q.Get("rating"), 64) + s, ok := dto.StarsFromRating(rating) + if err != nil || !ok { + http.Error(w, "rating must be a number from 0 to 10", http.StatusBadRequest) + return + } + stars = s + default: + http.Error(w, "likes is required", http.StatusBadRequest) + return } - api.setItemRating(w, r, rating) + api.applyCallerUserData(w, r, userDataChange{rating: &stars}) } func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) { - api.setItemRating(w, r, 0) + api.applyCallerUserData(w, r, userDataChange{rating: new(0)}) +} + +// updateUserItemData writes Rating, Likes and IsFavorite; omitted or null fields are left untouched. +// Play state is ignored because it only changes through playback reporting. +func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) { + body, ok := decodeUserDataBody(w, r) + if !ok { + return + } + change := userDataChange{favorite: body.IsFavorite} + // Jellyfin applies Likes before Rating, so Rating wins when a body carries both. + switch { + case body.Rating != nil: + stars, ok := dto.StarsFromRating(*body.Rating) + if !ok { + http.Error(w, "Rating must be from 0 to 10", http.StatusBadRequest) + return + } + change.rating = &stars + case body.Likes != nil: + change.rating = new(dto.StarsFromLikes(*body.Likes)) + } + api.applyCallerUserData(w, r, change) +} + +const maxUserDataBody = 64 << 10 + +// decodeUserDataBody accepts exactly one JSON object; Decode alone would ignore anything after it. +func decodeUserDataBody(w http.ResponseWriter, r *http.Request) (*dto.UpdateUserItemDataDto, bool) { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxUserDataBody)) + var body *dto.UpdateUserItemDataDto + err := dec.Decode(&body) + if err == nil && body != nil { + if _, err = dec.Token(); errors.Is(err, io.EOF) { + return body, true + } + } + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, false } diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index a436503ba..69d73ed05 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" + "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -21,7 +23,7 @@ var _ = Describe("Annotations", func() { var broker *fakeEventBroker // alice has access to library 1 only. ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + return request.WithUser(GinkgoT().Context(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) } BeforeEach(func() { @@ -30,6 +32,16 @@ var _ = Describe("Annotations", func() { api = &Router{ds: ds, broker: broker} }) + songWith := func(ann model.Annotations) *tests.MockMediaFileRepo { + mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: ann}}) + return mfRepo + } + songReq := func(method, target, body string, params ...string) *http.Request { + r := httptest.NewRequest(method, target, strings.NewReader(body)).WithContext(ctxUser()) + return withChiURLParam(r, append([]string{"itemId", dto.EncodeID(testID("s1"))}, params...)...) + } + Describe("markFavorite / unmarkFavorite", func() { It("stars a song and returns IsFavorite=true", func() { mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) @@ -268,41 +280,275 @@ var _ = Describe("Annotations", func() { Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) }) - It("clamps a Rating above 10 to Navidrome's max (5)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) - }) - - It("clamps a negative Rating to Navidrome's min (0)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) - }) - It("emits a refreshResource event when rating a song", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) + songWith(model.Annotations{}) + invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", "")) Expect(broker.Events).To(HaveLen(1)) Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) }) + + Context("Jellyfin likes semantics", func() { + It("stores likes=true as 5 stars and reports Rating 10 / Likes true", func() { + mfRepo := songWith(model.Annotations{}) + w := httptest.NewRecorder() + invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", "")) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) + d := parseUserData(w) + Expect(*d.Rating).To(Equal(10.0)) + Expect(*d.Likes).To(BeTrue()) + }) + + It("stores likes=false as 1 star (Jellyfin's dislike is Rating 1), not a clear", func() { + mfRepo := songWith(model.Annotations{Rating: 4}) + w := httptest.NewRecorder() + invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?Likes=False", "")) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1)) + d := parseUserData(w) + Expect(*d.Rating).To(Equal(2.0)) + Expect(*d.Likes).To(BeFalse()) + }) + + It("prefers likes over the numeric rating extension when both are sent", func() { + mfRepo := songWith(model.Annotations{}) + w := httptest.NewRecorder() + invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=false&rating=10", "")) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1)) + }) + + It("still clears on an explicit rating=0 from numeric clients", func() { + mfRepo := songWith(model.Annotations{Rating: 4}) + w := httptest.NewRecorder() + invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?rating=0", "")) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) + Expect(parseUserData(w).Rating).To(BeNil()) + }) + + It("keeps the favorite and play state and reports them in the response", func() { + playDate := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 3, PlayDate: &playDate}) + w := httptest.NewRecorder() + invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", "")) + Expect(w.Code).To(Equal(http.StatusOK)) + song := mfRepo.Data[testID("s1")] + Expect(song.Starred).To(BeTrue()) + Expect(song.PlayCount).To(Equal(int64(3))) + Expect(song.PlayDate).To(Equal(&playDate)) + d := parseUserData(w) + Expect(d.IsFavorite).To(BeTrue()) + Expect(d.PlayCount).To(Equal(3)) + Expect(d.Played).To(BeTrue()) + }) + + DescribeTable("rejects a missing or malformed value without touching the stored rating", + func(query string) { + mfRepo := songWith(model.Annotations{Rating: 4}) + w := httptest.NewRecorder() + invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating"+query, "")) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + Expect(broker.Events).To(BeEmpty()) + }, + Entry("no params (Jellyfin would clear)", ""), + Entry("unrelated params only", "?userId=x&foo=1"), + Entry("empty likes", "?likes="), + Entry("non-boolean likes", "?likes=maybe"), + Entry("non-boolean likes even with a valid rating", "?likes=maybe&rating=8"), + Entry("empty rating", "?rating="), + Entry("non-numeric rating", "?rating=abc"), + Entry("NaN rating", "?rating=NaN"), + Entry("infinite rating", "?rating=Inf"), + Entry("rating above 10", "?Rating=100"), + Entry("negative rating", "?Rating=-5"), + ) + }) + + Context("authorization", func() { + DescribeTable("refuses a userId naming another user and leaves the rating", + func(method, target string, params ...string) { + handler := api.setRating + if method == "DELETE" { + handler = api.removeRating + } + mfRepo := songWith(model.Annotations{Rating: 4}) + w := httptest.NewRecorder() + invoke(handler, w, songReq(method, target, "", params...)) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + Expect(broker.Events).To(BeEmpty()) + }, + Entry("path userId", "POST", "/Users/u2/Items/s1/Rating?likes=true", "userId", dto.EncodeID(testID("u2"))), + Entry("query userId on the /UserItems form", "POST", "/UserItems/s1/Rating?likes=true&userId="+dto.EncodeID(testID("u2"))), + Entry("clearing", "DELETE", "/Users/u2/Items/s1/Rating", "userId", dto.EncodeID(testID("u2"))), + ) + + It("accepts the caller's own userId", func() { + mfRepo := songWith(model.Annotations{}) + w := httptest.NewRecorder() + invoke(api.setRating, w, songReq("POST", "/Users/u1/Items/s1/Rating?likes=true", "", "userId", dto.EncodeID(testID("u1")))) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) + }) + }) + }) + + Describe("updateUserItemData", func() { + post := func(body string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + invoke(api.updateUserItemData, w, songReq("POST", "/UserItems/s1/UserData", body, params...)) + return w + } + + It("maps a 0-10 Rating to stars and answers with the full UserData", func() { + mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 2}) + w := post(`{"Rating":8}`) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + d := parseUserData(w) + Expect(*d.Rating).To(Equal(8.0)) + Expect(*d.Likes).To(BeTrue()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(d.PlayCount).To(Equal(2)) + Expect(d.ItemId).To(Equal(dto.EncodeID(testID("s1")))) + Expect(broker.Events).To(HaveLen(1)) + }) + + DescribeTable("stores the personal rating", + func(body string, before, expected int) { + mfRepo := songWith(model.Annotations{Rating: before}) + Expect(post(body).Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(expected)) + }, + Entry("Likes true", `{"Likes":true}`, 0, 5), + Entry("Likes false (Rating 1)", `{"Likes":false}`, 4, 1), + Entry("Rating wins over Likes, as Jellyfin applies Likes first", `{"Likes":true,"Rating":2}`, 0, 1), + Entry("fractional Rating rounds to the nearest star", `{"Rating":7.5}`, 0, 4), + Entry("small nonzero Rating floors at one star", `{"Rating":0.4}`, 0, 1), + Entry("Rating 0 clears", `{"Rating":0}`, 4, 0), + ) + + DescribeTable("leaves the rating untouched when Rating and Likes are omitted or null", + func(body string, wantEvents int) { + mfRepo := songWith(model.Annotations{Rating: 4}) + w := post(body) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + Expect(*parseUserData(w).Rating).To(Equal(8.0)) + Expect(broker.Events).To(HaveLen(wantEvents)) + }, + Entry("empty object writes nothing", `{}`, 0), + Entry("explicit nulls write nothing", `{"Rating":null,"Likes":null,"IsFavorite":null}`, 0), + Entry("favorite only", `{"IsFavorite":true}`, 1), + ) + + It("sets and unsets the favorite without touching the rating", func() { + mfRepo := songWith(model.Annotations{Rating: 4}) + Expect(post(`{"IsFavorite":true}`).Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue()) + Expect(post(`{"IsFavorite":false}`).Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + }) + + It("ignores play-state fields, which only change through playback reporting", func() { + mfRepo := songWith(model.Annotations{PlayCount: 2}) + w := post(`{"Played":true,"PlayCount":99,"LastPlayedDate":"2026-01-01T00:00:00Z","PlaybackPositionTicks":5}`) + Expect(w.Code).To(Equal(http.StatusOK)) + song := mfRepo.Data[testID("s1")] + Expect(song.PlayCount).To(Equal(int64(2))) + Expect(song.PlayDate).To(BeNil()) + Expect(broker.Events).To(BeEmpty()) + }) + + It("leaves state unchanged when a client echoes back the UserData it read", func() { + mfRepo := songWith(model.Annotations{Rating: 3, Starred: true, PlayCount: 1}) + echo, err := json.Marshal(dto.UserData(mfRepo.Data[testID("s1")].Annotations, testID("s1"))) + Expect(err).ToNot(HaveOccurred()) + Expect(post(string(echo)).Code).To(Equal(http.StatusOK)) + song := mfRepo.Data[testID("s1")] + Expect(song.Rating).To(Equal(3)) + Expect(song.Starred).To(BeTrue()) + Expect(song.PlayCount).To(Equal(int64(1))) + }) + + DescribeTable("rejects an invalid body without writing anything", + func(body string) { + mfRepo := songWith(model.Annotations{Rating: 4}) + Expect(post(body).Code).To(Equal(http.StatusBadRequest)) + song := mfRepo.Data[testID("s1")] + Expect(song.Rating).To(Equal(4)) + Expect(song.Starred).To(BeFalse()) + Expect(broker.Events).To(BeEmpty()) + }, + Entry("empty body", ""), + Entry("malformed JSON", `{"Rating":`), + Entry("Rating above 10, even with a valid IsFavorite", `{"IsFavorite":true,"Rating":10.5}`), + Entry("negative Rating", `{"IsFavorite":true,"Rating":-1}`), + Entry("Rating as a string", `{"Rating":"8"}`), + Entry("Likes as a string", `{"Likes":"yes"}`), + Entry("IsFavorite as a number", `{"IsFavorite":1}`), + Entry("JSON null", `null`), + Entry("JSON array", `[]`), + Entry("trailing garbage after the object", `{"Rating":8} x`), + Entry("a second JSON value after the object", `{"Rating":8}{"Rating":2}`), + Entry("a second object after whitespace", "{\"Rating\":8}\n{}"), + ) + + It("accepts trailing whitespace after the object", func() { + mfRepo := songWith(model.Annotations{}) + Expect(post("{\"Rating\":8}\r\n\t ").Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + }) + + It("rejects a body larger than the limit", func() { + mfRepo := songWith(model.Annotations{Rating: 4}) + big := `{"Rating":8,"Pad":"` + strings.Repeat("x", 70<<10) + `"}` + Expect(post(big).Code).To(Equal(http.StatusBadRequest)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + }) + + Context("authorization", func() { + It("refuses a userId naming another user without writing", func() { + mfRepo := songWith(model.Annotations{Rating: 4}) + w := post(`{"Rating":10,"IsFavorite":true}`, "userId", dto.EncodeID(testID("u2"))) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) + Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) + }) + + It("accepts the caller's own userId", func() { + songWith(model.Annotations{}) + Expect(post(`{"Rating":10}`, "userId", dto.EncodeID(testID("u1"))).Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 and writes nothing for an album in a library the user can't access", func() { + albumRepo := ds.Album().(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/UserItems/a1/UserData", strings.NewReader(`{"Rating":10,"IsFavorite":true}`)).WithContext(ctxUser()) + invoke(api.updateUserItemData, w, withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0)) + Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse()) + }) + + It("returns 404 for an unknown item", func() { + w := post(`{"Rating":10}`) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) }) }) +func parseUserData(w *httptest.ResponseRecorder) dto.UserItemDataDto { + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + return d +} + type fakeEventBroker struct { http.Handler Events []events.Event diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 4a471d1f0..b9fd32150 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler { r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite) r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite) r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/useritems/{itemId}/rating", api.setRating) + r.Delete("/useritems/{itemId}/rating", api.removeRating) r.Post("/users/{userId}/items/{itemId}/rating", api.setRating) r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating) @@ -153,6 +155,8 @@ func (api *Router) routes() http.Handler { // /Users/{userId}/Items is the legacy spelling. r.Get("/useritems/{itemId}/userdata", api.getUserItemData) r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData) + r.Post("/useritems/{itemId}/userdata", api.updateUserItemData) + r.Post("/users/{userId}/items/{itemId}/userdata", api.updateUserItemData) r.Get("/artists/{itemId}/similar", api.getSimilarArtists) r.Get("/items/{itemId}/similar", api.getSimilarItems) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 4a28eb313..318011d89 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -40,6 +40,14 @@ type UserItemDataDto struct { Key string `json:"Key"` ItemId string `json:"ItemId,omitempty"` LastPlayedDate *string `json:"LastPlayedDate,omitempty"` + Likes *bool `json:"Likes,omitempty"` +} + +// UpdateUserItemDataDto is the POST .../UserData body; other Jellyfin fields are accepted and ignored. +type UpdateUserItemDataDto struct { + Rating *float64 `json:"Rating"` + Likes *bool `json:"Likes"` + IsFavorite *bool `json:"IsFavorite"` } type BaseItemDto struct { diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index fc7e329de..620df783f 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -3,6 +3,7 @@ package dto import ( "cmp" "fmt" + "math" "time" "github.com/navidrome/navidrome/conf" @@ -121,6 +122,30 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { } } +// minLikeRating is Jellyfin's UserItemData.MinLikeValue: a rating at or above it reads as Likes. +const minLikeRating = 6.5 + +// StarsFromRating maps Jellyfin's 0-10 rating to 0-5 stars, rejecting out-of-range values and NaN. +// A nonzero rating floors at one star, since 0 stars means "not rated". +func StarsFromRating(rating float64) (int, bool) { + if !(rating >= 0 && rating <= 10) { + return 0, false + } + stars := int(math.Round(rating / 2)) + if rating > 0 { + stars = max(stars, 1) + } + return stars, true +} + +// StarsFromLikes mirrors Jellyfin, which stores likes as Rating 10 and dislikes as Rating 1. +func StarsFromLikes(likes bool) int { + if likes { + return 5 + } + return 1 +} + func UserData(a model.Annotations, itemID string) *UserItemDataDto { // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto. encodedID := EncodeID(itemID) @@ -134,6 +159,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto { if a.Rating > 0 { r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 d.Rating = &r + d.Likes = new(r >= minLikeRating) } if s := JellyfinDate(a.PlayDate); s != "" { d.LastPlayedDate = &s diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 259904673..3e4ba941f 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -478,6 +478,30 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes).To(BeNil()) }) + DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)", + func(stars int, expected *bool) { + d := UserData(model.Annotations{Rating: stars}, testID("s1")) + Expect(d.Likes).To(Equal(expected)) + }, + Entry("unrated has no Likes", 0, nil), + Entry("1 star (Jellyfin 2) dislikes", 1, new(false)), + Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)), + Entry("4 stars (Jellyfin 8) likes", 4, new(true)), + Entry("5 stars (Jellyfin 10) likes", 5, new(true)), + ) + + It("emits Rating as a whole number and omits Likes when unrated", func() { + rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1"))) + Expect(err).ToNot(HaveOccurred()) + Expect(string(rated)).To(ContainSubstring(`"Rating":8,`)) + Expect(string(rated)).To(ContainSubstring(`"Likes":true`)) + + unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1"))) + Expect(err).ToNot(HaveOccurred()) + Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`)) + Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`)) + }) + It("emits DateCreated and SortName on playlists, like albums and artists", func() { p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)} Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go index 182fd5436..014751f6e 100644 --- a/server/jellyfin/e2e/annotations_test.go +++ b/server/jellyfin/e2e/annotations_test.go @@ -130,13 +130,78 @@ var _ = Describe("Annotations", func() { Expect(*itemUserData(id).Rating).To(Equal(float64(8))) }) - It("clamps an out-of-range rating to the valid domain", func() { + It("rejects an out-of-range rating and keeps the stored one", func() { id := albumID("Help!") + Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK)) + Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", "").Code).To(Equal(http.StatusBadRequest)) + Expect(*itemUserData(id).Rating).To(Equal(float64(8))) + }) + + It("stores Jellyfin likes on the current /UserItems route instead of clearing", func() { + id := songID("So What") + var liked dto.UserItemDataDto + parseInto(post("/UserItems/"+enc(id)+"/Rating?likes=true", ""), &liked) + Expect(*liked.Rating).To(Equal(float64(10))) + Expect(*liked.Likes).To(BeTrue()) + + var disliked dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?likes=false", ""), &disliked) + Expect(*disliked.Rating).To(Equal(float64(2))) + Expect(*disliked.Likes).To(BeFalse()) + Expect(*itemUserData(id).Likes).To(BeFalse()) + + Expect(del("/UserItems/" + enc(id) + "/Rating").Code).To(Equal(http.StatusOK)) + Expect(itemUserData(id).Rating).To(BeNil()) + Expect(itemUserData(id).Likes).To(BeNil()) + }) + + It("does not clear a rating when the request has no value", func() { + id := songID("So What") + Expect(post("/UserItems/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusOK)) + Expect(post("/UserItems/"+enc(id)+"/Rating", "").Code).To(Equal(http.StatusBadRequest)) + Expect(post("/UserItems/"+enc(id)+"/Rating?likes=sure", "").Code).To(Equal(http.StatusBadRequest)) + Expect(*itemUserData(id).Rating).To(Equal(float64(10))) + }) + }) + + Describe("POST /UserItems/{id}/UserData", func() { + It("sets Rating, Likes and IsFavorite on both route spellings", func() { + id := songID("Come Together") var data dto.UserItemDataDto - parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) - // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. - Expect(data.Rating).ToNot(BeNil()) - Expect(*data.Rating).To(Equal(float64(10))) + parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data) + Expect(*data.Rating).To(Equal(float64(6))) + Expect(*data.Likes).To(BeFalse()) + + parseInto(post("/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Likes":true,"IsFavorite":true}`), &data) + stored := itemUserData(id) + Expect(*stored.Rating).To(Equal(float64(10))) + Expect(*stored.Likes).To(BeTrue()) + Expect(stored.IsFavorite).To(BeTrue()) + }) + + It("leaves omitted fields and play state untouched", func() { + id := songID("Come Together") + Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8,"IsFavorite":true}`).Code).To(Equal(http.StatusOK)) + before := itemUserData(id) + + Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Played":true,"PlayCount":42,"LastPlayedDate":"2026-01-01T00:00:00Z"}`).Code).To(Equal(http.StatusOK)) + Expect(post("/UserItems/"+enc(id)+"/UserData", `{"IsFavorite":false}`).Code).To(Equal(http.StatusOK)) + + after := itemUserData(id) + Expect(*after.Rating).To(Equal(float64(8))) + Expect(after.IsFavorite).To(BeFalse()) + Expect(after.PlayCount).To(Equal(before.PlayCount)) + Expect(after.Played).To(Equal(before.Played)) + Expect(after.LastPlayedDate).To(Equal(before.LastPlayedDate)) + }) + + It("rejects invalid bodies without writing", func() { + id := songID("Come Together") + Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":11,"IsFavorite":true}`).Code).To(Equal(http.StatusBadRequest)) + Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8} {"Rating":2}`).Code).To(Equal(http.StatusBadRequest)) + stored := itemUserData(id) + Expect(stored.Rating).To(BeNil()) + Expect(stored.IsFavorite).To(BeFalse()) }) }) }) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go index 905d4e16f..f4f2c43d2 100644 --- a/server/jellyfin/e2e/multiuser_test.go +++ b/server/jellyfin/e2e/multiuser_test.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -28,6 +29,36 @@ var _ = Describe("Multi-user access control", func() { }) }) + Describe("personal ratings", func() { + userData := func(u model.User, id string) *dto.UserItemDataDto { + var data dto.UserItemDataDto + parseInto(getAs(u, "/UserItems/"+enc(id)+"/UserData"), &data) + return &data + } + + It("keeps each user's rating separate", func() { + id := songID("So What") + Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusOK)) + Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/Rating?likes=false", "").Code).To(Equal(http.StatusOK)) + + Expect(*userData(adminUser, id).Rating).To(Equal(float64(10))) + Expect(*userData(regularUser, id).Rating).To(Equal(float64(2))) + }) + + It("refuses writes that name another user, even for an admin, and changes nothing", func() { + id := songID("So What") + Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/UserData", `{"Rating":4}`).Code).To(Equal(http.StatusOK)) + + Expect(postAs(regularUser, "/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusForbidden)) + Expect(post("/Users/"+enc(regularUser.ID)+"/Items/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusForbidden)) + Expect(del("/Users/" + enc(regularUser.ID) + "/Items/" + enc(id) + "/Rating").Code).To(Equal(http.StatusForbidden)) + Expect(post("/UserItems/"+enc(id)+"/UserData?userId="+enc(regularUser.ID), `{"Rating":10}`).Code).To(Equal(http.StatusForbidden)) + + Expect(userData(adminUser, id).Rating).To(BeNil()) + Expect(*userData(regularUser, id).Rating).To(Equal(float64(4))) + }) + }) + Describe("private playlists", func() { It("does not expose another user's private playlist", func() { adminPl := createPlaylist("Admin Private", nil) @@ -53,6 +84,7 @@ var _ = Describe("Multi-user access control", func() { adminPl := createPlaylist("Admin Private", nil) Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound)) Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound)) + Expect(postAs(regularUser, "/UserItems/"+enc(adminPl)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusNotFound)) }) It("lets each user manage their own playlist", func() { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 18789a34b..bdf0be253 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -18,11 +18,13 @@ import ( . "github.com/onsi/gomega" ) -// withChiURLParam simulates chi's routing having captured a path parameter, since these -// tests call handlers directly instead of going through the full router. -func withChiURLParam(r *http.Request, key, value string) *http.Request { +// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since +// these tests call handlers directly instead of going through the full router. +func withChiURLParam(r *http.Request, kv ...string) *http.Request { rctx := chi.NewRouteContext() - rctx.URLParams.Add(key, value) + for i := 0; i+1 < len(kv); i += 2 { + rctx.URLParams.Add(kv[i], kv[i+1]) + } return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) } diff --git a/server/middlewares.go b/server/middlewares.go index 149ddbfc3..b65a2d6e1 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -234,21 +234,16 @@ func trustedProxyPrefixes(list string) []string { // ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers // cannot be rotated for a fresh bucket. -func ClientIPRateLimiter(requestLimit int, windowLength time.Duration, opts ...httprate.Option) func(http.Handler) http.Handler { +func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { return ClientIP(r), nil - }, opts...) + }) } // ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The // peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket. func ClientIP(r *http.Request) string { - return httprate.CanonicalizeIP(ClientAddr(r)) -} - -// ClientAddr returns the client IP resolved by realIPMiddleware unmasked, for recording who made a request. -func ClientAddr(r *http.Request) string { - return cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)) + return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))) } // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 55f842914..15cf70341 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -494,35 +494,6 @@ var _ = Describe("middlewares", func() { }) }) - Describe("ClientAddr", func() { - var ctx context.Context - var addr, ip string - BeforeEach(func() { - ctx = GinkgoT().Context() - conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" - }) - call := func(h http.Handler, peer, xff string) { - r := httptest.NewRequestWithContext(ctx, "POST", "/auth/login", nil) - r.RemoteAddr = peer - r.Header.Set("X-Forwarded-For", xff) - h.ServeHTTP(httptest.NewRecorder(), r) - } - capture := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - addr, ip = ClientAddr(r), ClientIP(r) - }) - - It("returns the full resolved IPv6 address, while ClientIP keeps the /64 for rate limiting", func() { - call(realIPMiddleware(capture), "10.0.0.1:1234", "2001:db8:1:2:3:4:5:6") - Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6")) - Expect(ip).To(Equal("2001:db8:1:2::")) - }) - - It("falls back to the peer host without the middleware", func() { - call(capture, "[2001:db8:1:2:3:4:5:6]:1234", "") - Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6")) - }) - }) - Describe("ClientIPRateLimiter", func() { var handler http.Handler JustBeforeEach(func() { @@ -549,12 +520,6 @@ var _ = Describe("middlewares", func() { Entry("True-Client-IP", "True-Client-IP"), ) - It("sends the X-RateLimit headers by default", func() { - w := httptest.NewRecorder() - handler.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), "POST", "/auth/login", nil)) - Expect(w.Header().Get("X-RateLimit-Limit")).To(Equal("2")) - }) - Context("behind a trusted proxy", func() { BeforeEach(func() { conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index f1e6c4539..61013dd5a 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -22,7 +22,12 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec return nil, model.ErrNotFound } - return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID) + lib, err := ds.Library().Get(ctx, file.LibraryID) + if err != nil { + return nil, err + } + + return core.Inspect(file.AbsolutePath(), *lib, file.FolderID) } func inspect(ds model.DataStore) http.HandlerFunc { diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 37ae56c2b..46c7ca210 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -60,9 +60,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ - Format: info.format, BitRate: info.bitrate, - }) + streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0) + stream, err := pub.streamer.NewStream(ctx, mf, streamReq) if err != nil { if errors.Is(err, streampkg.ErrTooManyTranscodes) { w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 4b4a3545b..965bc7e05 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -116,11 +116,11 @@ var _ = Describe("handleStream", func() { BeforeEach(func() { ctx = GinkgoT().Context() auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - ds = &tests.MockDataStore{} + ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo streamer = &mockStreamer{} - pub = &Router{ds: ds, streamer: streamer} + pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))} }) makeRequest := func(token string) *httptest.ResponseRecorder { @@ -152,8 +152,17 @@ var _ = Describe("handleStream", func() { makeRequest(token) Expect(streamer.called).To(BeTrue()) - Expect(streamer.req.Format).To(Equal("mp3")) - Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() { + mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2} + shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf) + + claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2})) }) It("returns 404 when the track is outside the share owner's libraries", func() { diff --git a/server/public/public.go b/server/public/public.go index 142c474bd..8239ef927 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -21,14 +21,15 @@ type Router struct { http.Handler artwork artwork.Artwork streamer stream.MediaStreamer + decider stream.TranscodeDecider archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { - p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router { + p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) p.Handler = p.routes() diff --git a/server/serve_index.go b/server/serve_index.go index 4b093b953..167197403 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, + "pidAlbum": conf.Server.PID.Album, + "pidTrack": conf.Server.PID.Track, "enableNowPlaying": conf.Server.EnableNowPlaying, "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e2df55c4b..277513768 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -89,6 +89,8 @@ var _ = Describe("serveIndex", func() { Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), + Entry("pidAlbum", func() { conf.Server.PID.Album = "folder" }, "pidAlbum", "folder"), + Entry("pidTrack", func() { conf.Server.PID.Track = "title" }, "pidTrack", "title"), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true), diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 9394c8830..530588759 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -137,7 +137,7 @@ var _ = Describe("Artwork Serving", Ordered, func() { artRouter = buildArtworkRouter(artSvc) router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router - pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + pubRouter = public.New(ds, artSvc, streamerSpy, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{}) }) It("emits a bare optimistic coverArt id before the queue is drained", func() { diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 79b8529b4..a5e4126cf 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -30,7 +30,6 @@ type MockDataStore struct { MockedPlugin model.PluginRepository MockedArtwork model.ArtworkRepository MockedArtworkQueue model.ArtworkQueueRepository - MockedGrant model.GrantRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -322,19 +321,6 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository { return db.MockedArtworkQueue } -func (db *MockDataStore) Grant() model.GrantRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedGrant != nil { - return db.MockedGrant - } - if db.RealDS != nil { - return db.RealDS.Grant() - } - db.MockedGrant = &MockedGrantRepo{} - return db.MockedGrant -} - func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_grant_repo.go b/tests/mock_grant_repo.go deleted file mode 100644 index be9134ece..000000000 --- a/tests/mock_grant_repo.go +++ /dev/null @@ -1,8 +0,0 @@ -package tests - -import "github.com/navidrome/navidrome/model" - -// MockedGrantRepo exists so MockDataStore satisfies DataStore; auth tests use a real database. -type MockedGrantRepo struct { - model.GrantRepository -} diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index e21dcccce..6de2b9265 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -145,6 +145,17 @@ func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error { return nil } +func (m *MockLibraryRepo) SetScannedPID(_ context.Context, id int, pid model.PIDConfig) error { + if m.Err != nil { + return m.Err + } + if lib, ok := m.Data[id]; ok { + lib.ScannedPIDAlbum, lib.ScannedPIDTrack = pid.Album, pid.Track + m.Data[id] = lib + } + return nil +} + func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) { if m.Err != nil { return false, m.Err diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx index 075841e43..1de904031 100644 --- a/ui/src/album/AlbumInfo.jsx +++ b/ui/src/album/AlbumInfo.jsx @@ -8,7 +8,6 @@ import { ArrayField, BooleanField, ChipField, - DateField, FunctionField, SingleFieldList, TextField, @@ -17,6 +16,7 @@ import { } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import { + DateField, ArtistLinkField, MultiLineTextField, ParticipantsInfo, diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index 051d5988e..46a86c6bc 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -3,7 +3,6 @@ import { Datagrid, DatagridBody, DatagridRow, - DateField, NumberField, TextField, FunctionField, @@ -13,6 +12,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { + DateField, ArtistLinkField, ArtworkAvatar, DurationField, diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx index dac9cff08..8aab34ae1 100644 --- a/ui/src/common/DateField.jsx +++ b/ui/src/common/DateField.jsx @@ -1,12 +1,12 @@ import React from 'react' import { isDateSet } from '../utils/validations' -import { DateField as RADateField } from 'react-admin' +import { DateField as RADateField, useRecordContext } from 'react-admin' import { useDateLocale } from '../i18n/useDateLocale' export const DateField = (props) => { - const { record, source } = props + const record = useRecordContext(props) const locale = useDateLocale() - const value = record?.[source] + const value = record?.[props.source] if (!isDateSet(value)) return null return } diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx index 47d56756b..7494b7887 100644 --- a/ui/src/common/DateField.test.jsx +++ b/ui/src/common/DateField.test.jsx @@ -1,6 +1,7 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, beforeEach, vi } from 'vitest' +import { RecordContextProvider } from 'react-admin' import { DateField } from './DateField' vi.mock('react-admin', async (importOriginal) => ({ @@ -29,4 +30,13 @@ describe('', () => { ) expect(container).toBeEmptyDOMElement() }) + + it('reads the record from the record context', () => { + render( + + + , + ) + expect(screen.getByText('17.9.2026')).toBeInTheDocument() + }) }) diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx index 1b1a014f1..b9f7bea8c 100644 --- a/ui/src/common/SongInfo.jsx +++ b/ui/src/common/SongInfo.jsx @@ -6,7 +6,6 @@ import TableContainer from '@material-ui/core/TableContainer' import TableRow from '@material-ui/core/TableRow' import { BooleanField, - DateField, TextField, NumberField, FunctionField, @@ -15,6 +14,7 @@ import { } from 'react-admin' import { humanize, underscore } from 'inflection' import { + DateField, ArtistLinkField, BitrateField, ParticipantsInfo, diff --git a/ui/src/config.js b/ui/src/config.js index e406e47cf..62b3cb822 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -32,6 +32,8 @@ const defaultConfig = { listenBrainzEnabled: true, enableExternalServices: true, enableCoverAnimation: true, + pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID + pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID enableNowPlaying: true, playbackReportIntervalMs: 60000, devShowArtistPage: true, diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index a04b6e311..8f086f516 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -331,11 +331,22 @@ "totalDuration": "Duration", "defaultNewUsers": "Default for New Users", "createdAt": "Created", - "updatedAt": "Updated" + "updatedAt": "Updated", + "pidAlbum": "Album grouping", + "pidTrack": "Track identity" }, "sections": { "basic": "Basic Information", - "statistics": "Statistics" + "statistics": "Statistics", + "pid": "Persistent IDs" + }, + "pid": { + "global": "Use global setting (%{value})", + "folder": "Folder (one album per folder)", + "custom": "Custom", + "spec": "PID spec", + "help": "Tags and attributes that identify an item. See the documentation for the syntax:", + "docs": "Persistent IDs" }, "actions": { "scan": "Scan Library", @@ -365,7 +376,9 @@ "messages": { "deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.", "scanInProgress": "Scan in progress...", - "noLibrariesAssigned": "No libraries assigned to this user" + "noLibrariesAssigned": "No libraries assigned to this user", + "pidChangeTitle": "Change persistent IDs?", + "pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one." } }, "plugin": { @@ -664,6 +677,11 @@ "options": { "theme": "Theme", "language": "Language", + "dateFormat": "Date Format", + "dateFormats": { + "language": "Match language", + "browser": "Match browser" + }, "defaultView": "Default View", "desktop_notifications": "Desktop Notifications", "lastfmNotConfigured": "Last.fm API-Key is not configured", diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js index c9298a473..f02402f29 100644 --- a/ui/src/i18n/useDateLocale.js +++ b/ui/src/i18n/useDateLocale.js @@ -1,5 +1,8 @@ import { useLocale } from 'react-admin' +export const getStoredDateFormat = () => + localStorage.getItem('dateFormat') || 'language' + // Our language codes are mostly region-less ("en"), and Intl reads a bare "en" // as en-US. Borrow the region from the browser when it speaks the same language. const resolveDateLocale = (locale, browserLocales = []) => { @@ -10,5 +13,10 @@ const resolveDateLocale = (locale, browserLocales = []) => { ) } -export const useDateLocale = () => - resolveDateLocale(useLocale(), navigator.languages) +// With the "browser" date format, return undefined so Intl uses the browser's +// own locale, regardless of the selected language. +export const useDateLocale = () => { + const locale = useLocale() + if (getStoredDateFormat() === 'browser') return undefined + return resolveDateLocale(locale, navigator.languages) +} diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js index c0ea5ab40..16707a084 100644 --- a/ui/src/i18n/useDateLocale.test.js +++ b/ui/src/i18n/useDateLocale.test.js @@ -9,6 +9,7 @@ vi.mock('react-admin', () => ({ describe('useDateLocale', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.removeItem('dateFormat') }) const renderWith = async (locale, browserLocales) => { @@ -42,4 +43,10 @@ describe('useDateLocale', () => { const result = await renderWith(undefined, ['en-GB']) expect(result.current).toBeUndefined() }) + + it('returns undefined to use the browser locale when set to browser', async () => { + localStorage.setItem('dateFormat', 'browser') + const result = await renderWith('en', ['fr-FR']) + expect(result.current).toBeUndefined() + }) }) diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx index 0e69964b6..8166bb2f3 100644 --- a/ui/src/library/LibraryCreate.jsx +++ b/ui/src/library/LibraryCreate.jsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react' +import PropTypes from 'prop-types' import { Create, SimpleForm, @@ -10,7 +11,34 @@ import { useNotify, useRedirect, } from 'react-admin' +import { Typography } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' import { Title } from '../common' +import { PIDInputs } from './PIDInput' + +const useStyles = makeStyles((theme) => ({ + spaced: { marginTop: theme.spacing(3) }, +})) + +// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly +const SectionTitle = ({ label, spaced }) => { + const translate = useTranslate() + const classes = useStyles() + return ( + + {translate(label)} + + ) +} + +SectionTitle.propTypes = { + label: PropTypes.string.isRequired, + spaced: PropTypes.bool, +} const LibraryCreate = (props) => { const translate = useTranslate() @@ -73,9 +101,12 @@ const LibraryCreate = (props) => { return ( } {...props}> + + + ) diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index 53d17ac7f..c42c7ac4b 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -1,9 +1,11 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useState } from 'react' +import PropTypes from 'prop-types' import { Edit, FormWithRedirect, TextInput, BooleanInput, + Confirm, required, SaveButton, useTranslate, @@ -22,6 +24,9 @@ import { ReadOnlySizeField, Title, } from '../common' +import config from '../config' +import { PIDInputs } from './PIDInput' +import { pidConfigChanged } from './pidPresets' const useStyles = makeStyles({ toolbar: { @@ -53,8 +58,131 @@ const CustomToolbar = ({ showDelete, ...props }) => ( ) -const LibraryEdit = (props) => { +export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => { const translate = useTranslate() + const [confirmOpen, setConfirmOpen] = useState(false) + + // Every submit path (Save button and Enter key) goes through here, so a PID change always asks first + const submit = () => { + if ( + pidConfigChanged( + formProps.form.getState().values, + formProps.record, + config, + ) + ) { + setConfirmOpen(true) + return + } + formProps.handleSubmit() + } + + const handleConfirm = () => { + setConfirmOpen(false) + formProps.handleSubmit() + } + + return ( +
{ + event.preventDefault() + submit() + }} + > + + + + {/* Basic Information */} + + {translate('resources.library.sections.basic')} + + + + + + + + + {translate('resources.library.sections.pid')} + + + + + + {/* Statistics - Two Column Layout */} + + {translate('resources.library.sections.statistics')} + + + + + + + + + + + + + + + + + + + + + setConfirmOpen(false)} + /> + + ) +} + +LibraryEditForm.propTypes = { + formProps: PropTypes.object.isRequired, + canEditPath: PropTypes.bool, + canDelete: PropTypes.bool, +} + +const LibraryEdit = (props) => { const [mutate] = useMutation() const notify = useNotify() const redirect = useRedirect() @@ -93,91 +221,11 @@ const LibraryEdit = (props) => { {...props} save={save} render={(formProps) => ( -
- - - - {/* Basic Information */} - - {translate('resources.library.sections.basic')} - - - - - - - - - {/* Statistics - Two Column Layout */} - - {translate('resources.library.sections.statistics')} - - - - - - - - - - - - - - - - - - - - - + )} /> diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx new file mode 100644 index 000000000..926adc839 --- /dev/null +++ b/ui/src/library/LibraryEdit.test.jsx @@ -0,0 +1,125 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { + FormWithRedirect, + RecordContextProvider, + SaveContextProvider, +} from 'react-admin' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { LibraryEditForm } from './LibraryEdit' +import config from '../config' + +const record = { + id: '2', + name: 'Jazz', + path: '/music/jazz', + pidAlbum: '', + pidTrack: '', +} + +// Edit provides a save context in the app. SaveButton only reads these setters from it +const saveContext = { + save: vi.fn(), + setOnSuccess: vi.fn(), + setOnFailure: vi.fn(), + setTransform: vi.fn(), +} + +const renderForm = (save) => + render( + + + + ( + + )} + /> + + + , + ) + +const chooseAlbumGrouping = (optionText) => { + fireEvent.mouseDown( + screen.getByLabelText('resources.library.fields.pidAlbum'), + ) + fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText)) +} + +const dialogTitle = 'resources.library.messages.pidChangeTitle' + +describe('LibraryEditForm', () => { + afterEach(cleanup) + + it('saves directly when the PID config did not change', async () => { + const save = vi.fn() + renderForm(save) + fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), { + target: { value: 'Jazz Renamed' }, + }) + fireEvent.click(screen.getByText('ra.action.save')) + await waitFor(() => expect(save).toHaveBeenCalled()) + expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument() + }) + + it('asks before saving a PID change, and Cancel keeps the edits', async () => { + const save = vi.fn() + renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.click(screen.getByText('ra.action.save')) + + expect(await screen.findByText(dialogTitle)).toBeInTheDocument() + expect(save).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('ra.action.cancel')) + await waitFor(() => + expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(), + ) + expect(save).not.toHaveBeenCalled() + expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument() + }) + + it('saves the PID change after Confirm', async () => { + const save = vi.fn() + renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.click(screen.getByText('ra.action.save')) + fireEvent.click(await screen.findByText('ra.action.confirm')) + + await waitFor(() => expect(save).toHaveBeenCalled()) + expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' }) + }) + + it('pre-fills a Custom spec with the global spec', () => { + renderForm(vi.fn()) + chooseAlbumGrouping('resources.library.pid.custom') + expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue( + config.pidAlbum, + ) + }) + + it('asks before saving when the form is submitted with Enter', async () => { + const save = vi.fn() + const { container } = renderForm(save) + chooseAlbumGrouping('resources.library.pid.folder') + fireEvent.submit(container.querySelector('form')) + + expect(await screen.findByText(dialogTitle)).toBeInTheDocument() + expect(save).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/library/PIDInput.jsx b/ui/src/library/PIDInput.jsx new file mode 100644 index 000000000..6481dc392 --- /dev/null +++ b/ui/src/library/PIDInput.jsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { TextInput, required, useTranslate } from 'react-admin' +import { useField } from 'react-final-form' +import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { + PID_CUSTOM, + PID_FOLDER, + PID_GLOBAL, + pidModeFromValue, + pidValueForMode, +} from './pidPresets' +import config from '../config' +import { docsUrl } from '../utils' + +const PID_DOCS_URL = docsUrl('/docs/usage/pids/') + +const useStyles = makeStyles((theme) => ({ + help: { marginBottom: theme.spacing(1) }, +})) + +// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec +export const PIDInput = ({ source, label, globalValue, allowFolder }) => { + const translate = useTranslate() + const classes = useStyles() + const { input } = useField(source) + // Local state, so choosing Custom shows the text box before anything is typed + const [mode, setMode] = useState(() => + pidModeFromValue(input.value, allowFolder), + ) + + const choices = [ + { + id: PID_GLOBAL, + name: translate('resources.library.pid.global', { value: globalValue }), + }, + ...(allowFolder + ? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }] + : []), + { id: PID_CUSTOM, name: translate('resources.library.pid.custom') }, + ] + + const handleModeChange = (event) => { + const newMode = event.target.value + setMode(newMode) + input.onChange(pidValueForMode(newMode, globalValue)) + } + + return ( + <> + + {choices.map((choice) => ( + + {choice.name} + + ))} + + {mode === PID_CUSTOM && ( + <> + + + {translate('resources.library.pid.help')}{' '} + + {translate('resources.library.pid.docs')} + + + + )} + + ) +} + +PIDInput.propTypes = { + source: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + globalValue: PropTypes.string, + allowFolder: PropTypes.bool, +} + +export const PIDInputs = () => { + const translate = useTranslate() + return ( + <> + + + + ) +} diff --git a/ui/src/library/pidPresets.js b/ui/src/library/pidPresets.js new file mode 100644 index 000000000..0483fc691 --- /dev/null +++ b/ui/src/library/pidPresets.js @@ -0,0 +1,33 @@ +export const PID_GLOBAL = 'global' +export const PID_FOLDER = 'folder' +export const PID_CUSTOM = 'custom' + +export const pidModeFromValue = (value, allowFolder) => { + const v = (value || '').trim() + if (v === '') return PID_GLOBAL + if (allowFolder && v === PID_FOLDER) return PID_FOLDER + return PID_CUSTOM +} + +// Returns the value to store for a dropdown choice. Custom starts from the global spec +export const pidValueForMode = (mode, globalValue) => { + switch (mode) { + case PID_GLOBAL: + return '' + case PID_FOLDER: + return PID_FOLDER + default: + return globalValue || '' + } +} + +// Reports whether the form values change the effective PID spec of the saved record. Like the +// server, it trims, treats empty as the global value and compares case-insensitively +export const pidConfigChanged = (values, record, globals) => { + const effective = (value, field) => + ((value || '').trim() || globals[field] || '').toLowerCase() + return ['pidAlbum', 'pidTrack'].some( + (field) => + effective(values[field], field) !== effective(record[field], field), + ) +} diff --git a/ui/src/library/pidPresets.test.js b/ui/src/library/pidPresets.test.js new file mode 100644 index 000000000..dab1c82e2 --- /dev/null +++ b/ui/src/library/pidPresets.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { + PID_CUSTOM, + PID_FOLDER, + PID_GLOBAL, + pidConfigChanged, + pidModeFromValue, + pidValueForMode, +} from './pidPresets' + +describe('pidModeFromValue', () => { + it('maps an empty value to the global setting', () => { + expect(pidModeFromValue('', true)).toBe(PID_GLOBAL) + expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL) + }) + it('maps folder to the Folder preset when allowed', () => { + expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER) + }) + it('maps folder to Custom when the Folder preset is not offered', () => { + expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM) + }) + it('maps any other value to Custom', () => { + expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM) + }) +}) + +describe('pidValueForMode', () => { + it('stores an empty value for the global setting', () => { + expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('') + }) + it('stores folder for the Folder preset', () => { + expect(pidValueForMode(PID_FOLDER, '')).toBe('folder') + }) + it('starts Custom from the global spec', () => { + expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title') + expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('') + }) +}) + +describe('pidConfigChanged', () => { + const record = { pidAlbum: 'folder', pidTrack: '' } + const globals = { + pidAlbum: 'musicbrainz_albumid|albumartistid,album', + pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', + } + it.each([ + ['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false], + ['a missing value equals an empty one', { pidAlbum: 'folder' }, false], + [ + 'Custom set to the global value', + { pidAlbum: 'folder', pidTrack: globals.pidTrack }, + false, + ], + ['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false], + [ + 'a whitespace-only change', + { pidAlbum: ' folder ', pidTrack: ' ' }, + false, + ], + ['the album PID changed', { pidAlbum: '', pidTrack: '' }, true], + ['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true], + ])('%s', (_, values, expected) => { + expect(pidConfigChanged(values, record, globals)).toBe(expected) + }) +}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index a09f552d9..64ab0487d 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -1,4 +1,5 @@ import { + DateField, List, Pagination, SizeField, @@ -7,7 +8,6 @@ import { } from '../common/index' import { Datagrid, - DateField, TextField, downloadCSV, Filter, diff --git a/ui/src/personal/Personal.jsx b/ui/src/personal/Personal.jsx index 84f9b63e6..75c4dd45a 100644 --- a/ui/src/personal/Personal.jsx +++ b/ui/src/personal/Personal.jsx @@ -2,6 +2,7 @@ import { SimpleForm, Title, useTranslate } from 'react-admin' import { Card } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { SelectLanguage } from './SelectLanguage' +import { SelectDateFormat } from './SelectDateFormat' import { SelectTheme } from './SelectTheme' import { SelectDefaultView } from './SelectDefaultView' import { NotificationsToggle } from './NotificationsToggle' @@ -24,6 +25,7 @@ const Personal = () => { + {config.enableReplayGain && } diff --git a/ui/src/personal/SelectDateFormat.jsx b/ui/src/personal/SelectDateFormat.jsx new file mode 100644 index 000000000..fdc9583d2 --- /dev/null +++ b/ui/src/personal/SelectDateFormat.jsx @@ -0,0 +1,22 @@ +import { SelectInput, useTranslate } from 'react-admin' +import { getStoredDateFormat } from '../i18n/useDateLocale' + +export const SelectDateFormat = (props) => { + const translate = useTranslate() + + return ( + { + localStorage.setItem('dateFormat', event.target.value) + }} + /> + ) +} diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index d2b17b108..be77672a9 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react' import { Datagrid, - DateField, EditButton, Filter, NullableBooleanInput, @@ -20,6 +19,7 @@ import Switch from '@material-ui/core/Switch' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { + DateField, ArtworkAvatar, DurationField, List, @@ -67,15 +67,15 @@ const PlaylistFilter = (props) => { ) } -const TogglePublicInput = ({ resource, source }) => { +export const ToggleField = ({ resource, source }) => { const record = useRecordContext() const notify = useNotify() - const [togglePublic] = useUpdate( + const [toggle] = useUpdate( resource, - record.id, + record?.id, { ...record, - public: !record.public, + [source]: !record?.[source], }, { undoable: false, @@ -86,48 +86,25 @@ const TogglePublicInput = ({ resource, source }) => { ) const handleClick = (e) => { - togglePublic() + toggle() e.stopPropagation() } + if (!record) return null + return ( ) } -const ToggleAutoImport = ({ resource, source }) => { +export const ToggleAutoImport = (props) => { const record = useRecordContext() - const notify = useNotify() - const [ToggleAutoImport] = useUpdate( - resource, - record.id, - { - ...record, - sync: !record.sync, - }, - { - undoable: false, - onFailure: (error) => { - notify('ra.page.error', 'warning') - }, - }, - ) - const handleClick = (e) => { - ToggleAutoImport() - e.stopPropagation() - } - - return record.path ? ( - - ) : null + return record?.path ? : null } const PlaylistListBulkActions = (props) => { @@ -169,9 +146,7 @@ const PlaylistList = (props) => { updatedAt: isDesktop && ( ), - public: !isXsmall && ( - - ), + public: !isXsmall && , comment: , sync: !isXsmall && ( diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx index 4fbc6d516..c05833166 100644 --- a/ui/src/playlist/PlaylistList.test.jsx +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -1,7 +1,9 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' -import { PlaylistLove } from './PlaylistList' +import { TestContext } from 'ra-test' +import { RecordContextProvider } from 'react-admin' +import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' vi.mock('../config', () => ({ default: { enableFavourites: true }, @@ -13,6 +15,7 @@ vi.mock('../common', () => ({ {record?.starred ? 'starred' : 'not-starred'} ), + isWritable: (ownerId) => ownerId === 'me', })) describe('', () => { @@ -32,3 +35,50 @@ describe('', () => { }) }) }) + +// react-admin evicts records older than 10 minutes while the list still holds +// their ids, so rows can render with no record. +describe('playlist toggles without a record', () => { + it(' renders nothing', () => { + const { container } = render( + + + , + ) + expect(container.innerHTML).toBe('') + }) + + it(' renders nothing', () => { + const { container } = render( + + + , + ) + expect(container.innerHTML).toBe('') + }) +}) + +// Secondary is a surface color in many themes, so these toggles must use primary +describe('', () => { + const renderToggle = (record) => + render( + + + + + , + ) + + it.each([ + ['owner', 'me', false], + ['non-owner', 'someone-else', true], + ])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => { + renderToggle({ id: 'pl-1', public: true, ownerId }) + const input = screen.getByRole('checkbox') + const switchBase = input.closest('.MuiSwitch-switchBase') + expect(input.checked).toBe(true) + expect(input.disabled).toBe(disabled) + expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') + expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary') + }) +}) diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 0cfb2cd68..46e59ffcc 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -3,7 +3,6 @@ import React, { cloneElement } from 'react' import { CreateButton, Datagrid, - DateField, EditButton, Filter, sanitizeListRestProps, @@ -15,6 +14,7 @@ import { useTranslate, } from 'react-admin' import { + DateField, List, defaultRowsPerPageOptions, getStoredPerPage, diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js index 2e4ae38e5..45559c3af 100644 --- a/ui/src/themes/dracula.js +++ b/ui/src/themes/dracula.js @@ -185,16 +185,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: green, - }, - '&$checked + $track': { - backgroundColor: green, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 0f4cbd7c4..3e2955dcd 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -121,16 +121,6 @@ export default { boxShadow: '3px 3px 5px #3c3836', }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: '#458588', - }, - '&$checked + $track': { - backgroundColor: '#458588', - }, - }, - }, NDMobileArtistDetails: { bgContainer: { background: diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index 391a97f2c..285e579ae 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -25,6 +25,8 @@ import MoonbaseAlphaTheme from './moonbaseAlpha' import MoonbaseBravoTheme from './moonbaseBravo' import TokyoNightLightTheme from './tokyoNightLight' import TokyoNightTheme from './tokyoNight' +import SolarizedDarkTheme from './solarizedDark' +import SolarizedLightTheme from './solarizedLight' export default { // Classic default themes @@ -53,6 +55,8 @@ export default { RosePineDawnTheme, RosePineMoonTheme, RosePineTheme, + SolarizedDarkTheme, + SolarizedLightTheme, SpotifyTheme, SquiddiesGlassTheme, TokyoNightLightTheme, diff --git a/ui/src/themes/solarizedDark.css.js b/ui/src/themes/solarizedDark.css.js new file mode 100644 index 000000000..b357b8a0b --- /dev/null +++ b/ui/src/themes/solarizedDark.css.js @@ -0,0 +1,148 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #268bd2 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #268bd2 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #268bd2; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #268bd2 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #268bd2 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #268bd2 !important +} + +.react-jinke-music-player-main .loading svg { + color: #268bd2 !important +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.react-jinke-music-player-main .rc-slider-rail, +.react-jinke-music-player-mobile-progress .rc-slider-rail { + background-color: rgba(147, 161, 161, .2) !important +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #073642; + color: #93a1a1; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); +} + +.audio-lists-panel { + background-color: #073642; + bottom: 6.25rem; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #0d4251; +} + +.audio-lists-panel-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: none; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #93a1a1; + -webkit-text-stroke: 0.5px #002b36; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #268bd2 !important; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #268bd2 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #268bd2 +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #268bd2; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(38, 139, 210, .3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #268bd2; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/solarizedDark.js b/ui/src/themes/solarizedDark.js new file mode 100644 index 000000000..1e1fcdff4 --- /dev/null +++ b/ui/src/themes/solarizedDark.js @@ -0,0 +1,380 @@ +import stylesheet from './solarizedDark.css.js' + +// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/ +const background = '#002b36' // base03 +const surface = '#073642' // base02 +const currentLine = '#0d4251' // base02, slightly lighter for hover and headers +const foreground = '#93a1a1' // base1 +const comment = '#839496' // base0 +const blue = '#268bd2' +const link = '#57b5ae' // cyan, lightened to keep text links above 4.5:1 +const accent = '#2aa198' // cyan, since violet is too dark on base03 +const red = '#dc322f' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Solarized Dark', + palette: { + primary: { + main: blue, + }, + secondary: { + main: accent, + contrastText: background, + }, + error: { + main: red, + }, + type: 'dark', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px', + }, + colorSecondary: { + color: foreground, + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + // Cells have an opaque background, so the hover must be applied to them too + '&:hover > .MuiTableCell-root': { + background: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${foreground} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: accent, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: link, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #00212b', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(0 43 54 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: link, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/solarizedLight.css.js b/ui/src/themes/solarizedLight.css.js new file mode 100644 index 000000000..c6a599562 --- /dev/null +++ b/ui/src/themes/solarizedLight.css.js @@ -0,0 +1,123 @@ +const stylesheet = ` +.react-jinke-music-player-main.light-theme .loading svg { + color: #268bd2; + font-size: 24px +} + +.react-jinke-music-player-mobile-play-model-tip { + background-color: #268bd2; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #268bd2 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #268bd2 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing svg { + color: #268bd2 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer { + color: #268bd2 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #268bd2 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #268bd2 +} + +.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb { + background-color: #268bd2; +} + +.react-jinke-music-player-main.light-theme svg { + color: #586e75 +} + +.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { + color: #268bd2 +} + +.react-jinke-music-player-main.light-theme .rc-slider-rail { + background-color: rgba(88, 110, 117, .12) !important +} + +.react-jinke-music-player-main.light-theme .music-player-controller { + background-color: #fdf6e3; + border-color: #fdf6e3 +} + +.react-jinke-music-player-main.light-theme .music-player-panel { + background-color: #fdf6e3; + box-shadow: 0 1px 2px 0 rgba(0, 43, 54, .05); + color: #586e75 +} + +.react-jinke-music-player-main.light-theme .music-player-panel .img-content { + box-shadow: 0 0 10px #e4ddc6 +} + +.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar { + background-color: rgba(88, 110, 117, .08) !important +} + +.react-jinke-music-player-main.light-theme .rc-switch { + color: #fdf6e3 +} + +.react-jinke-music-player-main.light-theme .rc-switch:after { + background-color: #fdf6e3 +} + +.react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #268bd2 !important; + border: 1px solid #268bd2 +} + +.react-jinke-music-player-main.light-theme .rc-switch-inner { + color: #fdf6e3 +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn { + background-color: #eee8d5 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover { + background-color: #e4ddc6; + color: #586e75 +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg { + color: #268bd2 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel { + background-color: #fdf6e3; + box-shadow: 0 0 2px #e4ddc6; + color: #586e75 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item { + background-color: #fdf6e3 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) { + background-color: #f5efdc !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing { + background-color: #e4ddc6 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { + color: #268bd2 !important +} +` + +export default stylesheet diff --git a/ui/src/themes/solarizedLight.js b/ui/src/themes/solarizedLight.js new file mode 100644 index 000000000..65e0241e9 --- /dev/null +++ b/ui/src/themes/solarizedLight.js @@ -0,0 +1,380 @@ +import stylesheet from './solarizedLight.css.js' + +// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/ +const background = '#eee8d5' // base2 +const surface = '#fdf6e3' // base3 +const currentLine = '#e4ddc6' // base2, slightly darker for hover and headers +const foreground = '#586e75' // base01 +const comment = '#657b83' // base00 +const blue = '#268bd2' +const link = '#1c6599' // blue, darkened to keep text links above 4.5:1 +const accent = '#5d61a9' // violet, darkened to keep text above 4.5:1 +const red = '#dc322f' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Solarized Light', + palette: { + primary: { + main: blue, + }, + secondary: { + main: accent, + contrastText: surface, + }, + error: { + main: red, + }, + type: 'light', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(0, 43, 54, 0.12) 0px 4px 6px, rgba(0, 43, 54, 0.06) 0px 5px 7px', + }, + colorSecondary: { + color: foreground, + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + // Cells have an opaque background, so the hover must be applied to them too + '&:hover > .MuiTableCell-root': { + background: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${foreground} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 20%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: accent, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: link, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #93a1a1', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(238 232 213 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: link, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js index 07d372a6b..9f6424b77 100644 --- a/ui/src/themes/tokyoNight.js +++ b/ui/src/themes/tokyoNight.js @@ -184,16 +184,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: blue, - }, - '&$checked + $track': { - backgroundColor: blue, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js index f84cd0be9..a61c0fe87 100644 --- a/ui/src/themes/tokyoNightLight.js +++ b/ui/src/themes/tokyoNightLight.js @@ -184,16 +184,6 @@ export default { color: `${foreground} !important`, }, }, - MuiSwitch: { - colorSecondary: { - '&$checked': { - color: blue, - }, - '&$checked + $track': { - backgroundColor: blue, - }, - }, - }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index 4ccefe820..fbb5e9bc8 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -63,6 +63,8 @@ const useCurrentTheme = () => { ...theme.props, MuiUseMediaQuery: { noSsr: true }, MuiPopover: { disableScrollLock: true }, + // MUI defaults to secondary, which many themes use as a surface color + MuiSwitch: { color: 'primary' }, }, }), [theme], diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx index 65c3be8c6..6553d9866 100644 --- a/ui/src/themes/useCurrentTheme.test.jsx +++ b/ui/src/themes/useCurrentTheme.test.jsx @@ -3,6 +3,10 @@ import { Provider } from 'react-redux' import { createStore } from 'redux' import mediaQuery from 'css-mediaquery' import { renderHook } from '@testing-library/react-hooks' +import { render, screen } from '@testing-library/react' +import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles' +import Switch from '@material-ui/core/Switch' +import themes from './index' import useCurrentTheme from './useCurrentTheme' import { themeReducer } from '../reducers/themeReducer' import { AUTO_THEME_ID } from '../consts' @@ -161,4 +165,27 @@ describe('useCurrentTheme', () => { expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)') }) }) + describe('switch color', () => { + it.each(Object.keys(themes))( + 'renders switches with the primary color in %s', + (theme) => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + render( + + {}} /> + , + ) + const switchBase = screen + .getByRole('checkbox') + .closest('.MuiSwitch-switchBase') + expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') + }, + ) + }) })