Compare commits

..

38 commits

Author SHA1 Message Date
Deluan Quintão
a60a9e6642
Merge branch 'master' into apiv1-auth 2026-09-29 12:18:11 -04:00
Deluan
58090b42ec fix(log): redact marked secrets in every field type
redactSecrets only looked at strings, maps and errors, so a marked secret
inside a slice, struct or []byte field was logged as is, and a typed-nil
error field made it panic. It now renders each field with fmt.Sprint, as
the text formatter does (which also survives typed-nil errors), and writes
[]byte raw.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-29 10:37:53 -04:00
Deluan Quintão
d3a434a610
Merge branch 'master' into apiv1-auth 2026-09-28 23:34:40 -04:00
Deluan
cbc22b09cb refactor(api): group API v1 handlers into one file per OpenAPI tag
Handlers live in <tag>_handlers.go, so the package grows by tag rather than
by endpoint, and shared convention helpers keep plain names without
clashing with tag files.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 22:08:01 -04:00
Deluan
0628721006 refactor(api): simplify API v1 auth after dropping access tokens
- Fold Allowed into Expand and drop the ErrInsufficientScope sentinel; the
  gate's scopeError is now the only source of insufficient_scope.
- Replace the two-value authKind with a public flag, inline loadUser, and
  pass the grant to touch.
- Read a declared request body once before validation, so the JSON checks
  and the handler no longer depend on kin-openapi restoring the exact bytes.
- Merge the Service tests into one file and drop specs that only covered
  the removed liveness cache. The revoked-during-password-change spec now
  revokes after the gate authenticates, so it reaches ChangePassword again.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:47:16 -04:00
Deluan
9655c97b84 test(log): compute the expected source line instead of hard-coding it
Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:47:16 -04:00
Deluan
04b9e97eb2 fix(api): stop the API v1 request validator from rewriting bodies
Filling schema defaults made kin-openapi re-encode the body, so trailing data
after the JSON value of POST /auth/password was silently dropped instead of
answering 400 like the other endpoints. The handler already applies the
revokeOtherGrants default itself.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:06:31 -04:00
Deluan
d1b876097f refactor(api): use the grant secret as the API v1 bearer credential
API v1 no longer mints short-lived JWT access tokens. Clients send the grant
secret from POST /auth/login or /auth/setup as `Authorization: Bearer` on
every request.

Every request already looked the grant up in the database, so the JWT gave
no speed or revocation benefit and only added a refresh loop, which early
client authors pushed back on. The grant already is an API key: one per
client sign-in, scoped and revocable. Revocation is now immediate on every
node; the contract promises "within one minute".

Removed: POST /auth/token, the grantAuth scheme, the TokenRequest and
AccessToken schemas, the token_expired problem code, the API v1 JWT signer
and its signing key, the grant liveness cache, and PropertyRepository.PutIfAbsent.
ResolveGrant is now Authenticate.

Short-lived tokens return later only as narrow media tokens for
?access_token= on media URLs, together with the media endpoints.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:05:57 -04:00
Deluan
052be800a7 test(log): fix the source line assertion after the import change
Signed-off-by: Deluan <deluan@navidrome.org>
2026-09-28 21:05:56 -04:00
Deluan
1b5c68a203 refactor(log): mark secrets on their own line and cover session keys
Mark secret values with log.WithSecrets on a separate line instead of
nesting the call in argument lists. Also mark Last.fm/ListenBrainz session
keys written through SessionKeys.Put and the PasswordEncryptionKey checksum,
which still reached trace logs, and ignore values shorter than 8 characters
so a short plaintext marked after a failed encryption cannot mangle SQL text
or the [REDACTED] marker.
2026-09-28 20:06:28 -04:00
Deluan
ddcc611af3 refactor(log): redact caller-marked secret values in the log hook
Replaces the statement-wide SQL arg redaction from the previous commit,
which hid every arg of property and password writes (user names, emails,
scanner properties) and made troubleshooting harder.

