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>
- 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>
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>
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>
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
* feat(persistence): store hashed API keys on players
* feat(core): refresh key-bound players without renaming them
Add Players.Touch, which records usage for a player already identified by
an API key without guessing its identity or overwriting its name. Register
also stops renaming players that have an API key.
Register no longer returns player save errors (or a stale FindMatch
ErrNotFound when the save is rate-limited); save failures are only logged,
and only the transcoding lookup error is returned, same as Touch.
* feat(subsonic): authenticate with OpenSubsonic API keys
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
* feat(subsonic): add tokenInfo and advertise apiKeyAuthentication
* feat(server): add endpoints to generate and revoke player API keys
* feat(ui): manage player API keys
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
* fix(subsonic): throttle API keys per key and IP
A stale key on one device exhausted the shared per-IP bucket and locked out
every valid key from the same IP. The limiter only stores a hash of the bucket
string, so the key is not retained. Also adds e2e coverage of API key auth
through the real repository, and clarifies the player resolution log message.
* fix(ui): keep the new API key dialog open until closed
The key is shown only once, so Escape and backdrop clicks no longer dismiss
it. Also clarifies when the key can be used as a password.
* refactor: simplify API key code paths
Share the player refresh tail between Register and Touch, fold the
ownership-filtered write tail into execOwned, parse the query once for
apiKey conflicts, derive HasAPIKey in the player mock, share the player
form inputs between create and edit, and pick the delete button by key
state instead of spreading conditional props.
* feat(players): set API keys through the player record
The key is a write-only apiKey field applied on save: required and owner-only on create, optional on edit, empty to revoke. Replaces the generate/revoke endpoints.
* fix(players): reject API keys already in use
Creating or editing a player with a key another player already has now returns a validation error instead of a 500, and a create that loses the race no longer leaves a keyless player behind. Ownership is checked before the key on create.
* feat(ui): edit player API keys as a form field
Replaces the show-once dialog, whose icon-less Close button was invisible on mobile. The key is generated in the browser, required and pre-filled on create.
* fix(ui): keep new player API keys out of the record cache
The json-server create response echoes the request body, and undoable edits merge the payload into the cache, so the key could reappear on the edit page. Strip it from the create result and save player edits pessimistically. Also fall back to a prompt when the clipboard write fails.
* fix(ui): polish player API key field
Set userId on the created player record so owner actions show immediately, and show a neutral no-key message to non-owners.
* refactor: simplify player API key create and field
Write the key hash in the create INSERT so the unique index settles
races, re-read the created player instead of hand-building the cached
record, reuse isWritable for the revoke check, and collapse the key
field's derived state and generate/regenerate buttons.
* fix(ui): let the API key field size like other inputs
fullWidth is now opt-in instead of forced.
* fix(ui): align the API key field with other player inputs
Apply react-admin's input className, move the actions (now including Copy) below the field, and use a monospace font so the whole key fits.
* fix(ui): redirect to the player list after create
Matches the other create pages.
* refactor(persistence): name the write-access rule for owned rows
Owned-row writes now say which row they target and who may write it: ownedRow(rowID, ownerOrAdmin|ownerOnly) builds the WHERE, updateOwnedRow applies it, and SetAPIKey uses ownerOnly instead of a hand-built user_id filter. updateOwned/deleteOwned keep their signatures.
* fix(players): apply an edit's key change and fields atomically
Update now runs SetAPIKey and the column update in one transaction. Also shares the key format check, drops FindByAPIKey's unneeded empty-key guard, and sets the context username only on the apiKey path.
* fix(subsonic): treat any credential param sent with apiKey as a conflict
The spec requires error 43 when u, p, t or s is present with apiKey, even with an empty value.
* refactor(subsonic): leave the player cookie code unchanged for key-bound requests
Return early instead of wrapping the cookie block, so the diff (and CodeQL's view of it) matches master.
* fix(subsonic): don't count key lookup errors as failed logins
A database error while checking a key sent as the password now surfaces as a server error instead of a bad password, so it no longer feeds the failed-login limiter.
* feat(players): use nds_ as the API key prefix
Part of a Navidrome secret prefix family (nd + a letter for the kind), alongside ndg_ for API v1 grants.
* feat(ui): make player API keys easier to find
Label the Settings menu entry "Players & API keys", add an API key
filter to the player list, show the key icon in the mobile list, and
add Brazilian Portuguese translations for the new player strings.
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(ui): always show the player API key filter
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(ui): hide the unset Last Seen date in the player list
Players created by hand have no last_seen yet, which showed as 12/31/1.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
* feat(api): add OpenAPI v1 spec skeleton, lint ruleset and bundle tooling
vacuum v0.30.6's `bundle --composed` mangles component names for this
spec's multi-file layout (duplicates Problem as Problem__schemas etc.),
so api-bundle uses the Redocly CLI (npx @redocly/cli bundle) instead.
* fix(api): pin the Redocly CLI version
Tried moving components out of the root document (per libopenapi's
nested_files example) so vacuum's own bundler could produce clean
names, but any component declared via $ref inside components.* still
gets a __<parent>-suffixed twin regardless of collisions elsewhere, so
vacuum's --composed bundler can't cleanly bundle this spec. Pin the
already-working Redocly fallback to an exact version instead of
@latest.
* fix(api): bundle the OpenAPI spec with vacuum
vacuum's --composed bundler suffixes any component reached via a $ref
written directly inside the root document's own components.* block,
regardless of collisions elsewhere. Dropping the root-level schemas/
parameters/responses declarations (keeping only securitySchemes, and
leaving every component file under api/openapi/components/ untouched)
lets vacuum bundle cleanly with no __ suffixes, going back to Go-only
tooling. Components nothing references yet (ListMeta, offset, limit,
BadRequest, Unauthorized, Forbidden, NotFound) are absent from the
bundle until a later task's operation references them.
* fix(api): make spec lint rules cover all schemas and error codes
nd-schema-property-descriptions targeted $.components.schemas, but our
schemas live in path/response files, not the root document, so it was
dead code; switched to $..properties[*] to walk every resolved schema
wherever it ends up. nd-error-responses-are-problems only checked a
hardcoded status-code list; switched to a patternProperties schema
matching the full 4xx/5xx range. Also: api-diff now diffs against the
merge-base with API_DIFF_BASE (falling back to its tip with a notice
if no merge-base exists), gen no longer depends on api-gen until Task
3 wires up oapi-codegen, and api-lint suppresses vacuum's banner.
* feat(api): embed the bundled OpenAPI spec and expose its version
* feat(api): generate the v1 server interface with oapi-codegen
* feat(api): add RFC 9457 problem responses for API v1
* feat(api): add API v1 router with /server discovery and spec routes
* fix(api): serve the OpenAPI document without range support
* feat(api): mount API v1 behind the DevAPIv1 flag
* chore(ci): lint, regenerate and diff the OpenAPI v1 spec
* refactor(api): tighten spec version access, lint rules and test naming
* refactor(api): simplify spec routes, tests and OpenAPI tooling
Share one If-None-Match parser (utils/req) between the image and spec
routes, declare the YAML spec response as an object so tests need no
decoder override, and reuse ETag/304 spec components.
Install the OpenAPI tools only when missing or at a different version,
fail api-diff when its base ref does not exist, and in CI cache the
tools, fold regeneration into the go generate check, and fetch only the
PR base commit for the breaking-change gate.
* refactor(api): raise the list limit maximum to 2000 and drop the flag test
* feat(api): treat added enum values as non-breaking
Enums in API v1 are open: clients must accept unknown values. api-diff
now downgrades response-property-enum-value-added to INFO, while
removing a value from a request enum stays breaking.
* feat(api): gate breaking changes on x-stability-level
Every operation declares x-stability-level (alpha, beta, stable). oasdiff
ignores breaking changes to alpha operations and rejects lowering a
level, so unreleased endpoints can evolve while beta and stable ones
stay additive. All current operations start as alpha.
* feat(api): declare loginMethods as an enum
Prefix generated enum constants with their type name so enums sharing a
value (for example password) cannot collide in package apiv1.
* feat(api): send Allow on 405 and answer HEAD wherever GET is routed
chi only sets Allow in its default 405 handler, so the problem-format
handler now builds it by matching each method against the v1 router.
HEAD requests fall back to the GET route, as RFC 9110 expects.
* refactor(api): hash the spec ETag with xxh3
The bytes are compiled in, and the digest was truncated to 64 bits
anyway, so this matches the artwork ETags instead of paying for
cryptographic strength we discard.
* docs(api): explain the about:blank problem type
* feat(api): make code the problem identifier and omit a blank type
RFC 9457 says clients switch on the type URI, but no adopter surveyed
ships both a populated type and a separate code. Declare code as an
enum, and send type only once a problem has semantics of its own.
* fix(api): advertise the configured base path in the served OpenAPI spec
With BaseURL=/music the API is mounted at /music/api/v1, but the spec
told clients to call /api/v1 at the host root. The server now rewrites
servers[0].url to BasePath + /api/v1 when it serves the document.
Relative server URLs were tested first: "." and "../v1" work in
openapi-generator, Swagger UI and Redoc, but Scalar resolves them
against the page origin, so it breaks even without a base path. The
committed bundle keeps /api/v1, and a test pins that it appears exactly
once, which the rewrite relies on.
* refactor(scanner): remove the legacy ffmpeg metadata extractor
The ffmpeg extractor in scanner/metadata_old has not been wired into the scanner since the taglib-only rewrite, so it was only exercised by its own tests. Remove the package, the FFmpeg.Probe method and its ffmetadata command that only it used, and the startup fallback for Scanner.Extractor="ffmpeg". Configs that still set it keep working: unknown extractors already fall back to taglib with a warning.
* fix(conf): warn and fall back to taglib for an unknown Scanner.Extractor
Validate the option when loading the config, so invalid values such as the removed "ffmpeg" extractor are reported once at startup instead of only when a library storage is created.
* refactor(persistence): adopt generic deluan/rest repository API
Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.
* refactor(persistence): base repository helpers take a context
* refactor(persistence): LibraryRepository takes a context per call
* refactor(persistence): PropertyRepository takes a context per call
* refactor(persistence): UserPropsRepository takes a context per call
* refactor(persistence): TranscodingRepository takes a context per call
* refactor(persistence): ShareRepository takes a context per call
* refactor(persistence): PlayerRepository takes a context per call
* refactor(persistence): RadioRepository takes a context per call
* refactor(persistence): PlayQueueRepository takes a context per call
* refactor(persistence): Tag and Genre repositories take a context per call
* refactor(persistence): PluginRepository takes a context per call
* refactor(persistence): Scrobble repositories take a context per call
* refactor(persistence): FolderRepository takes a context per call
* refactor(persistence): Artwork repositories take a context per call
* refactor(persistence): UserRepository takes a context per call
* refactor(persistence): ArtistRepository takes a context per call
ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.
* test(persistence): assert artist role sort sanitization in ReadAll
* refactor(persistence): AlbumRepository takes a context per call
* test(persistence): pass the test context to album repository helpers
* refactor(persistence): MediaFileRepository takes a context per call
* refactor(persistence): Playlist repositories take a context per call
* refactor(persistence): build all repositories once per store
* refactor(core): REST repository wrappers are built once
* refactor(persistence): repositories are stateless
Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.
* chore(lint): skip containedctx in test files
* refactor: share simplifications from the stateless repositories sweep
Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.
* chore(core): assert REST wrappers implement Persistable
* chore: reformat imports
* perf(persistence): build repositories on first use
Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.
* fix(persistence): clean plugin references per deleted user
A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.
* fix(core): unload disabled plugins even when a user delete fails
A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.
* chore(deps): pin deluan/rest to v1.0.1
Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).
* test: use the spec context instead of context.Background()
Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.
* test: declare the spec context once per Describe
Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
* refactor: add Tags.First and slice.GroupOrdered helpers
Tags.First returns the first value of a tag or an empty string, replacing the inline len-check-then-index pattern in FullTitle, FullAlbumName, Album.FullName and the Subsonic album version mapping.
slice.GroupOrdered is slice.Group returning the groups in first-seen order, for callers that need a deterministic order the map-based Group cannot give.
* fix(archiver): give same-named albums their own folder in artist zips
Artist zips put every album in a folder named after the album, so two albums with the same name (an original and a deluxe edition, or names that only differ in characters the sanitizer replaces) were merged into one folder, with tracks mixed together and duplicate zip entries when file names collided.
The folder is now named after FullAlbumName(), so with AppendAlbumVersion on (the default) the version is part of the name, matching what clients display. Albums whose sanitized names still clash get a " [suffix]" taken from the first field that has a distinct, non-empty value for all of them: album version, year, release type, record label, catalog number, then a short album id. This follows the shape of beets' %aunique{} path function.
Albums are also grouped with slice.GroupOrdered instead of a map, so the zip is deterministic.
* fix(archiver): use the release year to tell same-named albums apart
Taggers often write an edition's date to the Date tag next to an original date, and the scanner then stores the original year in Year and the edition's year in ReleaseYear. Reissues of the same album therefore share Year, so the year disambiguator could not tell them apart and they fell through to the album id suffix. Prefer ReleaseYear and fall back to Year when it is not set.
Found by downloading an artist zip from a live server built from this branch.
* fix(archiver): let one clashing album keep the plain folder name
A disambiguator was only accepted when every clashing album had a non-empty value, so an original and its deluxe edition (with the version not appended to the name) fell through to the album id suffix. Accept a field whose values are distinct across the group even when one of them is empty, as beets' %aunique{} does: that album keeps the plain name, which the suffixed folders cannot clash with. Two or more empty values still count as a tie.
* test(archiver): refactor tests for album naming conventions and query order
* docs(jellyfin): refresh the README's known limitations
Rewrite the Known limitations list against the current code and Jellyfin
12.1, dropping stale entries (synthetic blurhash, unchecked artist access,
global genres) and adding the real gaps: search skipping filters, the
one-character minimum, the 2,000-item search cap, position-based playlist
entry ids, the rating param, missing endpoints and unemitted Fields.
Also move the lyrics description into its own section, add the missing
routes and filters to the endpoint table, and drop the stale artist-access
TODO in resolveItemByID.
* docs(jellyfin): list MaxConcurrentStreams env var and all e2e stubs
* fix(server): stop swallowing errors and correct two response bugs
Four independent bugs found while reviewing the HTTP layer:
initial_setup.go: createInitialAdminUser assigned the users.Put error to a
shadowed err, so the outer err (always nil by then, since a CountAll failure
panics) was returned instead. A failure to create the admin user was reported
as success, and initialSetup went on to commit the "setup complete" property
in the same transaction — so no admin user existed and initial setup was
skipped on every later boot.
auth.go: createAdminUser logged the Put error but returned nil, so createAdmin
fell through to doLogin and answered 401 "Invalid username or password"
instead of surfacing the real failure. It also logged the whole model.User,
which puts the new admin's password in the log in clear text; every other call
site logs user.UserName.
native_api.go: writeDeleteManyResponse did not return after http.Error when
marshaling failed, then wrote a nil body over the 500. It also built the
single-id body by hand with html.EscapeString, which does not escape
backslashes, so an id ending in one produced `{"id":"a\"}` — invalid JSON.
Both shapes now go through json.Marshal. A failed Write is now logged rather
than answered with http.Error, which could not work once the body had started.
handle_shares.go: handleM3U set Content-Type after WriteHeader, so it was
never sent and shared playlists were served with a sniffed type.
Signed-off-by: zapisanchez <zapisanchez@gmail.com>
* fix(server): address review feedback
- writeDeleteManyResponse uses rest.RespondWithJSON, so the response now
has Content-Type: application/json. This also removes a marshal error
branch that could never run.
- createInitialAdminUser returns the CountAll error instead of panicking,
and wraps its errors. initialSetup now stops the server with log.Fatal
when setup fails. Before, the error was dropped and the server started
with a half-done setup.
- Trim comments that described PR history.
---------
Signed-off-by: zapisanchez <zapisanchez@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
POST /Playlists dropped the client's IsPublic flag, so every playlist was
created private. JellyBox Player's create-playlist form defaults its "public"
checkbox to true, so JellyBox users could never create a public playlist.
Upstream's PlaylistsController passes IsPublic into PlaylistCreationRequest.
core/playlists.Create has no visibility parameter and widening it would ripple
into the Subsonic and native APIs, so createPlaylist follows the same pattern
updatePlaylist already uses: after Create succeeds, a non-nil IsPublic is
applied with a follow-up Update. The field is a pointer so an absent one keeps
today's default instead of forcing private.
If that second write fails the handler surfaces the error through playlistError
rather than returning the id: answering 200 for a playlist that is not as
visible as the client asked is the same silent drop this fixes.
* sec(server): sanitize user-controlled filenames in Content-Disposition
Playlist export, Subsonic download and public share download built the
Content-Disposition header by interpolating a user-controlled name into a
quoted-string with fmt.Sprintf. A name containing a double quote closes the
string early and the rest is parsed as additional parameters, so a playlist
named `party"; filename="evil.html` yielded
attachment; filename="party"; filename="evil.html.m3u"
letting whoever chose the name decide what the browser saves the download as.
The names come from playlists, album/artist names and media file tags.
Go's net/http already rewrites CR and LF in header values to spaces, so
response splitting was not reachable; parameter injection was.
Add str.ContentDispositionAttachment, which emits a sanitized ASCII-only
quoted `filename` plus an RFC 5987 `filename*` carrying the original UTF-8
name, and use it at all four call sites. The `filename*` parameter also fixes
non-ASCII names, which previously went out raw or were mangled by sanitizing.
Signed-off-by: zapisanchez <zapisanchez@gmail.com>
* fix(server): keep download names intact and sanitize filename*
Rework ContentDispositionAttachment after review. Names with no ASCII
letters now fall back to download.<ext> instead of a bare extension
(東京.mp3 gave filename="mp3"). filename* is built from the same
sanitized name as the ASCII fallback, so path separators, reserved
characters, control and bidi characters, and invalid UTF-8 no longer
reach it. The ASCII fallback transliterates accents and typographic
punctuation (Legião -> Legiao, She’s -> She's) through the existing
sanitize.Accents and str.Clear helpers, keeps leading dots, and only
trims trailing ones. Names are capped at 255 bytes, keeping the
extension.
Pure ASCII names now get only the quoted filename parameter, so the
header for them matches the previous output byte for byte. filename*
is encoded with mime.FormatMediaType instead of a hand-written RFC 5987
encoder. Adds tests for the M3U export and Subsonic download headers.
* fix(server): handle dot-only names and long fake extensions
A name made only of dots trimmed down to an empty filename. It now
falls back to download, like an empty stem does.
path.Ext treats anything after the last dot as the extension, so a long
suffix with no real extension was kept whole and replaced the stem with
download, going past the 255-byte cap. Suffixes longer than 16 bytes
are now treated as part of the stem and truncated with it.
Neither case is reachable from the current call sites, which always
append a short extension.
---------
Signed-off-by: zapisanchez <zapisanchez@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
The scrobble endpoint accepts multiple ids, but a nowPlaying notification
(submission=false) describes a single track, so only the first id is used.
The extra ids were dropped silently, which made client bugs invisible. Log a
warning instead, keeping the existing behavior for clients that rely on it.
* fix(subsonic): limit failed authentication attempts per client IP and username
The Subsonic API checked credentials on every request with no limit on failures, so any
account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped
per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same
settings that guard the UI login. Every request carries credentials, so only failures count:
a slot is taken before the check and given back on success or on a server error, which also
stops concurrent guesses from overshooting the limit.
Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no
Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and
internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is
now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket.
* refactor(subsonic): simplify failed authentication limiter
Release the limiter slot from a single place in authenticate(), after the user lookup and
credential check, instead of separately in the canceled branch. Store attempt counters by
value instead of by pointer, and drop limiter unit tests that only repeated the middleware
specs.
* fix(subsonic): wait for an in-flight auth check instead of rejecting
Slots were reserved before the credential check and only released afterwards, so once
AuthRequestLimit checks for the same client IP and username overlapped, the next request was
answered with error code 40 even when its credentials were valid. Clients that fan out parallel
requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty
five, and the web UI authenticates its own /rest calls the same way.
A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather
than failing when other checks for that key are in flight. Failures are recorded after the check,
and a request is only rejected when the key already reached the limit within the window. A waiting
request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most
AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land.
* docs(subsonic): state the real guess ceiling of the auth limiter
The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead.
On a multi-library instance, a few reads and writes built their own queries
without the per-user library filter that every other media read applies. A
user granted only some libraries could see, and store, tracks from libraries
they had no access to.
- getBookmarks now filters the query. It has to be the query and not the
result: the loop below it pre-sizes the response from the bookmark count,
so a row dropped afterwards would emit an empty bookmark entry.
- createBookmark rejects an id the caller cannot read, returning error 70 to
match getSong. Stored rows are left alone rather than purged, so a
temporary revoke does not lose saved playback positions.
- playlistTrackRepository Read, Count and GetAlbumIDs get the filter their
siblings CountAll and GetMediaFileIDs already had. Read is the one that
mattered most: its id is the integer playlist position, so it needed no
track id at all.
- Playlist track writes are filtered in playlistRepository.addTracks, the
only writer of playlist_tracks rows apart from smart playlists, so Add,
Insert, AddAlbums/AddArtists/AddDiscs and a full replace through Put all
go through it. Insert reserves a slot per requested id, so when the filter
drops one it renumbers to close the hole.
- playTracker.GetNowPlaying honours its context instead of discarding it.
The cache is process-global, so the filter belongs in the tracker rather
than in the Subsonic handler, and any future caller inherits it.
Admins and single-library installs are unaffected: applyLibraryFilter and
HasLibraryAccess both short-circuit for them. Scanner playlist sync runs as
admin, and M3U and CLI imports already resolve tracks through FindByPaths as
the same user, so neither changes.
* fix(ui): only redirect to ExtAuth logout URL for proxy-authenticated sessions
react-admin calls authProvider.logout() when the boot-time checkAuth fails
and after a 401, not only when the user clicks Logout. With
ExtAuth.LogoutURL set, every unauthenticated page load (e.g. direct LAN
access that bypasses the auth proxy) was sent to the IdP sign-out page and
the login form was never shown.
Redirect only when the page was authenticated by the reverse proxy
(config.auth is present). Other sessions fall back to the login form.
Fixes#6175
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(server): only warn about untrusted ExtAuth sources when the header is sent
UsernameFromExtAuthHeader checked the source IP before looking for the user
header, so every request from an IP outside ExtAuth.TrustedSources logged a
warning, even when it carried no header at all. With direct LAN access
alongside a forward-auth proxy, a single polling client produced a constant
stream of warnings (twice per Subsonic request, since the middleware chain
resolves the username in both checkRequiredParameters and authenticate).
Look for the header first and warn only when an untrusted source actually
sends it, which is the case worth seeing: a misconfigured proxy or a spoof
attempt.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
* feat(jellyfin): add Quick Connect sign-in
Jellyfin clients can now sign in without a password: the client shows a
6-digit code, a signed-in user approves it, and the client redeems a secret
for its access token.
- core/quickconnect: in-memory store shared by both routers through wire.
Codes expire after 10 minutes; a secret redeems only once (Jellyfin
allows repeats for 10 minutes); at most 1000 pending requests.
- Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect.
Admins may approve for another user via UserId, like Swiftfin's admin page.
Initiate and redeem share the login rate limiter; Connect does not, since
Finamp and Streamyfin poll it every second.
- Web UI: a Quick Connect item in the user menu looks up the code and shows
the app and device before approving, so a user can't be tricked into
approving an unknown device blindly.
- Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters
when the Jellyfin API is enabled.
* refactor(jellyfin): tidy Quick Connect naming and route guards
Group the Quick Connect routes under one requireQuickConnect guard, make the
request's device a named field so req.Device.ID can't be mistaken for a
request id, and rename the web API response type to quickConnectDevice.
* refactor(jellyfin): remove duplicated Jellyfin date formatting function
* test(jellyfin): set play count and starred in the song fixture literal
* refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper
* fix(jellyfin): bound the client fields Quick Connect keeps in memory
Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version
header fields for up to ten minutes. With no header size limit, each pending
request could hold about 1 MB, and even a short field kept the whole header
alive because the parsed values are substrings of it. Reject fields over 512
bytes and copy the stored values.
Also answer 500 instead of 401 when the redeem user lookup fails for a reason
other than the user being gone.
* fix(jellyfin): rate-limit Quick Connect code approval
Any signed-in user could try codes without limit on the Jellyfin Authorize
endpoint and the web UI lookup/authorize endpoints, and so could approve
another person's pending device for their own account. Apply the same per-IP
limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
* feat(jellyfin): compute the address advertised by auto-discovery
* fix(jellyfin): use TLSEnabled and path.Join for the auto-discovery address
* feat(jellyfin): answer LAN auto-discovery broadcasts
* test(jellyfin): e2e check that auto-discovery matches the public server identity
* feat(jellyfin): add opt-in AutoDiscovery option and start the listener
* fix(jellyfin): close the auto-discovery socket on read errors and quiet reply failures
* refactor(jellyfin): build the auto-discovery address with publicurl and log bind failures in place
discoveryAddress now hands only the discovery-specific part (the requester-facing host) to publicurl.AbsoluteURL, so the BaseURL, scheme and BasePath rules live in one place. ServeDiscovery logs its own bind failure and returns nothing, so the caller cannot route the error into the server errgroup. Tests use a DescribeTable and no longer wait on a fixed timeout to prove a packet was ignored.
* refactor(jellyfin): run auto-discovery as its own service in the main errgroup
Discovery is now a small type that needs only a DataStore, started by startJellyfinDiscovery like the other background services, so the errgroup waits for it on shutdown and startServer is back to a one-line mount. The server id resolution moved to resolveServerID with a package-level lock: the Router and Discovery are separate objects, and a per-Router lock would let them persist two different ids on first boot.
* refactor(jellyfin): build Discovery through wire and drop the serverName forwarder
startJellyfinDiscovery now follows its siblings: negative guard with a DISABLED debug log, and the service comes from a CreateJellyfinDiscovery wire injector instead of an inline constructor. The Router.serverName method only forwarded to the package function, so its four call sites call the function directly.
* fix(jellyfin): skip auto-discovery for unix socket servers without a BaseURL host
With Address set to a unix socket nothing listens on Port, so the route-facing IP plus Port pointed clients at a dead URL. Discovery now logs a warning and does not start in that mode unless BaseURL names the proxy host, which AbsoluteURL already advertises as-is.
* docs(jellyfin): note which address auto-discovery advertises on restricted binds
Also stop using a hostname Address in the fallback spec: a hostname like localhost binds a single interface, so it is not an example of the route-facing fallback being right. An empty Address is.
* feat(jellyfin): advertise Jellyfin server version 12.1.0
Streamyfin, jellyfin-android and jellyfin-androidtv refuse servers older than
10.10, Swiftfin warns below 12.0, and @jellyfin/sdk flags anything below its
minimum as unsupported, so 10.9.11 locked those clients out. 12.1.0 is the
current Jellyfin release (after 10.11 Jellyfin renumbered to 12.0). No client
checked has an upper bound or assumes the major is 10, and the value keeps
three parts because the Kotlin SDK and Swiftfin reject two-part versions.
* feat(jellyfin): acknowledge POST /Sessions/Playing/Ping
Jellyfin clients ping this endpoint to keep a transcode job alive while
paused. Navidrome ties transcodes to the stream request, so there is nothing
to keep alive; answer 204 like Jellyfin instead of a 404. It shares one no-op
handler with Sessions/Capabilities, renamed to acknowledge.
* feat(jellyfin): reorder playlist entries via Items/{entryId}/Move
Adds POST /Playlists/{id}/Items/{entryId}/Move/{newIndex}, which clients use
to reorder playlists. It maps the entry's PlaylistItemId (its position) and
Jellyfin's zero-based newIndex onto the existing core ReorderTrack, which
enforces ownership. As in Jellyfin, an index past the end appends and an
unknown entry is a no-op; out-of-range positions never reach Reorder, which
would otherwise shift unrelated rows.
* feat(jellyfin): honor position when adding items to a playlist
POST /Playlists/{id}/Items takes an optional zero-based position (added to
the Jellyfin spec in 12.0). Match Jellyfin: zero or negative prepends, past
the end appends, otherwise the new items are inserted at that index in the
order they were added.
Adds PlaylistTrackRepository.Insert and core playlists.InsertTracks, which
shift the following entries and insert in one transaction, instead of
appending and moving each new track with its own ReorderTrack call.
* feat(jellyfin): add type-specific InstantMix routes
Jellyfin exposes InstantMix under Songs/, Albums/, Artists/ and Playlists/
as well as Items/, plus the legacy Artists/InstantMix and
MusicGenres/InstantMix forms that take the seed as ?id=. Clients generated
from the Jellyfin SDKs call the type-specific routes, which 404ed. All of them
now share the existing Items/{id}/InstantMix handler; the external provider
already builds mixes from song, album, artist, playlist and genre seeds.
* feat(jellyfin): honor Width, Height and Fill* image size params
The image endpoint only read MaxWidth/MaxHeight, so clients that size covers
with fillWidth/fillHeight (Manet, Finamp) or width/height got the full-size
original on a cold artwork cache: a 578 KB PNG instead of a 13 KB resize.
Jellyfin applies Width/Height, caps them with MaxWidth/MaxHeight, then
shrinks to the smallest size that still covers the Fill box. Navidrome
resizes on one dimension, so the tightest bound wins and a fill box counts
as its larger side.
* feat(jellyfin): answer HEAD on audio, file and image routes
Fintunes sends HEAD to /Audio/{id}/universal to read the content type and
detect direct play (a Content-Length means direct play), and to the audio and
image URLs before a download, aborting the download when it fails. Those routes
were GET-only, so HEAD got a 404. HEAD now reuses the GET handlers. Direct
play goes through Stream.Serve, which already answers HEAD; a transcode
answers with the target content type and no length without starting ffmpeg,
so a probe never costs a transcode.
* fix(jellyfin): clamp playlist move and insert positions before adding one
movePlaylistItem computed min(newIndex+1, SongCount): newIndex=MaxInt wrapped
to a negative position, and Reorder then left the playlist with a gap
(positions 2, 3, 999998), making the moved entry unmovable. Clamp against the
playlist length first. addToPlaylist's min(position, MaxInt32)+1 wrapped on
32-bit builds and req.Int truncated large values there, so a far-past-the-end
position prepended; parse as int64 and clamp before converting.
* fix(playlists): validate reorder positions inside the write transaction
Reorder never checked its positions, so a source outside the playlist or a
destination past its end shifted rows around a missing entry and left a gap
(e.g. ids 1 and 3), which made the moved entry unmovable afterwards. The
Jellyfin Move handler guarded this with a SongCount read before ReorderTrack,
but a concurrent removal between the two reopened it, and the native API
reorder endpoint passed client positions through unchecked.
Reorder now reads the last position in the same transaction, returns
ErrNotFound for a source outside the playlist and clamps the destination.
ReorderTrack uses an immediate transaction so that read and the updates are
atomic against other writers. The Jellyfin handler drops its pre-check and
maps ErrNotFound to Jellyfin's no-op 204; the native API now answers 404 for
an unknown track instead of corrupting the order.
* fix(jellyfin): send SessionInfo on login so JellyBox gets past sign-in
JellyBox parses AuthenticateByName's SessionInfo as a required object and
fails silently when it is missing, leaving the user on the login screen.
Real Jellyfin always sends it (SessionManager.AuthenticateNewSessionInternal,
10.10.7 and master), so the login response now carries a full SessionInfo
built from the user and the MediaBrowser auth header. It includes every field
JellyBox (Id, PlayState) and Finamp (UserId, LastActivityDate, the activity
and control bools, PlayState's CanSeek/IsPaused/IsMuted) require once the
object is present. The session Id is derived from client and device id, so
repeated logins from one install share it.
* fix(jellyfin): ignore IncludeItemTypes names that aren't Jellyfin kinds
JellyBox opens an album with ParentId=<album>&IncludeItemTypes=music. Music
is not a BaseItemKind, and Jellyfin's comma-delimited binder drops values it
cannot parse, so real Jellyfin treats the request as having no type filter and
lists the album's tracks. Navidrome returned an empty list, so every album
opened empty. Entries that aren't BaseItemKind names are now dropped before
type resolution, so an all-unknown list behaves like an absent one. Real kinds
Navidrome doesn't serve, such as Boxset, still return nothing.
* fix(jellyfin): treat universal Container as the direct-play list
On /Audio/{id}/universal, Container lists the "container|codec" entries the
client can direct play, and TranscodingContainer/AudioCodec name the target
when it can't (UniversalAudioController builds DirectPlayProfiles from it).
Navidrome passed the whole list to the decider as one target format, which
matched nothing and fell back to DefaultDownsamplingFormat, so JellyBox got
every MP3 transcoded to Opus. /universal now has its own handler: a source
matching an entry keeps its format (still downsampled under a bitrate cap),
anything else is transcoded to TranscodingContainer, then AudioCodec. The
/stream routes keep treating Container as the target format.
* refactor(jellyfin): let the stream decider resolve universal requests
streamUniversal matched the Container list itself with plain string equality
and then asked the legacy resolver for the source format. That skipped the
decider's container and codec aliases (mp4 vs m4a, ogg vs opus), and a
direct-playable source over the bitrate cap was transcoded to its own format
instead of the client's TranscodingContainer.
The shared part of ResolveRequest (server-side player override, player
MaxBitRate cap, decision to Request mapping) moves to a resolve helper, and a
new ResolveClientRequest exposes it for callers that build their own
ClientInfo. streamUniversal now turns Container into DirectPlayProfiles and
TranscodingContainer/AudioCodec into a transcoding profile, so the decision
uses the same rules as the Subsonic getTranscodeDecision path. streamFile and
the /stream routes share a serveStream helper, NewSessionInfo reads the clock
itself, and duplicate comments and tests are trimmed.
* fix(jellyfin): match Jellyfin's item payloads so strict clients can sync
Manet (iOS/macOS) aborted its whole library sync on the first item that was
missing a key its decoder requires, leaving the library empty (#6147). Every
gap was a field real Jellyfin always sends:
- dates now use .NET's round-trip layout with 7 fractional digits, which Manet
requires and plain RFC3339 failed
- playlists carry SortName/DateCreated, and every item carries MediaType,
ImageTags, ChannelId and, when Fields asks, Genres/GenreItems/Tags
- albums carry Artists and LocationType; songs always carry HasLyrics
- the library view is a full CollectionFolder (ChildCount, DateCreated,
SortName, Path, LocationType, UserData), read from the library rows rather
than the user projection, which has no counts
- IncludeItemTypes matches case-insensitively and returns nothing for Jellyfin
kinds Navidrome has none of, instead of falling back to every album
Verified against a real Jellyfin 10.10.7 server and a live Manet client.
* refactor(jellyfin): fold the repeated empty-list defaults into one helper
The three mappers each initialised Genres/GenreItems/Tags the same way, and the
e2e suite grew three near-identical specs walking every item type. Both now go
through a single helper and one table.
* refactor(jellyfin): fill the always-present item fields at the serialization edge
The defaults real Jellyfin puts on every item were spread across three mappers,
so item types nobody had tested yet (playlists, genres, the library view) still
shipped payloads a strict client rejects. stampItem now takes the request's
Fields and fills them for every item, which is provably the only path to JSON.
Also drops the hand-copied BaseItemKind list: only an absent IncludeItemTypes
defaults to albums now, so any type Navidrome does not serve returns nothing,
as it would from Jellyfin. The synthetic playlists folder matches
case-insensitively like the rest, /Items/{libraryId} reads the full library row
instead of the count-less user projection, and PremiereDate and LastPlayedDate
go through jellyfinDate rather than spelling the layout out again.
* fix: return 404 instead of 500 for missing native API resources
The deluan/rest controller only maps rest.ErrNotFound to 404, comparing with ==.
Most repositories return model.ErrNotFound, which had the same message but was a
different value, so requesting a missing playlist, album, artist, song, radio,
player, transcoding or library returned 500. This also applied to other users'
private playlists.
Make model.ErrNotFound the same value as rest.ErrNotFound. This fixes every REST
route at once, with no per-route wrapping. errors.Is checks against either
error keep working, and nothing wraps model.ErrNotFound before it reaches the
controller.
Fixes#6130
* fix(radio): return not found when deleting a missing radio station
radioRepository.Delete used the shared delete helper, which never reports a
missing row because SQL DELETE on zero rows is not an error. Deleting an unknown
id silently succeeded: DELETE /api/radio/{id} returned 200, and the Subsonic
deleteInternetRadioStation endpoint returned ok.
Delete now checks the affected row count and returns model.ErrNotFound when
nothing was deleted. The native API returns 404, and deleteInternetRadioStation
returns error 70 (data not found). This matches Subsonic 6.1.6, gonic (both
verified live) and Ampache (verified in source). Airsonic-Advanced does not
implement this endpoint.
The shared delete helper is unchanged, as several callers rely on deletes of
absent rows succeeding.
* fix(ui): return 404 for missing files that are not missing or do not exist
missingRepository.Read filtered media files by bare "id" and "missing" columns.
The media file query joins the library table, so SQLite rejected the query as
ambiguous and GET /api/missing/{id} returned 500. Read now loads the file with
MediaFileRepository.Get, which qualifies the column, and returns not found
when the file does not exist or is not marked missing.
* fix: report missing rows on single-item deletes and adopt deluan/rest errors.Is
Bump github.com/deluan/rest to the version whose controller matches errors
with errors.Is and errors.As. model.ErrNotFound stays the same value as
rest.ErrNotFound, so the many hand-written conversions from model.ErrNotFound
to rest.ErrNotFound in repositories, core services and test mocks did nothing.
Remove them, along with the duplicate rest.ErrNotFound check in the Subsonic
error mapper. Mappings from model.ErrNotAuthorized stay, as those are
different errors.
User and transcoding deletes had the same silent success as radio: the shared
delete helper never reports a missing row, so their not-found checks never
fired and DELETE /api/user/{id} and /api/transcoding/{id} returned 200 for
unknown ids. Add deleteByID, which returns model.ErrNotFound when no row
matched, and use it for radio, user and transcoding. Also drop the dead
sql.ErrNoRows branch from delete, since a DELETE never returns it.
The Subsonic deleteUser endpoint is not implemented (501), so this does not
change the Subsonic API. Plugin deletes keep the silent helper: there is no
REST route for them, and the plugin manager only deletes rows it just read.
* chore: drop ErrNotFound comment and its identity test
The alias to rest.ErrNotFound is self-explanatory, and the identity test only
restated the declaration.
* refactor: alias model.ErrNotAuthorized to rest.ErrPermissionDenied
Like ErrNotFound, make model.ErrNotAuthorized the same value as the rest
library's error, so REST endpoints map it to 403 directly. This removes the
ErrNotAuthorized to rest.ErrPermissionDenied mappings in the library and
playlist REST adapters and the duplicate check in the Subsonic error mapper.
Handlers that check model.ErrNotAuthorized now also recognize
rest.ErrPermissionDenied returned by repositories, so writePlaylistError, the
image upload handlers and the public share handler return 403 for it instead
of their fallback status. The error message changes from "not authorized" to
"permission denied".
* fix(artwork): cap declared image dimensions before resizing
resizeStaticImage decoded the image with a raw image.Decode, so a small file declaring huge dimensions (e.g. a PNG header claiming 50k x 50k) forced a multi-gigabyte allocation on the serve-time resize path. The processor already guards its own decodes with decodeCapped; use it here too so the same 64M pixel cap applies to uploaded and sidecar images served through the cache.
* fix(share): validate every resource ID and reject mixed types when saving
Save only resolved the first ID in ResourceIDs to pick the resource type; the remaining IDs were never checked. A non-existent or hidden entity could ride along behind a valid first ID, and IDs of different kinds were accepted as one share. Resolve every ID as the current user and require all of them to be the same kind, returning ErrNotFound or ErrValidation otherwise.
* fix(share): scope album and media file shares to the owner's libraries
loadMedia already loaded artist and playlist shares as the share owner, but album and media_file shares used the repository context. Public share rendering carries no user, so the library filter was skipped and the share listed albums and tracks from libraries the owner cannot access. Streaming was already blocked, so only metadata leaked. Use ownerContext for all resource types.
* fix(server): limit login payload size and surface first-admin creation errors
The unauthenticated /login and /createAdmin handlers decoded the request body
with no size limit. Add a body-limit middleware to the /auth route group that
caps the payload at 8KiB, which is plenty for a username and password. Also
make createAdminUser return the datastore error instead of logging it and
returning nil, which previously let createAdmin proceed to a login attempt for
a user that was never saved.
* fix(conf): create the log file readable only by the owner
The log file was created with mode 0644, so other local users could read it. Logs can contain usernames, paths and, at trace level, request details, so create it with 0600 instead. Existing files keep their current mode.
* fix(lastfm): stop logging the auth token when fetching the session key fails
The Last.fm callback token was written to the log as a structured field on failure. The redaction hook only matches value patterns, so it was not masked. Drop the field; the request ID is enough to correlate the failure.
* fix(db): allow a music folder path containing a single quote on fresh databases
The library table migration interpolated conf.Server.MusicFolder into the SQL with fmt.Sprintf, so a path such as /music/Rock 'n' Roll produced invalid SQL and the migration failed on a brand new database. Bind the path as a parameter instead.
* fix(scanner): return an error when the folder watcher cannot start
When notify.Watch failed, the watcher goroutine logged the error and exited, but never signalled the started channel, so Start blocked until its context was cancelled and left the watching flag set. Call notify.Watch before spawning the event loop, so Start returns the error right away, the started/failed signalling goes away, and the storage can be watched again later.
* fix(jellyfin): limit the login request body size
The Jellyfin AuthenticateByName endpoint decoded its JSON body with no size limit, the same gap the native /auth routes had. Export the login body-limit middleware from the server package and apply it to the Jellyfin login route, before the optional per-IP rate limiter, so both unauthenticated login surfaces share the same 8KiB cap.
* fix(scanner): share one scanner instance across all injectors
Each wire injector built its own scanner controller, so the Subsonic and native API routers held a different instance from the ones used by the startup scan, the periodic scan, the folder watcher and the SIGUSR1 handler. Status reads the in-progress file and folder counters from its own instance, so getScanStatus reported scanning=true with count=0 for every scan not started through the API. Verified live with a startup scan: master reports count 0 while scanning, this branch reports the real counts. Expose the controller through a singleton, as the watcher, broker and play tracker already are, and wire everything to it. New stays available for tests that need isolated controllers.
* fix(share): do not panic when a media file share has no visible tracks
Share.CoverArtID picked a random track for media file shares without checking that any track was loaded. The tracks are empty when the files went missing, were deleted, or the owner lost access to their library, and the public share page then panicked inside the random pick and returned a 500. Return an empty artwork ID instead, so the page renders with the placeholder cover. The old guard on the split resource IDs was dead code, since SplitN always returns at least one element.
The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP
and X-Forwarded-For headers on every request, including when no trusted
reverse proxy was configured. The login rate limiters on /auth/login and the
Jellyfin /Users/AuthenticateByName derived their bucket from that value, so
an unauthenticated client could rotate a forwarding header and get a fresh
bucket for every password attempt, defeating the brute-force protection.
Resolve the client IP with chi's ClientIPFrom* middlewares instead. The
forwarding headers are only honoured when ExtAuth.TrustedSources is set and
the connecting peer is in that list, reusing the trust check that external
authentication already applies; otherwise the peer address is used. The
X-Forwarded-For chain is now walked against the trusted CIDRs rather than
taking its leftmost entry, so a spoofed value prepended by the client is
skipped.
Both limiters now key on the resolved address. The resolved address is still
mirrored into RemoteAddr, so request logging, player registration and the
Jellyfin local-network check keep reporting the client rather than the proxy.
Reported by gehan-psbc.
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes#6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
The comment justified anonymous access with "item ids are unguessable".
That is not true: an artist id is a deterministic, unsalted hash of the
artist name, id.NewHash(id.NewHash(str.Clear(lower(name)))), so it is
computable offline by anyone who knows the name.
The real reason the route is public is that upstream Jellyfin's is too.
ImageController.GetItemImage carries no [Authorize] attribute (verified on
v12.0, master/13.0.0, v10.11.9 and v10.10.7), and an anonymous request
reaches LibraryManager.ItemIsVisible with a null user, which returns true
unconditionally. Clients build cover URLs with no credentials at all, so
requiring auth here would break them.
No behavior change.
When a player has a forced transcoding format, ClientInfo.ForceFormat cleared
DirectPlayProfiles unconditionally. A FLAC source on a player configured to
transcode to FLAC was therefore re-encoded to FLAC, wasting CPU and bandwidth
for no gain. Worse, the transcoder pipes ffmpeg output to stdout, so the
resulting FLAC has total_samples=0 and no seek table -- an offline copy of it
can never be seeked. Reported against getTranscodeDecision by the Symfonium
author.
ForceFormat now rebuilds DirectPlayProfiles from the matching transcoding
profiles instead of dropping them: a client declaring a transcoding profile for
a format is proof it can consume that format, so a source already in it is
served as-is. Container and codec come from resolveTargetFormat, so a legacy
"oga" target_format yields an ogg/opus profile, and the profile's
MaxAudioChannels is carried across.
DirectPlayProfile has no bitrate field, so restoring direct play needs a
ceiling to keep an over-bitrate source out of it. GetTranscodeDecision now
seeds that ceiling from the transcoding row's DefaultBitRate when a format was
successfully forced, with the player's own MaxBitRate still taking precedence.
This also closes a gap where the new endpoint ignored DefaultBitRate entirely:
an mp3 320 source on a player forced to mp3@192 was served at 320, while the
legacy /rest/stream path correctly gave 192.
Applied via CapBitrate, which only ever lowers, so a client declaring a
stricter limit keeps it. The legacy path (applyServerOverride) is untouched --
ForceFormat has no other callers.
* docs: point jftui client link to canonical repository
* docs: fix relative path to webhook-rs example in nd-pdk-host README
* docs: fix capability schema paths in plugin examples README
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Jellify's Discover tab calls GET /Items/Latest and got a 404: we only routed the
/Users/{userId}/Items/Latest form, which real Jellyfin marks [Obsolete] and hides
from its OpenAPI spec, so SDK-generated clients never see it. Add the current
route alongside the legacy one, both served by the same handler.
getLatest also ignored ParentId, so browsing a library or an artist returned the
newest albums across everything the user can see. It now scopes to the library
when ParentId names one, and filters to that artist's albums otherwise, which
also makes a stale id return nothing instead of silently widening back to the
full library set. A malformed ParentId 404s, matching /Items and the contract
decodeFilterParam documents.
PlaybackInfo returned a TranscodingUrl prefixed with the /jellyfin mount path.
Clients concatenate that value onto a server base URL that already carries the
prefix, producing /jellyfin/jellyfin/Audio/{id}/universal and a 404, so playback
never started. Jellify, jellyfin-web, jellyfin-vue and Streamyfin all consume the
field this way; real Jellyfin emits it server-relative (StreamInfo.ToUrl is called
with a nil baseUrl).
Emit the path server-relative to match. Finamp is unaffected: it builds its own
stream URLs and never reads the field.