diff --git a/README.md b/README.md
index e1a2955a1..4bc85e6a6 100644
--- a/README.md
+++ b/README.md
@@ -35,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
## Cloud Hosting
-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.
-
-
-
-
-
-
-
-
-
-
+[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.
+[](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 0f9a90d94..e7dd18ac1 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(model.Library{ID: 1}, "folderID"))
+ return new(metadata.ToMediaFile(1, "folderID"))
}
BeforeEach(func() {
diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml
index bd31e23f8..261502224 100644
--- a/api/.vacuum.yaml
+++ b/api/.vacuum.yaml
@@ -36,6 +36,14 @@ 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 bfe794c71..5755ee75b 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 /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",
+ "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",
"license": {
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
@@ -18,6 +18,10 @@
{
"name": "server",
"description": "Server discovery and the published OpenAPI document."
+ },
+ {
+ "name": "auth",
+ "description": "Grants and login methods."
}
],
"paths": {
@@ -29,8 +33,9 @@
"tags": [
"server"
],
+ "security": [],
"summary": "Describe the server",
- "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
+ "description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n",
"responses": {
"200": {
"description": "Server description.",
@@ -48,6 +53,41 @@
}
}
},
+ "/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",
@@ -56,6 +96,7 @@
"tags": [
"server"
],
+ "security": [],
"summary": "Get the OpenAPI document (JSON)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
@@ -81,6 +122,56 @@
}
}
},
+ "/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",
@@ -89,6 +180,7 @@
"tags": [
"server"
],
+ "security": [],
"summary": "Get the OpenAPI document (YAML)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
@@ -113,6 +205,262 @@
}
}
}
+ },
+ "/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": {
@@ -120,8 +468,7 @@
"bearerAuth": {
"type": "http",
"scheme": "bearer",
- "bearerFormat": "JWT",
- "description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
+ "description": "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
}
},
"schemas": {
@@ -153,17 +500,23 @@
"description": "True until the first admin user has been created."
},
"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"
- ]
- }
+ "$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.\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.",
@@ -187,7 +540,7 @@
},
"detail": {
"type": "string",
- "description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
+ "description": "Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients."
},
"code": {
"type": "string",
@@ -196,12 +549,21 @@
"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`.",
@@ -228,6 +590,310 @@
"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": {
@@ -241,6 +907,21 @@
}
}
},
+ "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": {
@@ -248,14 +929,127 @@
"$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 f1ae95b76..8b430b629 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 /server` and never sniff versions.
+ capability modules through `GET /capabilities` 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,6 +15,10 @@ 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
@@ -23,6 +27,8 @@ servers:
tags:
- name: server
description: Server discovery and the published OpenAPI document.
+ - name: auth
+ description: Grants and login methods.
paths:
/server:
get:
@@ -30,11 +36,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.
- Authenticated requests will additionally receive the implemented capability modules
- once authentication is available.
+ Capability modules are listed by `GET /capabilities`.
responses:
'200':
description: Server description.
@@ -44,12 +50,30 @@ 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:
@@ -65,12 +89,41 @@ 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:
@@ -86,13 +139,172 @@ 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
- bearerFormat: JWT
- description: Short-lived access token minted from a device grant. Not yet applied to any operation.
+ description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
schemas:
ServerInfo:
type: object
@@ -117,12 +329,19 @@ components:
type: boolean
description: True until the first admin user has been created.
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
+ $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.
Problem:
type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
@@ -145,7 +364,7 @@ components:
description: HTTP status code of this response.
detail:
type: string
- description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
+ description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
@@ -153,10 +372,18 @@ 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`.
@@ -175,6 +402,238 @@ 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.
@@ -182,13 +641,96 @@ 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
new file mode 100644
index 000000000..07250936b
--- /dev/null
+++ b/api/openapi/components/headers/CacheControlNoStore.yaml
@@ -0,0 +1,4 @@
+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
new file mode 100644
index 000000000..65d5fb2fb
--- /dev/null
+++ b/api/openapi/components/headers/WWWAuthenticate.yaml
@@ -0,0 +1,3 @@
+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
new file mode 100644
index 000000000..a602ffd6b
--- /dev/null
+++ b/api/openapi/components/responses/Conflict.yaml
@@ -0,0 +1,5 @@
+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 6259185ea..9f39b6b53 100644
--- a/api/openapi/components/responses/Forbidden.yaml
+++ b/api/openapi/components/responses/Forbidden.yaml
@@ -1,4 +1,7 @@
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
new file mode 100644
index 000000000..4918bf1e7
--- /dev/null
+++ b/api/openapi/components/responses/PayloadTooLarge.yaml
@@ -0,0 +1,5 @@
+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
new file mode 100644
index 000000000..de82cd59e
--- /dev/null
+++ b/api/openapi/components/responses/TooManyRequests.yaml
@@ -0,0 +1,10 @@
+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 0209f4dd9..87d5bab08 100644
--- a/api/openapi/components/responses/Unauthorized.yaml
+++ b/api/openapi/components/responses/Unauthorized.yaml
@@ -1,4 +1,7 @@
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
new file mode 100644
index 000000000..722a6823c
--- /dev/null
+++ b/api/openapi/components/schemas/AuthUser.yaml
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 000000000..aa760e8e5
--- /dev/null
+++ b/api/openapi/components/schemas/Capabilities.yaml
@@ -0,0 +1,10 @@
+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
new file mode 100644
index 000000000..c52c21318
--- /dev/null
+++ b/api/openapi/components/schemas/CoreCapability.yaml
@@ -0,0 +1,5 @@
+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
new file mode 100644
index 000000000..08e198b23
--- /dev/null
+++ b/api/openapi/components/schemas/CredentialsRequest.yaml
@@ -0,0 +1,34 @@
+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
new file mode 100644
index 000000000..95b4aae19
--- /dev/null
+++ b/api/openapi/components/schemas/Grant.yaml
@@ -0,0 +1,41 @@
+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
new file mode 100644
index 000000000..d385ba335
--- /dev/null
+++ b/api/openapi/components/schemas/GrantCreated.yaml
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 000000000..8e80391ae
--- /dev/null
+++ b/api/openapi/components/schemas/GrantList.yaml
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 000000000..106d3a6ed
--- /dev/null
+++ b/api/openapi/components/schemas/LoginMethods.yaml
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 000000000..12700fba7
--- /dev/null
+++ b/api/openapi/components/schemas/LogoutResponse.yaml
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 000000000..319cdbf3d
--- /dev/null
+++ b/api/openapi/components/schemas/PasswordCapability.yaml
@@ -0,0 +1,5 @@
+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
new file mode 100644
index 000000000..c176014c4
--- /dev/null
+++ b/api/openapi/components/schemas/PasswordChangeRequest.yaml
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 000000000..e8a1f0ee2
--- /dev/null
+++ b/api/openapi/components/schemas/PasswordLoginMethod.yaml
@@ -0,0 +1,2 @@
+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 0fd36d4b1..fb966f914 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 for internal errors.
+ description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
@@ -24,10 +24,18 @@ 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
new file mode 100644
index 000000000..a1763945f
--- /dev/null
+++ b/api/openapi/components/schemas/Scope.yaml
@@ -0,0 +1,5 @@
+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
new file mode 100644
index 000000000..a9a936147
--- /dev/null
+++ b/api/openapi/components/schemas/ScopeRequest.yaml
@@ -0,0 +1,4 @@
+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 8906924eb..f80fbce5c 100644
--- a/api/openapi/components/schemas/ServerInfo.yaml
+++ b/api/openapi/components/schemas/ServerInfo.yaml
@@ -15,8 +15,4 @@ properties:
type: boolean
description: True until the first admin user has been created.
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]
+ $ref: ./LoginMethods.yaml
diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml
index 73cbb7b27..02a0bda8d 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 /server` and never sniff versions.
+ capability modules through `GET /capabilities` 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,6 +15,10 @@ 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
@@ -23,17 +27,32 @@ 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
- bearerFormat: JWT
- description: Short-lived access token minted from a device grant. Not yet applied to any operation.
+ description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
diff --git a/api/openapi/paths/auth.yaml b/api/openapi/paths/auth.yaml
new file mode 100644
index 000000000..83c28ea15
--- /dev/null
+++ b/api/openapi/paths/auth.yaml
@@ -0,0 +1,188 @@
+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
new file mode 100644
index 000000000..dc0d9d7ca
--- /dev/null
+++ b/api/openapi/paths/capabilities.yaml
@@ -0,0 +1,16 @@
+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 3c25dc8b7..bc0b874f6 100644
--- a/api/openapi/paths/openapi.yaml
+++ b/api/openapi/paths/openapi.yaml
@@ -4,6 +4,7 @@ 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:
@@ -25,6 +26,7 @@ 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 1f881dbb1..1e17a3b87 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.
- Authenticated requests will additionally receive the implemented capability modules
- once authentication is available.
+ Capability modules are listed by `GET /capabilities`.
responses:
'200':
description: Server description.
diff --git a/cmd/inspect.go b/cmd/inspect.go
index 05f569f3e..5e88793cc 100644
--- a/cmd/inspect.go
+++ b/cmd/inspect.go
@@ -1,17 +1,13 @@
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"
@@ -32,7 +28,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(cmd.Context(), args)
+ runInspector(args)
},
}
@@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
return []byte(res.String()), nil
}
-func runInspector(ctx context.Context, args []string) {
+func runInspector(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
}
- 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, "")
+ output, err := core.Inspect(filePath, 1, "")
if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err)
continue
@@ -87,33 +77,3 @@ func runInspector(ctx context.Context, 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
deleted file mode 100644
index 728dc8770..000000000
--- a/cmd/inspect_test.go
+++ /dev/null
@@ -1,61 +0,0 @@
-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 e39c55365..089f09472 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -190,20 +190,16 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
}
-// 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)
+func pidHashChanged(ds model.DataStore) (bool, error) {
+ pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "")
if err != nil {
- return nil, err
+ return false, err
}
- var names []string
- for _, lib := range libs {
- if lib.PIDChanged() {
- names = append(names, lib.Name)
- }
+ pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "")
+ if err != nil {
+ return false, err
}
- return names, nil
+ return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
}
// runInitialScan runs an initial scan of the music library if needed.
@@ -218,12 +214,12 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
- pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
+ pidHasChanged, err := pidHashChanged(ds)
if err != nil {
return err
}
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
- scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
+ scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
s := CreateScanner(ctx)
@@ -231,9 +227,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 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 pidHasChanged:
+ log.Warn(ctx, "PID config changed, performing full scan")
+ fullScanRequired = "1"
case inProgress:
log.Warn(ctx, "Resuming interrupted scan")
default:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 423cd2a8f..af8d44e7e 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -1,7 +1,6 @@
package cmd
import (
- "errors"
"net/http"
"net/http/httptest"
"path"
@@ -10,8 +9,6 @@ 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"
)
@@ -47,30 +44,3 @@ 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 f35a31fb1..72ec67f90 100644
--- a/cmd/utils.go
+++ b/cmd/utils.go
@@ -18,16 +18,11 @@ import (
"github.com/navidrome/navidrome/persistence"
)
-// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
-func existingDBFile() (string, bool) {
- path, _, _ := strings.Cut(conf.Server.DbPath, "?")
- _, err := os.Stat(path)
- return path, err == nil
-}
-
-// requireExistingDB aborts the command when the database file does not exist.
+// requireExistingDB aborts the command when the database file (DbPath minus DSN
+// params) does not exist.
func requireExistingDB() {
- if path, ok := existingDBFile(); !ok {
+ path, _, _ := strings.Cut(conf.Server.DbPath, "?")
+ if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
}
}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 2a396689d..19f92d9d5 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -95,9 +95,8 @@ 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, transcodeDecider, dataStore, share, artworkArtwork)
+ archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
players := core.NewPlayers(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
@@ -111,6 +110,7 @@ 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,10 +159,9 @@ 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, transcodeDecider, dataStore, share, artworkArtwork)
- router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
+ archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
+ router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
return router
}
diff --git a/consts/consts.go b/consts/consts.go
index 42e9ec42f..8faffdbd7 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -34,6 +34,7 @@ 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
@@ -156,6 +157,8 @@ 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 1eb414b15..400c54fc7 100644
--- a/core/agents/session_keys.go
+++ b/core/agents/session_keys.go
@@ -3,6 +3,7 @@ package agents
import (
"context"
+ "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
@@ -13,6 +14,7 @@ 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 e0232c08e..66eaf3a57 100644
--- a/core/agents/session_keys_test.go
+++ b/core/agents/session_keys_test.go
@@ -1,21 +1,31 @@
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() {
- ctx := context.Background()
+ var ctx context.Context
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"))
})
@@ -34,4 +44,34 @@ 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
new file mode 100644
index 000000000..5d6143b0e
--- /dev/null
+++ b/core/apiauth/apiauth_suite_test.go
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 000000000..082d92d45
--- /dev/null
+++ b/core/apiauth/context.go
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 000000000..43834ab51
--- /dev/null
+++ b/core/apiauth/credentials.go
@@ -0,0 +1,68 @@
+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
new file mode 100644
index 000000000..a57d54c29
--- /dev/null
+++ b/core/apiauth/credentials_test.go
@@ -0,0 +1,63 @@
+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
new file mode 100644
index 000000000..5f54e6007
--- /dev/null
+++ b/core/apiauth/db_test.go
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 000000000..fdfde677c
--- /dev/null
+++ b/core/apiauth/export_test.go
@@ -0,0 +1,11 @@
+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
new file mode 100644
index 000000000..319269a00
--- /dev/null
+++ b/core/apiauth/scopes.go
@@ -0,0 +1,63 @@
+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
new file mode 100644
index 000000000..351f935e2
--- /dev/null
+++ b/core/apiauth/scopes_test.go
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 000000000..beaed16c5
--- /dev/null
+++ b/core/apiauth/secret.go
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 000000000..3f9bae3da
--- /dev/null
+++ b/core/apiauth/secret_test.go
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 000000000..3ec70476f
--- /dev/null
+++ b/core/apiauth/service.go
@@ -0,0 +1,264 @@
+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
new file mode 100644
index 000000000..6482122c7
--- /dev/null
+++ b/core/apiauth/service_test.go
@@ -0,0 +1,463 @@
+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 60eb44858..6f362322a 100644
--- a/core/archiver.go
+++ b/core/archiver.go
@@ -35,14 +35,13 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
-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}
+func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
+ return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork}
}
type archiver struct {
ds model.DataStore
ms stream.MediaStreamer
- decider stream.TranscodeDecider
shares Share
artwork artwork.Artwork
}
@@ -79,9 +78,8 @@ 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 {
- 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) {
+ file := a.albumFilename(mf, format, isMultiDisc, folder)
+ if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, 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
@@ -206,9 +204,8 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
- 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) {
+ file := a.playlistFilename(mf, format, idx)
+ if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, 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()
@@ -254,14 +251,7 @@ 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) 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 {
+func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
path := mf.AbsolutePath()
// Open the source before writing the zip entry header so a rejection
@@ -269,13 +259,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
// archive.
var r io.ReadCloser
var err error
- if req.Format != "raw" {
- r, err = a.ms.NewStream(ctx, &mf, req)
+ if format != "raw" && format != "" {
+ r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}
if err != nil {
- log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
+ log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
return err
}
defer func() {
diff --git a/core/archiver_test.go b/core/archiver_test.go
index 178d1b6b9..4e00ce78c 100644
--- a/core/archiver_test.go
+++ b/core/archiver_test.go
@@ -26,7 +26,6 @@ var _ = Describe("Archiver", func() {
var (
arch core.Archiver
ms *mockMediaStreamer
- dc *fakeDecider
ds *mockDataStore
sh *mockShare
ca *mockCoverArt
@@ -34,11 +33,10 @@ var _ = Describe("Archiver", func() {
BeforeEach(func() {
ms = &mockMediaStreamer{}
- dc = &fakeDecider{}
sh = &mockShare{}
ds = &mockDataStore{}
ca = &mockCoverArt{images: map[string][]byte{}}
- arch = core.NewArchiver(ms, dc, ds, sh, ca)
+ arch = core.NewArchiver(ms, ds, sh, ca)
})
Context("ZipAlbum", func() {
@@ -68,23 +66,6 @@ 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() {
@@ -315,30 +296,6 @@ 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"}},
@@ -614,19 +571,6 @@ 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 fb07332fe..40baa2495 100644
--- a/core/artwork/resolve.go
+++ b/core/artwork/resolve.go
@@ -374,11 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
- 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()"})
+ albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
+ 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 2a36531bb..da144d8e2 100644
--- a/core/artwork/resolve_test.go
+++ b/core/artwork/resolve_test.go
@@ -707,16 +707,6 @@ 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 4be99f92e..28e51958c 100644
--- a/core/artwork/worker.go
+++ b/core/artwork/worker.go
@@ -4,11 +4,9 @@ import (
"bytes"
"cmp"
"context"
- "fmt"
"io"
"math"
"math/rand/v2"
- "runtime/debug"
"sync"
"time"
@@ -246,7 +244,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.safeAcquire(ctx, item)
+ out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue()
switch out {
@@ -288,20 +286,6 @@ 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 80ca68bc3..a6c07b763 100644
--- a/core/artwork/worker_test.go
+++ b/core/artwork/worker_test.go
@@ -142,18 +142,6 @@ 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())
@@ -290,36 +278,6 @@ 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 1bdc917da..1d521451a 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, getEncKey(), secret); err != nil {
+ if secret, err = utils.Decrypt(ctx, EncryptionKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err)
return createNewSecret(ctx, ds, key)
}
@@ -171,11 +171,12 @@ 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, getEncKey(), secret)
+ encSecret, err := utils.Encrypt(ctx, EncryptionKey(), 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)
}
@@ -195,7 +196,7 @@ func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
return jwtauth.VerifyToken(TokenAuth, tokenStr)
}
-func getEncKey() []byte {
+func EncryptionKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,
consts.DefaultEncryptionKey,
diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go
index c86dcd08c..05da7ec65 100644
--- a/core/auth/auth_test.go
+++ b/core/auth/auth_test.go
@@ -15,6 +15,7 @@ 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
new file mode 100644
index 000000000..a082e6222
--- /dev/null
+++ b/core/auth/first_admin.go
@@ -0,0 +1,54 @@
+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
new file mode 100644
index 000000000..0cbbcf575
--- /dev/null
+++ b/core/auth/first_admin_test.go
@@ -0,0 +1,106 @@
+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 c60459b88..01ec33760 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, lib model.Library, folderId string) (*InspectOutput, error) {
+func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
path, file := filepath.Split(filePath)
s, err := storage.For(path)
@@ -39,22 +39,12 @@ func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutpu
return nil, model.ErrNotFound
}
- md := metadata.New(scannerPath(lib, filePath), tag)
+ md := metadata.New(path, tag)
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
- MappedTags: new(md.ToMediaFile(lib, folderId)),
+ MappedTags: new(md.ToMediaFile(libraryId, 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
deleted file mode 100644
index 0ac90990c..000000000
--- a/core/inspect_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-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 f1153da26..628ee4b7b 100644
--- a/core/library.go
+++ b/core/library.go
@@ -2,12 +2,10 @@ package core
import (
"context"
- "errors"
"fmt"
"io/fs"
"os"
"path/filepath"
- "slices"
"strconv"
"strings"
"time"
@@ -17,7 +15,6 @@ 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"
@@ -203,22 +200,23 @@ 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)
}
- 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)
+ // 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 || pidChanged) && r.scanner != nil {
- go r.triggerScan(ctx, lib, "updated")
+ if r.scanner != nil {
+ go r.triggerScan(ctx, lib, "updated")
+ }
}
// Send library refresh event to all clients
@@ -327,15 +325,6 @@ 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}
}
@@ -343,11 +332,6 @@ 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) {
@@ -415,27 +399,11 @@ 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: 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 {
+ warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library
+ 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 e6ebb1974..5402eac22 100644
--- a/core/library_test.go
+++ b/core/library_test.go
@@ -322,37 +322,6 @@ 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() {
@@ -710,48 +679,6 @@ 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 1d2ff5df4..4a78a7f3f 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -10,7 +10,6 @@ import (
"path/filepath"
"runtime"
"runtime/debug"
- "slices"
"strings"
"sync"
"sync/atomic"
@@ -270,13 +269,9 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
if err != nil {
log.Trace(ctx, "Error reading radios count", err)
}
- libs, err := c.ds.Library().GetAll(ctx)
+ data.Library.Libraries, err = c.ds.Library().CountAll(ctx)
if err != nil {
- 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
+ log.Trace(ctx, "Error reading libraries count", err)
}
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 b5991b095..658bd92dc 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 := model.NewLibraryMatcher(libs)
- lib, ok := matcher.FindLibrary(dir)
+ matcher := 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 ab95b8850..9610e9dbb 100644
--- a/core/playlists/parse_m3u.go
+++ b/core/playlists/parse_m3u.go
@@ -1,11 +1,13 @@
package playlists
import (
+ "cmp"
"context"
"fmt"
"io"
"net/url"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -154,9 +156,61 @@ 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 *model.LibraryMatcher
+ matcher *libraryMatcher
}
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
@@ -165,7 +219,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er
if err != nil {
return nil, err
}
- matcher := model.NewLibraryMatcher(libs)
+ matcher := newLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil
}
@@ -192,14 +246,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 {
- lib, ok := r.matcher.FindLibrary(absolutePath)
- if !ok {
+ libID, libPath := r.matcher.findLibraryForPath(absolutePath)
+ if libID == 0 {
return pathResolution{valid: false}
}
return pathResolution{
absolutePath: absolutePath,
- libraryPath: filepath.Clean(lib.Path),
- libraryID: lib.ID,
+ libraryPath: libPath,
+ libraryID: libID,
valid: true,
}
}
@@ -234,7 +288,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 *model.LibraryMatcher, owner model.User) string {
+func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
@@ -254,7 +308,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryM
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 ced6c2b16..b6a3a96f9 100644
--- a/core/playlists/parse_m3u_test.go
+++ b/core/playlists/parse_m3u_test.go
@@ -9,6 +9,187 @@ 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 e2ce00a1b..686838565 100644
--- a/core/storage/local/local.go
+++ b/core/storage/local/local.go
@@ -76,17 +76,6 @@ 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
new file mode 100644
index 000000000..84fec2622
--- /dev/null
+++ b/db/migrations/20260926045200_create_api_grant.sql
@@ -0,0 +1,23 @@
+-- +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
deleted file mode 100644
index 487512287..000000000
--- a/db/migrations/20260929221042_add_library_pid_columns.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- +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 e96b8c8b3..12cc95e73 100644
--- a/go.mod
+++ b/go.mod
@@ -40,6 +40,7 @@ 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
@@ -74,6 +75,7 @@ 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 fe6dbbc3f..ed9f32f13 100644
--- a/go.sum
+++ b/go.sum
@@ -6,14 +6,18 @@ 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=
@@ -136,6 +140,7 @@ 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=
@@ -188,6 +193,10 @@ 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=
@@ -255,6 +264,7 @@ 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=
@@ -263,6 +273,7 @@ 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 da1d7622e..eb6f81cb8 100644
--- a/log/log.go
+++ b/log/log.go
@@ -72,7 +72,10 @@ const (
type contextKey string
-const loggerCtxKey = contextKey("logger")
+const (
+ loggerCtxKey = contextKey("logger")
+ secretsCtxKey = contextKey("secrets")
+)
type levelPath struct {
path string
@@ -188,6 +191,34 @@ 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 {
@@ -289,6 +320,12 @@ 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 184ff57db..0e6628adb 100644
--- a/log/log_test.go
+++ b/log/log_test.go
@@ -1,11 +1,14 @@
package log
import (
+ "bytes"
"context"
"encoding/json"
"errors"
+ "fmt"
"net/http"
"net/http/httptest"
+ "runtime"
"testing"
"time"
@@ -93,9 +96,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")
- // 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().Data[" source"]).To(ContainSubstring(fmt.Sprintf("/log/log_test.go:%d", line+1)))
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
})
@@ -109,6 +112,26 @@ 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 6e17243e7..b8ad0ddb4 100755
--- a/log/redactrus.go
+++ b/log/redactrus.go
@@ -7,6 +7,7 @@ import (
"fmt"
"reflect"
"regexp"
+ "strings"
"github.com/sirupsen/logrus"
)
@@ -35,6 +36,7 @@ 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 {
@@ -47,7 +49,8 @@ func (h *Hook) Fire(e *logrus.Entry) error {
}
switch reflect.TypeOf(v).Kind() {
case reflect.String:
- e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")
+ // 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")
continue
case reflect.Map:
s := fmt.Sprintf("%+v", v)
@@ -63,6 +66,36 @@ 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 36a19e2f5..6b9d71f89 100755
--- a/log/redactrus_test.go
+++ b/log/redactrus_test.go
@@ -1,6 +1,8 @@
package log
import (
+ "errors"
+ "net/url"
"testing"
"github.com/sirupsen/logrus"
@@ -157,3 +159,85 @@ 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 6ded8c575..1175d212d 100644
--- a/model/datastore.go
+++ b/model/datastore.go
@@ -37,6 +37,7 @@ 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
new file mode 100644
index 000000000..ecbf30e41
--- /dev/null
+++ b/model/grant.go
@@ -0,0 +1,67 @@
+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
new file mode 100644
index 000000000..dd59a2422
--- /dev/null
+++ b/model/grant_test.go
@@ -0,0 +1,24 @@
+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 e80d22c89..1e33222ac 100644
--- a/model/library.go
+++ b/model/library.go
@@ -1,13 +1,10 @@
package model
import (
- "cmp"
"context"
- "strings"
"time"
"github.com/deluan/rest"
- "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -30,38 +27,6 @@ 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 (
@@ -94,8 +59,6 @@ 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
deleted file mode 100644
index 83af96f9f..000000000
--- a/model/library_matcher.go
+++ /dev/null
@@ -1,57 +0,0 @@
-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
deleted file mode 100644
index 09e6f7e33..000000000
--- a/model/library_matcher_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-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
deleted file mode 100644
index 4e799e13f..000000000
--- a/model/library_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-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 2135824d2..6d12feba9 100644
--- a/model/metadata/map_mediafile.go
+++ b/model/metadata/map_mediafile.go
@@ -8,14 +8,15 @@ 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(lib model.Library, folderID string) model.MediaFile {
+func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf := model.MediaFile{
- LibraryID: lib.ID,
+ LibraryID: libID,
FolderID: folderID,
Tags: maps.Clone(md.tags),
}
@@ -83,9 +84,8 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
// Persistent IDs
- pid := lib.EffectivePID()
- mf.PID = md.trackPID(mf, pid)
- mf.AlbumID = md.albumID(mf, pid.Album)
+ mf.PID = md.trackPID(mf)
+ mf.AlbumID = md.albumID(mf, conf.Server.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 c19398841..baaf8fab5 100644
--- a/model/metadata/map_mediafile_test.go
+++ b/model/metadata/map_mediafile_test.go
@@ -30,23 +30,9 @@ var _ = Describe("ToMediaFile", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags
md = metadata.New("filepath", props)
- return md.ToMediaFile(model.Library{ID: 1}, "folderID")
+ return md.ToMediaFile(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 db652fb8b..ec66e12b9 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(model.Library{ID: 1}, "folderID")
+ return md.ToMediaFile(1, "folderID")
}
Describe("ARTIST(S) tags", func() {
diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go
index a1a675006..c84d93981 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(model.Library{}, "0")
+ return md.ToMediaFile(0, "0")
}
DescribeTable("Gain",
diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go
index b66ce824a..db315dc6b 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,13 +22,12 @@ 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. albumSpec is the album PID
-// spec used to resolve the `albumid` attribute.
+// skipped and the function looks for the next field.
//
// 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, albumSpec string, prependLibId bool, hash hashFunc) string {
+func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
switch spec {
case "track_legacy":
return legacyTrackID(mf, prependLibId)
@@ -42,7 +41,7 @@ func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prepend
values := make([]string, len(attributes))
hasValue := false
for i, attr := range attributes {
- v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash)
+ v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
if v != "" {
hasValue = true
}
@@ -59,15 +58,15 @@ func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prepend
return hash(pid)
}
-func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string {
+func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
- if spec == albumSpec {
+ if spec == conf.Server.PID.Album {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return ""
}
- return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash)
+ return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
@@ -80,50 +79,18 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool,
return md.String(model.TagName(attr))
}
-// 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) trackPID(mf model.MediaFile) string {
+ return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
}
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
- return computePID(mf, md, pidConf, pidConf, true, id.NewHash)
+ return computePID(mf, md, 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 9f6eaf1f4..8e38bbd42 100644
--- a/model/metadata/persistent_ids_test.go
+++ b/model/metadata/persistent_ids_test.go
@@ -3,7 +3,8 @@ package metadata
import (
"strings"
- "github.com/navidrome/navidrome/consts"
+ "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"
@@ -12,18 +13,16 @@ import (
var _ = Describe("getPID", func() {
var (
- md Metadata
- mf model.MediaFile
- sum hashFunc
- albumSpec string
+ md Metadata
+ mf model.MediaFile
+ sum hashFunc
)
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
- return computePID(mf, md, spec, albumSpec, prependLibId, sum)
+ return computePID(mf, md, spec, prependLibId, sum)
}
BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
- albumSpec = consts.DefaultAlbumPID
})
Context("attributes are tags", func() {
@@ -67,7 +66,8 @@ var _ = Describe("getPID", func() {
Context("calculated attributes", func() {
BeforeEach(func() {
- albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.PID.Album = "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
- albumSpec = "albumid,album,albumversion,releasedate"
- spec := albumSpec
+ conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
+ spec := conf.Server.PID.Album
md.tags = map[model.TagName][]string{
"album": {"Album Name"},
"albumversion": {"Version"},
@@ -205,7 +205,8 @@ var _ = Describe("getPID", func() {
})
When("prependLibId is true with nested albumid", func() {
It("should handle nested albumid calls correctly", func() {
- albumSpec = "album"
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.PID.Album = "album"
spec := "albumid"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.AlbumArtist = "Test Artist"
@@ -305,34 +306,3 @@ 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 d22c3d0d6..36c9007fb 100644
--- a/model/scanner.go
+++ b/model/scanner.go
@@ -2,15 +2,12 @@ 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 5a8168754..ce7d2f37b 100644
--- a/model/tag_mappings.go
+++ b/model/tag_mappings.go
@@ -195,28 +195,6 @@ 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 91e54e5d4..e582c3f2f 100644
--- a/model/tag_mappings_test.go
+++ b/model/tag_mappings_test.go
@@ -192,22 +192,3 @@ 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
new file mode 100644
index 000000000..349099983
--- /dev/null
+++ b/persistence/grant_repository.go
@@ -0,0 +1,114 @@
+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
new file mode 100644
index 000000000..2436f8bb4
--- /dev/null
+++ b/persistence/grant_repository_test.go
@@ -0,0 +1,206 @@
+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 85da65cbc..bf6b8995e 100644
--- a/persistence/library_repository.go
+++ b/persistence/library_repository.go
@@ -93,8 +93,6 @@ 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})
@@ -178,15 +176,6 @@ 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 bf485a06f..0ff470861 100644
--- a/persistence/library_repository_test.go
+++ b/persistence/library_repository_test.go
@@ -270,38 +270,6 @@ 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 44e944bff..9656be178 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -7,6 +7,7 @@ import (
"sync"
"time"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -37,6 +38,7 @@ 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.
@@ -64,6 +66,7 @@ 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) }),
}
}
@@ -155,6 +158,10 @@ 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]
@@ -271,6 +278,10 @@ 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 ee2794454..dc6134793 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -1,7 +1,9 @@
package persistence
import (
+ "bytes"
"context"
+ "os"
"path/filepath"
"testing"
"time"
@@ -348,6 +350,18 @@ 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)
}
diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go
index 880b315ec..eac174c5b 100644
--- a/persistence/property_repository_test.go
+++ b/persistence/property_repository_test.go
@@ -33,4 +33,26 @@ 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 20b4e5125..26f7b19d4 100644
--- a/persistence/user_repository.go
+++ b/persistence/user_repository.go
@@ -119,6 +119,7 @@ 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 {
@@ -399,6 +400,7 @@ 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)
@@ -432,7 +434,8 @@ 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})
- _, err = r.executeSQL(ctx, upd)
+ userCtx := log.WithSecrets(ctx, u.NewPassword)
+ _, err = r.executeSQL(userCtx, 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 0e776fc3a..156d43253 100644
--- a/persistence/user_repository_test.go
+++ b/persistence/user_repository_test.go
@@ -2,12 +2,16 @@ 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"
@@ -17,6 +21,7 @@ import (
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
)
var _ = Describe("UserRepository", func() {
@@ -74,6 +79,28 @@ 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":"????"}}]}`}
@@ -96,6 +123,33 @@ 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
deleted file mode 100644
index 86bc86df9..000000000
--- a/resources/hosting/elfhosted.svg
+++ /dev/null
@@ -1,77 +0,0 @@
-
diff --git a/resources/hosting/pikapods.svg b/resources/hosting/pikapods.svg
deleted file mode 100644
index a3865feea..000000000
--- a/resources/hosting/pikapods.svg
+++ /dev/null
@@ -1,25 +0,0 @@
-Run on PikaPods
-
-Run on PikaPods Button
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/hosting/zenith.svg b/resources/hosting/zenith.svg
deleted file mode 100644
index 0b5b11539..000000000
--- a/resources/hosting/zenith.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-Deploy with Zenith
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 238919259..f0d8a0e06 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -328,22 +328,11 @@
"totalDuration": "Duração",
"defaultNewUsers": "Padrão para Novos Usuários",
"createdAt": "Data de Criação",
- "updatedAt": "Últ. Atualização",
- "pidAlbum": "Agrupamento de álbuns",
- "pidTrack": "Identificação das faixas"
+ "updatedAt": "Últ. Atualização"
},
"sections": {
"basic": "Informações Básicas",
- "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"
+ "statistics": "EstatÃsticas"
},
"actions": {
"scan": "Scanear Biblioteca",
@@ -373,9 +362,7 @@
"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",
- "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."
+ "noLibrariesAssigned": "Nenhuma biblioteca atribuÃda a este usuário"
}
},
"plugin": {
diff --git a/scanner/controller.go b/scanner/controller.go
index 1b13c1846..5eed6c58d 100644
--- a/scanner/controller.go
+++ b/scanner/controller.go
@@ -25,7 +25,7 @@ import (
)
var (
- ErrAlreadyScanning = model.ErrAlreadyScanning
+ ErrAlreadyScanning = errors.New("already scanning")
)
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, will resume an interrupted full scan,
-// or will rescan a library in full because its PID config changed, in one of the included libraries.
+// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted
+// full scan 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 || library.NeedsPIDRescan()
+ return library.FullScanInProgress
})
}
diff --git a/scanner/controller_test.go b/scanner/controller_test.go
index 974540e32..bdcb99eda 100644
--- a/scanner/controller_test.go
+++ b/scanner/controller_test.go
@@ -2,7 +2,6 @@ package scanner_test
import (
"context"
- "time"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
@@ -71,21 +70,14 @@ 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, 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},
+ {ID: 1, FullScanInProgress: true},
+ {ID: 2},
})
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 4edaecacd..7b6a6b097 100644
--- a/scanner/phase_1_folders.go
+++ b/scanner/phase_1_folders.go
@@ -40,7 +40,6 @@ 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)
@@ -52,13 +51,12 @@ 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)
- prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one
- 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)
+ lock sync.Mutex
+ numFolders atomic.Int64
}
func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
@@ -79,32 +77,16 @@ 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,
- prevAlbumPIDConf: prevAlbumPIDConf,
+ lib: lib,
+ fs: fsys,
+ lastUpdates: lastUpdates,
+ targetFolders: targetFolders,
}, nil
}
@@ -140,13 +122,14 @@ 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
- 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
+ prevAlbumPIDConf string
+ imageChanges *imageChangeCollector
}
func (p *phaseFolders) description() string {
@@ -155,6 +138,12 @@ 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
@@ -184,7 +173,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
// Check if folder is outdated
if folder.isOutdated() {
- if !folder.job.lib.FullScanInProgress {
+ if !p.state.fullScan {
// 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() {
@@ -250,7 +239,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 || entry.job.lib.FullScanInProgress {
+ if !foundInDB || p.state.fullScan {
filesToImport[fullPath] = dbTrack
} else {
info, err := af.Info()
@@ -300,18 +289,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, entry.id)
+ track := md.ToMediaFile(entry.job.lib.ID, 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 := track.AlbumID
+ prevAlbumID := ""
if prev := toImport[filePath]; prev != nil {
prevAlbumID = prev.AlbumID
- } else if entry.job.prevAlbumPIDConf != "" {
- prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf)
+ } else {
+ prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf)
}
_, ok := entry.albumIDMap[track.AlbumID]
if prevAlbumID != track.AlbumID && !ok {
@@ -464,7 +453,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en
if len(queueItems) > 0 {
queue := tx.ArtworkQueue()
enqueue := queue.Enqueue
- if entry.job.lib.FullScanInProgress {
+ if p.state.fullScan {
enqueue = queue.EnqueueIfMissing
}
if err := enqueue(ctx, queueItems...); err != nil {
diff --git a/scanner/scanner.go b/scanner/scanner.go
index 305c443f4..cd2fe3c8d 100644
--- a/scanner/scanner.go
+++ b/scanner/scanner.go
@@ -4,11 +4,13 @@ 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"
@@ -30,7 +32,6 @@ 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) {
@@ -47,17 +48,31 @@ func (s *scanState) sendWarning(msg string) {
s.sendProgress(&ProgressInfo{Warning: msg})
}
-func (s *scanState) markFailed(libID int) {
- if s.failedLibs == nil {
- s.failedLibs = map[int]bool{}
- }
- s.failedLibs[libID] = true
-}
-
func (s *scanState) sendError(err error) {
s.sendProgress(&ProgressInfo{Error: err.Error()})
}
+// 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 *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
startTime := time.Now()
@@ -89,7 +104,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
})
for _, target := range targets {
- folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
+ folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
if folderPath == "" {
folderPath = "."
}
@@ -122,10 +137,6 @@ 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
@@ -204,13 +215,10 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt
var successfulLibs []model.Library
for _, lib := range state.libraries {
- // 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 {
+ if lib.LastScanStartedAt.IsZero() {
// 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 || pidRescan)
+ return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan)
}, "scanner: begin library scan")
if err != nil {
log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
@@ -332,12 +340,11 @@ 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)
}
- // 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 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)
}
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 e8abb7c7d..0778bd6ec 100644
--- a/scanner/scanner_internal_test.go
+++ b/scanner/scanner_internal_test.go
@@ -4,6 +4,8 @@ package scanner
import (
"context"
"errors"
+ "os"
+ "path/filepath"
"sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline"
@@ -11,6 +13,43 @@ 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 f6634c875..546baf756 100644
--- a/scanner/scanner_multilibrary_test.go
+++ b/scanner/scanner_multilibrary_test.go
@@ -835,170 +835,4 @@ 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 d39cad5f6..1864a6a44 100644
--- a/scanner/walk_dir_tree.go
+++ b/scanner/walk_dir_tree.go
@@ -43,13 +43,6 @@ 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)
@@ -232,18 +225,6 @@ 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 0c2aba3e0..43939e5c2 100644
--- a/scanner/walk_dir_tree_test.go
+++ b/scanner/walk_dir_tree_test.go
@@ -4,10 +4,8 @@ import (
"context"
"fmt"
"io/fs"
- "maps"
"os"
"path/filepath"
- "slices"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@@ -262,44 +260,6 @@ 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"))
- })
- })
})
})
@@ -473,8 +433,8 @@ var _ = Describe("walk_dir_tree", func() {
})
// Regression for #5752: the production localFS must resolve file symlinks.
- // fs.ReadLink-based resolution can't follow targets outside the library
- // root, so full OS-level resolution is required.
+ // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
+ // resolution is not available and full OS-level resolution is required.
Context("production local storage FS", func() {
var libRoot string
var musicFS storage.MusicFS
@@ -500,7 +460,12 @@ 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())
- musicFS = newLocalMusicFS(libRoot)
+ 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())
})
walkRoot := func() *folderEntry {
@@ -735,17 +700,6 @@ 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 a75de3d8d..6acd95cfe 100644
--- a/server/apiv1/api.go
+++ b/server/apiv1/api.go
@@ -1,32 +1,53 @@
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
+ ds model.DataStore
+ auth *apiauth.Service
}
func New(ds model.DataStore) *Router {
- rt := &Router{ds: ds}
+ rt := &Router{ds: ds, auth: apiauth.New(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()
- r.Use(problemRecoverer, headAsGet(r))
+ 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.NotFound(func(w http.ResponseWriter, req *http.Request) {
writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint")
})
@@ -40,11 +61,18 @@ func (rt *Router) routes() http.Handler {
strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
- writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error())
+ if tooLarge(err) {
+ writeProblem(w, req, ClientError(err, tooLargeDetail))
+ return
+ }
+ writeProblemStatus(w, req, http.StatusBadRequest, ProblemCodeValidation, "request body is not valid JSON")
},
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
}
@@ -90,9 +118,18 @@ 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 req.URL.Path
+ return cmp.Or(req.URL.RawPath, req.URL.Path, "/")
}
diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go
index 4bffd47fc..9afd22465 100644
--- a/server/apiv1/api_gen.go
+++ b/server/apiv1/api_gen.go
@@ -7,21 +7,29 @@ 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"
- ProblemCodeInternal ProblemCode = "internal"
- ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed"
- ProblemCodeNotFound ProblemCode = "not_found"
- ProblemCodeUnauthorized ProblemCode = "unauthorized"
- ProblemCodeUnavailable ProblemCode = "unavailable"
- ProblemCodeValidation ProblemCode = "validation"
+ 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"
)
// Valid indicates whether the value is a known member of the ProblemCode enum.
@@ -29,12 +37,22 @@ 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:
@@ -46,32 +64,192 @@ func (e ProblemCode) Valid() bool {
}
}
-// Defines values for ServerInfoLoginMethods.
+// Defines values for Scope.
const (
- ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password"
+ ScopeAll Scope = "all"
+ ScopePassword Scope = "password"
+ ScopeRead Scope = "read"
)
-// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum.
-func (e ServerInfoLoginMethods) Valid() bool {
+// Valid indicates whether the value is a known member of the Scope enum.
+func (e Scope) Valid() bool {
switch e {
- case ServerInfoLoginMethodsPassword:
+ case ScopeAll:
+ return true
+ case ScopePassword:
+ return true
+ case ScopeRead:
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 for internal errors.
+ // Detail Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients.
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"`
@@ -87,10 +265,19 @@ 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. New methods may be added; clients ignore values they do not recognise.
- LoginMethods []ServerInfoLoginMethods `json:"loginMethods"`
+ // 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"`
// Name Human-readable server product name.
Name string `json:"name"`
@@ -105,9 +292,6 @@ 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).
@@ -117,11 +301,77 @@ 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)
@@ -131,6 +381,48 @@ 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) {
@@ -146,6 +438,148 @@ 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) {
@@ -273,6 +707,27 @@ 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)
})
@@ -280,8 +735,738 @@ 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 {
}
@@ -321,6 +1506,27 @@ 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)
@@ -365,6 +1571,199 @@ 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 2552e40b0..3851db3d1 100644
--- a/server/apiv1/api_test.go
+++ b/server/apiv1/api_test.go
@@ -3,7 +3,11 @@ 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"
@@ -16,6 +20,40 @@ 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))
@@ -67,6 +105,19 @@ 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 f89244e38..00b26abc4 100644
--- a/server/apiv1/apiv1_suite_test.go
+++ b/server/apiv1/apiv1_suite_test.go
@@ -2,10 +2,14 @@ package apiv1
import (
"bytes"
+ "context"
+ "encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
+ "path/filepath"
+ "strings"
"testing"
"github.com/getkin/kin-openapi/openapi3"
@@ -14,7 +18,11 @@ 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"
@@ -29,7 +37,13 @@ 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)
@@ -46,6 +60,64 @@ 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
new file mode 100644
index 000000000..eec4a3dae
--- /dev/null
+++ b/server/apiv1/auth_handlers.go
@@ -0,0 +1,90 @@
+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
new file mode 100644
index 000000000..48c2748ad
--- /dev/null
+++ b/server/apiv1/auth_handlers_test.go
@@ -0,0 +1,287 @@
+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
new file mode 100644
index 000000000..6b292a10b
--- /dev/null
+++ b/server/apiv1/db_test.go
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 000000000..c65830a6a
--- /dev/null
+++ b/server/apiv1/dto.go
@@ -0,0 +1,71 @@
+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
new file mode 100644
index 000000000..49f10f067
--- /dev/null
+++ b/server/apiv1/dto_test.go
@@ -0,0 +1,15 @@
+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
new file mode 100644
index 000000000..9d6c20716
--- /dev/null
+++ b/server/apiv1/gate.go
@@ -0,0 +1,422 @@
+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
new file mode 100644
index 000000000..f748957fc
--- /dev/null
+++ b/server/apiv1/gate_test.go
@@ -0,0 +1,406 @@
+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
new file mode 100644
index 000000000..36a546287
--- /dev/null
+++ b/server/apiv1/oapi-codegen-overlay.yaml
@@ -0,0 +1,12 @@
+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 b9236de1a..8301d8088 100644
--- a/server/apiv1/oapi-codegen.yaml
+++ b/server/apiv1/oapi-codegen.yaml
@@ -8,5 +8,7 @@ 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 aa6389357..fe5c8169b 100644
--- a/server/apiv1/problem.go
+++ b/server/apiv1/problem.go
@@ -3,26 +3,89 @@ 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)
- detail = ""
+ 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
}
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):
@@ -45,6 +108,19 @@ 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 {
@@ -53,20 +129,20 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code
}
func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
- var fieldErrors []ValidationError
+ var fieldErrs []ValidationError
var required *RequiredParamError
var invalid *InvalidParamFormatError
var tooMany *TooManyValuesForParamError
var unmarshal *UnmarshalingParamError
switch {
case errors.As(err, &required):
- fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"})
+ fieldErrs = append(fieldErrs, ValidationError{Field: required.ParamName, Message: "is required"})
case errors.As(err, &invalid):
- fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()})
+ fieldErrs = append(fieldErrs, ValidationError{Field: invalid.ParamName, Message: "has an invalid value"})
case errors.As(err, &tooMany):
- fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"})
+ fieldErrs = append(fieldErrs, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"})
case errors.As(err, &unmarshal):
- fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()})
+ fieldErrs = append(fieldErrs, ValidationError{Field: unmarshal.ParamName, Message: "has an invalid value"})
}
- writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...)
+ writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "invalid request parameters", fieldErrs...)
}
diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go
index 256296b3c..95ee46668 100644
--- a/server/apiv1/problem_test.go
+++ b/server/apiv1/problem_test.go
@@ -1,12 +1,15 @@
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"
@@ -19,12 +22,14 @@ 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.NewRequest(http.MethodGet, "/api/v1/server", nil)
+ r = httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/server", nil)
})
Describe("writeProblem", func() {
@@ -46,20 +51,68 @@ 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),
)
- 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("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())
+ })
It("hides details for internal errors", func() {
writeProblem(w, r, errors.New("db password is hunter2"))
@@ -95,14 +148,19 @@ 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(ContainSubstring(message))
+ Expect((*p.Errors)[0].Message).To(Equal(message))
},
Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"),
- 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"),
+ 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"),
)
+ 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
new file mode 100644
index 000000000..bc614878c
--- /dev/null
+++ b/server/apiv1/reference.go
@@ -0,0 +1,29 @@
+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_info.go b/server/apiv1/server_handlers.go
similarity index 62%
rename from server/apiv1/server_info.go
rename to server/apiv1/server_handlers.go
index 458363efe..f5c159629 100644
--- a/server/apiv1/server_info.go
+++ b/server/apiv1/server_handlers.go
@@ -18,6 +18,13 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec
ServerVersion: consts.Version,
SpecVersion: api.SpecVersion(),
SetupRequired: count == 0,
- LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword},
+ LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}},
+ }, nil
+}
+
+func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) {
+ return GetCapabilities200JSONResponse{
+ Core: &CoreCapability{Version: 1},
+ Password: &PasswordCapability{Version: 1},
}, nil
}
diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_handlers_test.go
similarity index 63%
rename from server/apiv1/server_info_test.go
rename to server/apiv1/server_handlers_test.go
index b9dae6f8d..59d0dcb3d 100644
--- a/server/apiv1/server_info_test.go
+++ b/server/apiv1/server_handlers_test.go
@@ -8,6 +8,7 @@ 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"
@@ -43,7 +44,8 @@ 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).To(ConsistOf(ServerInfoLoginMethodsPassword))
+ Expect(info.LoginMethods.Password).ToNot(BeNil())
+ Expect(w.Body.String()).To(ContainSubstring(`"loginMethods":{"password":{}}`))
})
It("reports setupRequired=false once a user exists", func() {
@@ -59,3 +61,33 @@ 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 3e58359da..e772c1da2 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -13,7 +13,6 @@ import (
"slices"
"strings"
"sync"
- "time"
"github.com/deluan/rest"
"github.com/go-chi/jwtauth/v5"
@@ -26,8 +25,6 @@ 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 (
@@ -127,16 +124,11 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
- c, err := ds.User().CountAll(r.Context())
- if err != nil {
- _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
- return
- }
- if c > 0 {
+ _, err = auth.CreateFirstAdmin(r.Context(), ds, username, password, nil)
+ if errors.Is(err, auth.ErrSetupComplete) {
_ = 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
@@ -145,26 +137,6 @@ 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 1095fafc9..b7db3a881 100644
--- a/server/auth_test.go
+++ b/server/auth_test.go
@@ -74,14 +74,27 @@ var _ = Describe("Auth", func() {
})
})
- Describe("createAdminUser", func() {
+ Describe("CreateFirstAdmin", func() {
It("returns the error when the user cannot be saved", func() {
- ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}}
- err := createAdminUser(context.Background(), ds, "johndoe", "secret")
+ failing := dsWithFailingPut(errors.New("db is down"))
+ _, err := auth.CreateFirstAdmin(ctx, failing, "johndoe", "secret", nil)
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/middlewares.go b/server/middlewares.go
index b65a2d6e1..149ddbfc3 100644
--- a/server/middlewares.go
+++ b/server/middlewares.go
@@ -234,16 +234,21 @@ 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) func(http.Handler) http.Handler {
+func ClientIPRateLimiter(requestLimit int, windowLength time.Duration, opts ...httprate.Option) 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(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)))
+ 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))
}
// 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 15cf70341..55f842914 100644
--- a/server/middlewares_test.go
+++ b/server/middlewares_test.go
@@ -494,6 +494,35 @@ 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() {
@@ -520,6 +549,12 @@ 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 61013dd5a..f1e6c4539 100644
--- a/server/nativeapi/inspect.go
+++ b/server/nativeapi/inspect.go
@@ -22,12 +22,7 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec
return nil, model.ErrNotFound
}
- lib, err := ds.Library().Get(ctx, file.LibraryID)
- if err != nil {
- return nil, err
- }
-
- return core.Inspect(file.AbsolutePath(), *lib, file.FolderID)
+ return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
}
func inspect(ds model.DataStore) http.HandlerFunc {
diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go
index 46c7ca210..37ae56c2b 100644
--- a/server/public/handle_streams.go
+++ b/server/public/handle_streams.go
@@ -60,8 +60,9 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
- streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0)
- stream, err := pub.streamer.NewStream(ctx, mf, streamReq)
+ stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{
+ Format: info.format, BitRate: info.bitrate,
+ })
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 965bc7e05..4b4a3545b 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{MockedTranscoding: &tests.MockTranscodingRepo{}}
+ ds = &tests.MockDataStore{}
shareRepo = &tests.MockShareRepo{}
ds.MockedShare = shareRepo
streamer = &mockStreamer{}
- pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))}
+ pub = &Router{ds: ds, streamer: streamer}
})
makeRequest := func(token string) *httptest.ResponseRecorder {
@@ -152,17 +152,8 @@ var _ = Describe("handleStream", func() {
makeRequest(token)
Expect(streamer.called).To(BeTrue())
- })
-
- 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}))
+ Expect(streamer.req.Format).To(Equal("mp3"))
+ Expect(streamer.req.BitRate).To(Equal(192))
})
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 8239ef927..142c474bd 100644
--- a/server/public/public.go
+++ b/server/public/public.go
@@ -21,15 +21,14 @@ 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, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router {
- p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver}
+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}
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 167197403..4b093b953 100644
--- a/server/serve_index.go
+++ b/server/serve_index.go
@@ -58,8 +58,6 @@ 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 277513768..e2df55c4b 100644
--- a/server/serve_index_test.go
+++ b/server/serve_index_test.go
@@ -89,8 +89,6 @@ 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 530588759..9394c8830 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, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{})
+ pubRouter = public.New(ds, artSvc, streamerSpy, 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 a5e4126cf..79b8529b4 100644
--- a/tests/mock_data_store.go
+++ b/tests/mock_data_store.go
@@ -30,6 +30,7 @@ type MockDataStore struct {
MockedPlugin model.PluginRepository
MockedArtwork model.ArtworkRepository
MockedArtworkQueue model.ArtworkQueueRepository
+ MockedGrant model.GrantRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
@@ -321,6 +322,19 @@ 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
new file mode 100644
index 000000000..be9134ece
--- /dev/null
+++ b/tests/mock_grant_repo.go
@@ -0,0 +1,8 @@
+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 6de2b9265..e21dcccce 100644
--- a/tests/mock_library_repo.go
+++ b/tests/mock_library_repo.go
@@ -145,17 +145,6 @@ 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/config.js b/ui/src/config.js
index 62b3cb822..e406e47cf 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -32,8 +32,6 @@ 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 f694ea75f..a04b6e311 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -331,22 +331,11 @@
"totalDuration": "Duration",
"defaultNewUsers": "Default for New Users",
"createdAt": "Created",
- "updatedAt": "Updated",
- "pidAlbum": "Album grouping",
- "pidTrack": "Track identity"
+ "updatedAt": "Updated"
},
"sections": {
"basic": "Basic Information",
- "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"
+ "statistics": "Statistics"
},
"actions": {
"scan": "Scan Library",
@@ -376,9 +365,7 @@
"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",
- "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."
+ "noLibrariesAssigned": "No libraries assigned to this user"
}
},
"plugin": {
diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx
index 8166bb2f3..0e69964b6 100644
--- a/ui/src/library/LibraryCreate.jsx
+++ b/ui/src/library/LibraryCreate.jsx
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'
-import PropTypes from 'prop-types'
import {
Create,
SimpleForm,
@@ -11,34 +10,7 @@ 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()
@@ -101,12 +73,9 @@ const LibraryCreate = (props) => {
return (
} {...props}>
-
-
-
)
diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx
index c42c7ac4b..53d17ac7f 100644
--- a/ui/src/library/LibraryEdit.jsx
+++ b/ui/src/library/LibraryEdit.jsx
@@ -1,11 +1,9 @@
-import React, { useCallback, useState } from 'react'
-import PropTypes from 'prop-types'
+import React, { useCallback } from 'react'
import {
Edit,
FormWithRedirect,
TextInput,
BooleanInput,
- Confirm,
required,
SaveButton,
useTranslate,
@@ -24,9 +22,6 @@ import {
ReadOnlySizeField,
Title,
} from '../common'
-import config from '../config'
-import { PIDInputs } from './PIDInput'
-import { pidConfigChanged } from './pidPresets'
const useStyles = makeStyles({
toolbar: {
@@ -58,131 +53,8 @@ const CustomToolbar = ({ showDelete, ...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 (
-
- )
-}
-
-LibraryEditForm.propTypes = {
- formProps: PropTypes.object.isRequired,
- canEditPath: PropTypes.bool,
- canDelete: PropTypes.bool,
-}
-
const LibraryEdit = (props) => {
+ const translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
@@ -221,11 +93,91 @@ const LibraryEdit = (props) => {
{...props}
save={save}
render={(formProps) => (
-
+
)}
/>
diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx
deleted file mode 100644
index 926adc839..000000000
--- a/ui/src/library/LibraryEdit.test.jsx
+++ /dev/null
@@ -1,125 +0,0 @@
-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
deleted file mode 100644
index 6481dc392..000000000
--- a/ui/src/library/PIDInput.jsx
+++ /dev/null
@@ -1,114 +0,0 @@
-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) => (
-
- ))}
-
- {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
deleted file mode 100644
index 0483fc691..000000000
--- a/ui/src/library/pidPresets.js
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index dab1c82e2..000000000
--- a/ui/src/library/pidPresets.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-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/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index e1695e980..d2b17b108 100644
--- a/ui/src/playlist/PlaylistList.jsx
+++ b/ui/src/playlist/PlaylistList.jsx
@@ -67,15 +67,15 @@ const PlaylistFilter = (props) => {
)
}
-export const ToggleField = ({ resource, source }) => {
+const TogglePublicInput = ({ resource, source }) => {
const record = useRecordContext()
const notify = useNotify()
- const [toggle] = useUpdate(
+ const [togglePublic] = useUpdate(
resource,
- record?.id,
+ record.id,
{
...record,
- [source]: !record?.[source],
+ public: !record.public,
},
{
undoable: false,
@@ -86,25 +86,48 @@ export const ToggleField = ({ resource, source }) => {
)
const handleClick = (e) => {
- toggle()
+ togglePublic()
e.stopPropagation()
}
- if (!record) return null
-
return (
)
}
-export const ToggleAutoImport = (props) => {
+const ToggleAutoImport = ({ resource, source }) => {
const record = useRecordContext()
- return record?.path ? : null
+ 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
}
const PlaylistListBulkActions = (props) => {
@@ -146,7 +169,9 @@ 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 c05833166..4fbc6d516 100644
--- a/ui/src/playlist/PlaylistList.test.jsx
+++ b/ui/src/playlist/PlaylistList.test.jsx
@@ -1,9 +1,7 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
-import { TestContext } from 'ra-test'
-import { RecordContextProvider } from 'react-admin'
-import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
+import { PlaylistLove } from './PlaylistList'
vi.mock('../config', () => ({
default: { enableFavourites: true },
@@ -15,7 +13,6 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'}
),
- isWritable: (ownerId) => ownerId === 'me',
}))
describe('', () => {
@@ -35,50 +32,3 @@ 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/themes/dracula.js b/ui/src/themes/dracula.js
index 45559c3af..2e4ae38e5 100644
--- a/ui/src/themes/dracula.js
+++ b/ui/src/themes/dracula.js
@@ -185,6 +185,16 @@ 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 3e2955dcd..0f4cbd7c4 100644
--- a/ui/src/themes/gruvboxDark.js
+++ b/ui/src/themes/gruvboxDark.js
@@ -121,6 +121,16 @@ 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/tokyoNight.js b/ui/src/themes/tokyoNight.js
index 9f6424b77..07d372a6b 100644
--- a/ui/src/themes/tokyoNight.js
+++ b/ui/src/themes/tokyoNight.js
@@ -184,6 +184,16 @@ 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 a61c0fe87..f84cd0be9 100644
--- a/ui/src/themes/tokyoNightLight.js
+++ b/ui/src/themes/tokyoNightLight.js
@@ -184,6 +184,16 @@ 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 fbb5e9bc8..4ccefe820 100644
--- a/ui/src/themes/useCurrentTheme.js
+++ b/ui/src/themes/useCurrentTheme.js
@@ -63,8 +63,6 @@ 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 6553d9866..65c3be8c6 100644
--- a/ui/src/themes/useCurrentTheme.test.jsx
+++ b/ui/src/themes/useCurrentTheme.test.jsx
@@ -3,10 +3,6 @@ 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'
@@ -165,27 +161,4 @@ 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')
- },
- )
- })
})