log.WithSecrets marks values on a context, log calls now pass their
context to the logrus entry, and the redaction hook replaces those values
in the message and fields. logSQL logs the real args again; only the
encrypted password, the API v1 key and the JWT secrets are marked.
2026-09-28 20:06:28 -04:00
Deluan
c205b894aa fix(api): stop sending X-RateLimit headers from API v1
Rate-limit counts are per node, so X-RateLimit-Remaining would mislead
clients once API v1 runs behind more than one instance. API v1 now sends
only Retry-After on 429; v0 and Jellyfin limiters keep their headers.
2026-09-28 20:06:28 -04:00
Deluan
8789561b60 fix(api): consolidate grant deletes and harden API v1 auth after review
- GrantRepository keeps three deletes: DeleteForUser, DeleteStaleEpochs
  (replaces DeleteOtherEpochs and DeleteIfEpoch) and DeleteIdle. Delete(id)
  is gone; the idle path in ResolveGrant now calls DeleteIdle, so a grant
  renewed by another node between the read and the delete survives.
  settleEpoch deletes the user's grants below the snapshot's epoch, which
  is safe outside the transaction because epochs only move forward.
- The "dead grants on an older epoch are only deleted when presented"
  policy note moves from the repository to core ListGrants.
- SQL trace logging no longer prints the args of property writes (signing
  keys) or user password writes, both encrypted with a key that may be the
  public default. The SQL statement is still logged.
- The spec gate rejects JSON body keys that differ from a declared property
  only in case. kin-openapi validates exact names while encoding/json
  decodes case-insensitively, so {"scopes":[],"Scopes":null} minted a
  token with every scope and a "Client" key skipped maxLength.
  It also rejects data after the first JSON value, which the handlers'
  decoder ignores and which let a body skip the alias check.
- The liveness cache trims its eviction log on evict, not only on put, so
  evict-only traffic stays bounded; the floor still drops stale fills.
- createAccessToken, login and setupFirstAdmin declare Cache-Control:
  no-store on their success responses.
2026-09-28 20:06:28 -04:00
Deluan
d368eef3ce fix(log): redact named string types instead of panicking
The redaction hook matched fields by reflect.Kind but read them with a
v.(string) type assertion, so any named string type (such as an enum)
panicked the log call. API v1 problem logging hit this on every error.
2026-09-28 20:06:28 -04:00
Deluan
550e03976c refactor(api): shorten the grant touch comment 2026-09-28 20:06:28 -04:00
Deluan
45d8a7724d test(api): share API v1 test helpers and check scopes in Go
Move the end-to-end call/setup/mint helpers to a suite-level test client
and the apiauth login/mustMint helpers to package level. Replace the
hardcoded vacuum x-scope enum with a Go test that every known scope is
a valid spec Scope; the gate already rejects unknown x-scope values.
2026-09-28 20:06:28 -04:00
Deluan
c479c4f581 refactor(api): tighten API v1 auth internals and first-admin setup
Load the signer lock-free once cached, read the signing key before
generating one, cap the liveness cache at its limit, share one
grantable-scope predicate, and let CreateFirstAdmin open its own locked
transaction with an optional in-transaction follow-up.
2026-09-28 20:06:28 -04:00
Deluan
2aca5fe365 refactor(api): simplify the API v1 spec gate
Key operations by a struct, share the validation options, derive the
route method from chi, and set every rule-derived field in buildGateOp.
Build WWW-Authenticate challenges and 413 problems in one place, fetch
the principal through one helper, and refuse gate rules that name an
operation missing from the spec.
2026-09-28 20:06:28 -04:00
Deluan
a41e656706 refactor(api): reuse existing helpers in API v1 auth
Use gg.V, slice.Map, chi's RequestSize, core/auth's encryption key and
ClientIPRateLimiter instead of local copies; share the grant idle expiry
constant and a Grant.LastActivity helper; pass last use as a time value.
2026-09-28 20:06:28 -04:00
Deluan
88a652346b docs(api): say a password change ends the user's other Navidrome sessions
changePassword now documents that on Navidrome the change also ends the
user's sessions on its other APIs, regardless of revokeOtherGrants, which
only covers API v1 grants.
2026-09-28 20:06:28 -04:00
Deluan
870942c7dd fix(api): record the full client IP, challenge presented tokens and mark token responses no-store
The gate passed server.ClientIP to Authenticate and ResolveGrant, which
masks IPv6 addresses to their /64 for rate limiting, so lastUsedIp stored
a prefix. A new server.ClientAddr returns the resolved address unmasked;
ClientIP builds on it and stays the rate limiter key.

