2020-02-29 16:01:09 -09:00
|
|
|
package persistence
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2022-09-30 14:54:25 -08:00
|
|
|
"errors"
|
2020-02-29 16:01:09 -09:00
|
|
|
|
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
|
|
|
"github.com/deluan/rest"
|
|
|
|
|
"github.com/navidrome/navidrome/model"
|
2023-12-09 09:52:17 -09:00
|
|
|
"github.com/pocketbase/dbx"
|
2020-02-29 16:01:09 -09:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type playerRepository struct {
|
|
|
|
|
sqlRepository
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-09 09:52:17 -09:00
|
|
|
func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerRepository {
|
2020-02-29 16:01:09 -09:00
|
|
|
r := &playerRepository{}
|
|
|
|
|
r.ctx = ctx
|
2023-12-09 09:52:17 -09:00
|
|
|
r.db = db
|
2024-09-09 15:45:02 -08:00
|
|
|
r.registerModel(&model.Player{}, map[string]filterFunc{
|
2024-08-05 14:21:21 -08:00
|
|
|
"name": containsFilter("player.name"),
|
2024-09-09 15:45:02 -08:00
|
|
|
})
|
2024-10-26 10:06:34 -08:00
|
|
|
r.setSortMappings(map[string]string{
|
2024-09-20 17:36:59 -08:00
|
|
|
"user_name": "username", //TODO rename all user_name and userName to username
|
2024-10-26 10:06:34 -08:00
|
|
|
})
|
2020-02-29 16:01:09 -09:00
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *playerRepository) Put(p *model.Player) error {
|
|
|
|
|
_, err := r.put(p.ID, p)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-03 09:37:21 -08:00
|
|
|
func (r *playerRepository) selectPlayer(options ...model.QueryOptions) SelectBuilder {
|
|
|
|
|
return r.newSelect(options...).
|
|
|
|
|
Columns("player.*").
|
|
|
|
|
Join("user ON player.user_id = user.id").
|
|
|
|
|
Columns("user.user_name username")
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-29 16:01:09 -09:00
|
|
|
func (r *playerRepository) Get(id string) (*model.Player, error) {
|
2024-08-03 09:37:21 -08:00
|
|
|
sel := r.selectPlayer().Where(Eq{"player.id": id})
|
2020-02-29 16:01:09 -09:00
|
|
|
var res model.Player
|
|
|
|
|
err := r.queryOne(sel, &res)
|
|
|
|
|
return &res, err
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-03 09:37:21 -08:00
|
|
|
func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
|
|
|
|
|
sel := r.selectPlayer().Where(And{
|
2021-06-20 06:36:50 -08:00
|
|
|
Eq{"client": client},
|
|
|
|
|
Eq{"user_agent": userAgent},
|
2024-08-03 09:37:21 -08:00
|
|
|
Eq{"user_id": userId},
|
2021-06-20 06:36:50 -08:00
|
|
|
})
|
2020-02-29 16:01:09 -09:00
|
|
|
var res model.Player
|
|
|
|
|
err := r.queryOne(sel, &res)
|
|
|
|
|
return &res, err
|
|
|
|
|
}
|
|
|
|
|
|
2020-03-17 11:00:28 -08:00
|
|
|
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
|
2024-08-03 09:37:21 -08:00
|
|
|
s := r.selectPlayer(options...)
|
2020-06-08 13:29:09 -08:00
|
|
|
return s.Where(r.addRestriction())
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-18 16:37:35 -09:00
|
|
|
func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) {
|
|
|
|
|
sel := r.newSelect(options...).
|
|
|
|
|
Columns(
|
|
|
|
|
"case when client = 'NavidromeUI' then name else client end as player",
|
|
|
|
|
"count(*) as count",
|
|
|
|
|
).GroupBy("client")
|
|
|
|
|
var res []struct {
|
|
|
|
|
Player string
|
|
|
|
|
Count int64
|
|
|
|
|
}
|
|
|
|
|
err := r.queryAll(sel, &res)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
counts := make(map[string]int64, len(res))
|
|
|
|
|
for _, c := range res {
|
|
|
|
|
counts[c.Player] = c.Count
|
|
|
|
|
}
|
|
|
|
|
return counts, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *playerRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
|
|
|
|
return r.count(r.newRestSelect(), options...)
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-29 16:01:09 -09:00
|
|
|
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
2024-12-18 16:37:35 -09:00
|
|
|
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
2020-02-29 16:01:09 -09:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 05:57:30 -09:00
|
|
|
func (r *playerRepository) Read(id string) (any, error) {
|
2024-08-03 09:37:21 -08:00
|
|
|
sel := r.newRestSelect().Where(Eq{"player.id": id})
|
2020-03-17 11:00:28 -08:00
|
|
|
var res model.Player
|
|
|
|
|
err := r.queryOne(sel, &res)
|
|
|
|
|
return &res, err
|
2020-02-29 16:01:09 -09:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 05:57:30 -09:00
|
|
|
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
2024-09-09 15:45:02 -08:00
|
|
|
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
|
2020-02-29 16:01:09 -09:00
|
|
|
res := model.Players{}
|
|
|
|
|
err := r.queryAll(sel, &res)
|
|
|
|
|
return res, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *playerRepository) EntityName() string {
|
|
|
|
|
return "player"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 05:57:30 -09:00
|
|
|
func (r *playerRepository) NewInstance() any {
|
2020-02-29 16:01:09 -09:00
|
|
|
return &model.Player{}
|
|
|
|
|
}
|
|
|
|
|
|
fix: enforce ownership atomically on player and share updates (#5563)
* fix(player): enforce ownership atomically on player update
The native API PUT /api/player/{id} authorized writes using the userId in
the request body via isPermitted, while the actual write targeted the row
by the URL id. A non-admin user could set userId to their own id in the
body to pass the check, then overwrite and reassign ownership of another
user's player row identified by the URL id (cross-tenant takeover).
Add updateOwned on the base repository: an atomic, ownership-restricted
UPDATE that folds the owner predicate (user_id = caller) into the WHERE
clause for non-admins, so a row owned by another user simply does not
match and no write happens. It also never writes user_id, so ownership is
immutable on update and no caller (admin included) can reassign a player
to a different owner. Unlike put, it never falls through to an INSERT, so
a non-matching id returns ErrNotFound instead of creating a row.
playerRepository.Update now uses updateOwned. Extract filterUpdateValues,
shared by put and updateOwned, so the update-column filtering lives in one
place. The create path (Save) keeps the body-based isPermitted check,
which is correct for new records.
Add regression tests covering the spoofed-userId hijack, regular-user and
admin ownership reassignment, legitimate owner updates, and the
nonexistent-player case.
* fix(share): enforce ownership atomically on share update
shareRepository.Update authorized writes with a separate checkOwnership
SELECT, then wrote the row via put(). The check and the write were two
statements (a TOCTOU window), put() could fall through to an INSERT on a
missing id, and put() would write user_id if present in the update
columns, so ownership was mutable on update.
Switch Update to updateOwned, which folds the owner predicate into the
UPDATE's WHERE clause, never writes user_id, and never inserts. This
makes the write atomic and ownership immutable, and drops the extra
ownership SELECT on the happy path.
To preserve the previous 403/404 distinction, updateOwned now classifies
a non-matching id: it runs a follow-up existence check only on the
failure path (count == 0, where no write happened, so no TOCTOU) and
returns ErrPermissionDenied when the row exists but is owned by another
user, ErrNotFound when the id is missing. The player path inherits this:
its tests now expect ErrPermissionDenied for a non-owner targeting an
existing row, and ErrNotFound only for a genuinely missing id.
Add share regression tests for the nonexistent-id and ownership-
reassignment cases. checkOwnership remains in use by Delete.
* refactor(persistence): extract canonical ownerFilter predicate
The non-admin owner-restriction predicate (user_id = me, exempting admins
and headless contexts) was spelled out independently in updateOwned and in
playerRepository.addRestriction. The two copies had drifted: addRestriction
did not exempt the headless/invalid user, so a headless context restricted
to user_id = "-1" (matching nothing) while updateOwned exempted it.
Extract sqlRepository.ownerFilter as the single definition and route both
call sites through it. addRestriction now exempts the headless user too;
that path is only reachable from the authenticated native API, so there is
no production behavior change, but the latent divergence is removed.
playlistRepository.userFilter is intentionally left alone: it encodes a
different policy (public OR owner_id = me, on the owner_id column).
* fix(share): preserve all-columns update path in Update
shareRepository.Update unconditionally appended "updated_at" to cols.
filterUpdateValues treats an empty cols as "update every column", so when
a caller passes no columns, appending "updated_at" turned an all-columns
update into an updated_at-only one, silently dropping every other field.
The REST controller always populates cols from the request-body field
names, so this path is not reachable through the native API and the
behavior was latent (and pre-existing). Guard the append so the
all-columns path is preserved, and add a regression test that updates
with no columns and asserts the other fields persist.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-04 15:43:13 -08:00
|
|
|
// isPermitted authorizes creating a new record, based on the owner declared in the request body.
|
|
|
|
|
// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a
|
|
|
|
|
// player they own. Updates must not use this (the body owner is attacker-controlled); they go
|
|
|
|
|
// through updateOwned, which authorizes against the persisted user_id in the WHERE clause.
|
2020-03-17 11:00:28 -08:00
|
|
|
func (r *playerRepository) isPermitted(p *model.Player) bool {
|
|
|
|
|
u := loggedUser(r.ctx)
|
2024-08-03 09:37:21 -08:00
|
|
|
return u.IsAdmin || p.UserId == u.ID
|
2020-03-17 11:00:28 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 05:57:30 -09:00
|
|
|
func (r *playerRepository) Save(entity any) (string, error) {
|
2020-02-29 16:01:09 -09:00
|
|
|
t := entity.(*model.Player)
|
2020-03-17 11:00:28 -08:00
|
|
|
if !r.isPermitted(t) {
|
|
|
|
|
return "", rest.ErrPermissionDenied
|
|
|
|
|
}
|
2020-02-29 16:01:09 -09:00
|
|
|
id, err := r.put(t.ID, t)
|
2022-09-30 14:54:25 -08:00
|
|
|
if errors.Is(err, model.ErrNotFound) {
|
2020-02-29 16:01:09 -09:00
|
|
|
return "", rest.ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
return id, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 05:57:30 -09:00
|
|
|
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
|
2020-02-29 16:01:09 -09:00
|
|
|
t := entity.(*model.Player)
|
2021-11-01 09:55:47 -08:00
|
|
|
t.ID = id
|
fix: enforce ownership atomically on player and share updates (#5563)
* fix(player): enforce ownership atomically on player update
The native API PUT /api/player/{id} authorized writes using the userId in
the request body via isPermitted, while the actual write targeted the row
by the URL id. A non-admin user could set userId to their own id in the
body to pass the check, then overwrite and reassign ownership of another
user's player row identified by the URL id (cross-tenant takeover).
Add updateOwned on the base repository: an atomic, ownership-restricted
UPDATE that folds the owner predicate (user_id = caller) into the WHERE
clause for non-admins, so a row owned by another user simply does not
match and no write happens. It also never writes user_id, so ownership is
immutable on update and no caller (admin included) can reassign a player
to a different owner. Unlike put, it never falls through to an INSERT, so
a non-matching id returns ErrNotFound instead of creating a row.
playerRepository.Update now uses updateOwned. Extract filterUpdateValues,
shared by put and updateOwned, so the update-column filtering lives in one
place. The create path (Save) keeps the body-based isPermitted check,
which is correct for new records.
Add regression tests covering the spoofed-userId hijack, regular-user and
admin ownership reassignment, legitimate owner updates, and the
nonexistent-player case.
* fix(share): enforce ownership atomically on share update
shareRepository.Update authorized writes with a separate checkOwnership
SELECT, then wrote the row via put(). The check and the write were two
statements (a TOCTOU window), put() could fall through to an INSERT on a
missing id, and put() would write user_id if present in the update
columns, so ownership was mutable on update.
Switch Update to updateOwned, which folds the owner predicate into the
UPDATE's WHERE clause, never writes user_id, and never inserts. This
makes the write atomic and ownership immutable, and drops the extra
ownership SELECT on the happy path.
To preserve the previous 403/404 distinction, updateOwned now classifies
a non-matching id: it runs a follow-up existence check only on the
failure path (count == 0, where no write happened, so no TOCTOU) and
returns ErrPermissionDenied when the row exists but is owned by another
user, ErrNotFound when the id is missing. The player path inherits this:
its tests now expect ErrPermissionDenied for a non-owner targeting an
existing row, and ErrNotFound only for a genuinely missing id.
Add share regression tests for the nonexistent-id and ownership-
reassignment cases. checkOwnership remains in use by Delete.
* refactor(persistence): extract canonical ownerFilter predicate
The non-admin owner-restriction predicate (user_id = me, exempting admins
and headless contexts) was spelled out independently in updateOwned and in
playerRepository.addRestriction. The two copies had drifted: addRestriction
did not exempt the headless/invalid user, so a headless context restricted
to user_id = "-1" (matching nothing) while updateOwned exempted it.
Extract sqlRepository.ownerFilter as the single definition and route both
call sites through it. addRestriction now exempts the headless user too;
that path is only reachable from the authenticated native API, so there is
no production behavior change, but the latent divergence is removed.
playlistRepository.userFilter is intentionally left alone: it encodes a
different policy (public OR owner_id = me, on the owner_id column).
* fix(share): preserve all-columns update path in Update
shareRepository.Update unconditionally appended "updated_at" to cols.
filterUpdateValues treats an empty cols as "update every column", so when
a caller passes no columns, appending "updated_at" turned an all-columns
update into an updated_at-only one, silently dropping every other field.
The REST controller always populates cols from the request-body field
names, so this path is not reachable through the native API and the
behavior was latent (and pre-existing). Guard the append so the
all-columns path is preserved, and add a regression test that updates
with no columns and asserts the other fields persist.
Signed-off-by: Deluan <deluan@navidrome.org>
---------
Signed-off-by: Deluan <deluan@navidrome.org>
2026-06-04 15:43:13 -08:00
|
|
|
return r.updateOwned(id, t, cols...)
|
2020-02-29 16:01:09 -09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *playerRepository) Delete(id string) error {
|
fix(share): enforce per-user ownership on share reads
Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count,
CountAll) did not apply an owner filter, so non-admin users saw shares
belonging to other users. The write paths already enforced per-user ownership;
this brings reads in line with them.
Add an addRestriction()/ownerFilter() based scope to share reads, keeping
admins and the headless public-share resolution path unrestricted. Route share
and player Delete through a new base-repo deleteOwned() primitive that applies
the ownership predicate in the DELETE's WHERE clause (atomic, no select-then-
delete window) and classifies a zero-row result as permission-denied vs
not-found, mirroring updateOwned. The addRestriction helper and the write-miss
classifier are hoisted onto the base repository so player and share share one
implementation.
Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error
handler so ownership/not-found failures from the rest-backed repositories
return the proper Subsonic codes (50 / 70) instead of a generic error.
Covered by unit tests (persistence, subsonic error mapping) and an end-to-end
cross-user sharing isolation test.
2026-06-05 11:50:59 -08:00
|
|
|
return r.deleteOwned(id)
|
2020-02-29 16:01:09 -09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ model.PlayerRepository = (*playerRepository)(nil)
|
|
|
|
|
var _ rest.Repository = (*playerRepository)(nil)
|
|
|
|
|
var _ rest.Persistable = (*playerRepository)(nil)
|