A 401 raised after a token was accepted by the gate, such as a password
change whose grant was revoked mid-request, carried a bare Bearer
challenge. writeProblemStatus now answers Bearer error="invalid_token"
whenever the request presented a bearer token.

login, setupFirstAdmin and createAccessToken responses now carry
Cache-Control: no-store (RFC 6749 section 5.1), set by the gate for a
small list of operations so their error responses are covered too.

The v0/v1 setup race test also checks the v1 status is 201 or 409.
2026-09-28 20:06:28 -04:00
Deluan
406a2e9cf4 fix(api): never widen the scopes an access token claims
Authenticate ran token claims through Expand, so a token claiming `all`
would have gained every known scope. Only a holder of the signing key
could mint one, but tokens should carry concrete scopes only. Claims now
go through Allowed, which keeps known scopes (and admin only for admins)
and never expands `all`.
2026-09-28 20:06:28 -04:00
Deluan
3e3b73a9cc fix(api): use a 256-bit API v1 signing key
The HS256 key was 22 base62 characters, about 128 bits, below the 256 bits
RFC 7518 section 3.2 asks for. New keys are 32 bytes from crypto/rand,
hex-encoded before being encrypted and stored. Keys already stored keep
working unchanged.
2026-09-28 20:06:28 -04:00
Deluan
b1cfa932be fix(api): make logout idempotent and hide grants left on a stale epoch
Logout answered an undeclared 404 when its grant was already gone, for
example revoked by another node inside the liveness cache window or by a
concurrent logout. It now treats a missing grant as success and still
evicts the cache entry, so logout always answers 200.

Grants left on an older user epoch (after a password reset through the
existing UI, or a login that raced a password change) are dead but only
deleted when presented. Listing and counting grants now filter on the
user's current epoch, so those grants no longer show up.

dropGrant now deletes before evicting, like RevokeGrant, so a concurrent
cache fill cannot re-cache a grant that is being dropped.
2026-09-28 20:06:28 -04:00
Deluan
28d023449d feat(api): report login methods and add GET /capabilities 2026-09-28 20:06:27 -04:00
Deluan
294c1a9a6a feat(api): add API v1 login, setup, token, grant and password endpoints
Adds the seven auth operations to the spec (createAccessToken, listGrants,
revokeGrant and logout in core; login, setupFirstAdmin and changePassword in
the password module), the bearerAuth/grantAuth schemes, and vacuum rules
requiring explicit security and a known x-scope. The strict handlers sit on
core/apiauth and are covered end to end against a real SQLite database.

The first operations with parameters make the generated code import
github.com/oapi-codegen/runtime. An oapi-codegen overlay renames the shared
offset/limit parameter types, since a generated Offset clashes with Ginkgo's
dot-imported Offset in this package's tests; the published spec is unchanged.
2026-09-28 20:06:27 -04:00
Deluan
4f2d350757 fix(api): route the spec gate on chi's exact path and name the scope in every insufficient-scope challenge 2026-09-28 20:06:27 -04:00
Deluan
3e645959f8 feat(api): enforce API v1 security from the spec and harden problem responses 2026-09-28 20:06:27 -04:00
Deluan
71c379ff44 feat(api): add API v1 token checks, grant management and password change 2026-09-28 20:06:27 -04:00
Deluan
e3cf849507 fix(api): retry the signing-key load after a failure and drop the password from issued grants 2026-09-28 20:06:27 -04:00
Deluan
0bbca1b133 feat(api): add API v1 login, setup, grant resolution and token minting 2026-09-28 20:06:27 -04:00
Deluan
8c0ba43e9a fix(api): tolerate small clock skew between nodes on access tokens 2026-09-28 20:06:27 -04:00
Deluan
899cc428fd feat(api): add the API v1 signing key and access-token JWTs 2026-09-28 20:06:27 -04:00
Deluan
7dd8dba12b feat(api): add grant liveness cache 2026-09-28 20:06:27 -04:00
Deluan
21ce7c7115 feat(api): add API v1 scope rules and grant secrets 2026-09-28 20:06:27 -04:00
Deluan
158721ea60 fix(server): create the first admin inside one locked transaction 2026-09-28 20:06:27 -04:00
Deluan
5688283d78 feat(persistence): add PropertyRepository.PutIfAbsent 2026-09-28 20:06:27 -04:00
Deluan
2afe2ffe0a feat(api): add api_grant storage for API v1 grants 2026-09-28 20:06:27 -04:00
159 changed files with 7735 additions and 2049 deletions

View file

@ -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() {

View file

@ -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

View file

@ -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"
]
}
}
}
}

View file

@ -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 <secret>`. 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 <secret>`."
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

View file

@ -0,0 +1,4 @@
description: Always `no-store`, because the response carries a secret.
schema:
type: string
enum: [no-store]

View file

@ -0,0 +1,3 @@
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
schema:
type: string

View file

@ -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

View file

@ -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:

View file

@ -0,0 +1,5 @@
description: "The request body is too large (`payload_too_large`)."
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -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

View file

@ -1,4 +1,7 @@
description: Missing, invalid, or expired credentials.
headers:
WWW-Authenticate:
$ref: ../headers/WWWAuthenticate.yaml
content:
application/problem+json:
schema:

View file

@ -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."

View file

@ -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

View file

@ -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.}

View file

@ -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

View file

@ -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.

View file

@ -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 <secret>`."
grant:
description: The new grant.
allOf:
- $ref: ./Grant.yaml
user:
description: The user the grant belongs to.
allOf:
- $ref: ./AuthUser.yaml

View file

@ -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.

View file

@ -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

View file

@ -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."

View file

@ -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.}

View file

@ -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."

View file

@ -0,0 +1,2 @@
type: object
description: Username and password login (`POST /auth/login`). No settings yet.

View file

@ -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`.

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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 <secret>`. 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`."

188
api/openapi/paths/auth.yaml Normal file
View file

@ -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

View file

@ -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}

View file

@ -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:

View file

@ -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.

View file

@ -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
}

View file

@ -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())
})
})
})

View file

@ -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:

View file

@ -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"))
})
})

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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 (

View file

@ -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)
}

View file

@ -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"))
})
})

View file

@ -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")
}

14
core/apiauth/context.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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))
})
})

43
core/apiauth/db_test.go Normal file
View file

@ -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
}

View file

@ -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 }

63
core/apiauth/scopes.go Normal file
View file

@ -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"))
}

View file

@ -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())
})
})
})

20
core/apiauth/secret.go Normal file
View file

@ -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[:])
}

View file

@ -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())
})
})

264
core/apiauth/service.go Normal file
View file

@ -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)
})
}

View file

@ -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")
}

View file

@ -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() {

View file

@ -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

View file

@ -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
}

View file

@ -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{}))
})
})
})

View file

@ -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) {

View file

@ -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"}})

View file

@ -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,

View file

@ -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")

54
core/auth/first_admin.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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))
})
})

View file

@ -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))

View file

@ -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}

View file

@ -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)},

View file

@ -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)
}

View file

@ -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 ""

View file

@ -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

View file

@ -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

View file

@ -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;

2
go.mod
View file

@ -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

11
go.sum
View file

@ -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=

View file

@ -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:]
}
}

View file

@ -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() {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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

67
model/grant.go Normal file
View file

@ -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)
}

24
model/grant_test.go Normal file
View file

@ -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))
})
})
})

View file

@ -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
}

View file

@ -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)
}

View file

@ -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(""))
})
})

View file

@ -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_"))
})
})

View file

@ -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

View file

@ -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{

View file

@ -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() {

View file

@ -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",

View file

@ -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 {

View file

@ -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"`),
)
})

View file

@ -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 {

View file

@ -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

View file

@ -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())
})
})

View file

@ -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())))
}

View file

@ -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))
})
})

View file

@ -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)

View file

@ -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

View file

@ -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)

Some files were not shown because too many files have changed in this diff Show more