Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a50097f27 | ||
|
|
5c950beefe | ||
|
|
767a7ccf24 | ||
|
|
20ed9ecd05 | ||
|
|
43beb4693a | ||
|
|
5f9cfd213b | ||
|
|
40c4d6bcc4 | ||
|
|
076351f3d5 | ||
|
|
3034db6dc8 | ||
|
|
4430eda7a2 | ||
|
|
373939126e | ||
|
|
49e14d3459 | ||
|
|
c26835cd6a | ||
|
|
83a9a1c102 | ||
|
|
e40e2437ef | ||
|
|
5d513c2737 | ||
|
|
6701da1829 | ||
|
|
fcc782fe19 | ||
|
|
18bc4b3f16 | ||
|
|
ac280ab7f3 | ||
|
|
f499d0901b | ||
|
|
2bf4bd127e | ||
|
|
f2c9468122 | ||
|
|
4b70679815 | ||
|
|
0b7156ac0a | ||
|
|
9c0172a706 | ||
|
|
0733e3fc51 | ||
|
|
ecc08b7569 | ||
|
|
0638a514b7 | ||
|
|
e067041b05 | ||
|
|
d934bb8924 | ||
|
|
4d98005a23 |
+1
-1
@@ -1954,7 +1954,7 @@ func main() {
|
||||
// Construct auth service for jellycompat login.
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
|
||||
compatDeps.APIKeyUserLoader = userRepo
|
||||
compatDeps.UserLoader = userRepo
|
||||
compatDeps.ScanQueue = deps.LibraryScanQueue
|
||||
sessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
jwtService := auth.NewJWTService(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
# Tiered Security Model: Group-Based Permissions — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: Approved design, pending implementation plan
|
||||
|
||||
Commands and paths in this document assume the repository root is the cwd.
|
||||
|
||||
## Overview
|
||||
|
||||
Replace Silo's per-user authorization fields with a group-based model:
|
||||
|
||||
- Admins create **groups**; each group carries a full policy (feature permissions, library access, playback/transcode limits, download rights).
|
||||
- Users are assigned to one or more groups; effective policy is the most-permissive union of their groups.
|
||||
- Groups are the **only** source of policy. Per-user policy columns are removed.
|
||||
- Two protected built-in groups ship by default: `administrators` and `users`. New accounts join the group(s) named by a server setting (initially `users`).
|
||||
- The `users.role` column and JWT role claim are removed; "admin" becomes a permission granted by group membership.
|
||||
|
||||
Deployments may have **thousands of users**. All fan-out paths (revision bumps, member listings, migration artifacts) are designed set-based and paginated accordingly.
|
||||
|
||||
## Current state (what this replaces)
|
||||
|
||||
- `users.role` text (`"admin"`/`"user"`), checked by `RequireAdmin` middleware via JWT `Claims.Role` (`internal/api/middleware/auth.go`).
|
||||
- `users.permissions text[]` with two assignable permissions (`marker_edit`, `metadata_curation`) defined in `internal/auth/permissions.go`; admins implicitly hold all.
|
||||
- Per-user resource policy columns on `users`: `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
|
||||
- `users.access_policy_revision` invalidates profile tokens via `internal/access/resolver.go`.
|
||||
- Household profiles (`user_profiles`) carry a separate restriction layer (child flag, content rating, allowed libraries). **Profiles are out of scope** — that layer is unchanged and continues to apply on top of the user's effective policy.
|
||||
|
||||
## Data model
|
||||
|
||||
### `groups`
|
||||
|
||||
```sql
|
||||
CREATE TABLE groups (
|
||||
id serial PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE, -- stable identifier: 'administrators', 'users', ...
|
||||
name text NOT NULL, -- display name, renamable
|
||||
description text NOT NULL DEFAULT '',
|
||||
built_in boolean NOT NULL DEFAULT false,
|
||||
permissions text[] NOT NULL DEFAULT '{}',
|
||||
library_ids integer[], -- NULL = all libraries
|
||||
max_streams integer NOT NULL DEFAULT 6,
|
||||
max_transcodes integer NOT NULL DEFAULT 2,
|
||||
max_profiles integer NOT NULL DEFAULT 5,
|
||||
max_playback_quality text NOT NULL DEFAULT '', -- '' = unrestricted
|
||||
download_allowed boolean NOT NULL DEFAULT true,
|
||||
download_transcode_allowed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### `user_groups`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_groups (
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
|
||||
```
|
||||
|
||||
### Admin as a permission
|
||||
|
||||
`'admin'` is a permission string in `groups.permissions`, not a column. The resolver treats it as implying every permission and unrestricted policy, mirroring the current `HasEffectivePermission` short-circuit for `role == "admin"`. One mechanism covers everything.
|
||||
|
||||
Permission catalog at launch: `admin`, `marker_edit`, `metadata_curation`. The catalog stays a Go-level allowlist in `internal/auth/permissions.go`; adding a permission is a code change, as today.
|
||||
|
||||
### Built-in groups
|
||||
|
||||
| Slug | Permissions | Policy | Protections |
|
||||
|---|---|---|---|
|
||||
| `administrators` | `['admin']` | irrelevant (admin implies unrestricted) | not deletable; `admin` permission not removable; last enabled member not removable |
|
||||
| `users` | `['marker_edit']` | all libraries, 6 streams, 2 transcodes, 5 profiles, downloads on, transcode downloads off | not deletable |
|
||||
|
||||
Protections are enforced at the service layer (consistent with existing last-admin guards), not by triggers.
|
||||
|
||||
### `users` table changes
|
||||
|
||||
Dropped: `role`, `permissions`, `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
|
||||
|
||||
Kept: `enabled` (account kill-switch stays user-level), `access_policy_revision` (still the invalidation counter).
|
||||
|
||||
### Default group setting
|
||||
|
||||
A `server_settings` entry `users.default_group_slugs` (initially `["users"]`), consumed by all account-provisioning paths (signup, invite, OAuth) in `internal/auth/account_provisioner.go`. Invite codes may later override it; out of scope now.
|
||||
|
||||
## Resolution semantics
|
||||
|
||||
A user's **effective policy** is the most-permissive union of their groups:
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `permissions` | set union; `admin` present → every permission granted |
|
||||
| `library_ids` | union; any group with `NULL` (all) or `admin` → unrestricted |
|
||||
| `max_streams`, `max_transcodes`, `max_profiles` | 0 means unlimited at enforcement, so any group with 0 wins; otherwise max across groups |
|
||||
| `max_playback_quality` | highest wins; `''` (unrestricted) beats everything (ordering as in `internal/access/quality.go`) |
|
||||
| `download_allowed`, `download_transcode_allowed` | OR |
|
||||
|
||||
**Zero groups → empty policy**: no permissions, no libraries (lockout is enforced by the empty library list, so stream/transcode limits are irrelevant; `max_profiles` is floored at 1 since 0 would mean unlimited profile creation). The user can authenticate but sees an empty server. This is deliberate ("groups only grant") and gives admins a soft-disable distinct from `enabled = false`.
|
||||
|
||||
**Where computed:** the user repository (`internal/auth/repository.go`) aggregates group policy in the same load path as the user row (a second indexed query over `user_groups`/`groups`, batched for list views; cost is O(memberships per user), independent of total user count). `models.User` keeps its existing policy fields (`LibraryIDs`, `MaxStreams`, `Permissions`, …) but they become **effective** values populated from the aggregate. The ~25 consumer packages (playback, catalog access filters, jellycompat, sections, audiobooks, requests, …) keep working unchanged. `Role` is replaced by `IsAdmin bool` derived from effective permissions. Assigned memberships are exposed separately (`GroupIDs`) for the admin API.
|
||||
|
||||
**Invalidation:** unchanged mechanism, new triggers:
|
||||
|
||||
- Group policy edit → bump `access_policy_revision` for all members in **one set-based `UPDATE ... FROM user_groups`** (never a per-user loop; the `users` group may have thousands of members).
|
||||
- Membership add/remove → bump the affected user's revision.
|
||||
|
||||
The profile-token revision check in `internal/access/resolver.go` then invalidates stale scopes automatically. The post-bump re-resolution stampede is bounded: each active session re-resolves once with a single indexed query on its next request. No new caching layer until profiling shows a need.
|
||||
|
||||
## Enforcement changes
|
||||
|
||||
- **JWT claims:** `Claims.Role` removed. Claims carry identity only (`user_id`, session, profile, revision) — never policy. Fixes the existing staleness gap where a role change waited for token re-issuance; after this, revoking admin takes effect on the next request.
|
||||
- **`RequireAdmin`** (`internal/api/middleware/auth.go`): loads the user (effective policy included) and checks `IsAdmin`; stashes the loaded user in request context so handlers don't re-fetch. One indexed query on low-traffic admin routes.
|
||||
- **Permission middleware** (`internal/api/middleware/permissions.go`): logic unchanged — it already operates on `models.User`. Only `user.Role == "admin"` comparisons become `user.IsAdmin`.
|
||||
- **Jellyfin compat** (`internal/jellycompat/handlers_auth.go`): `UserPolicy` maps from effective policy — `IsAdministrator` ← `IsAdmin`, `EnableContentDownloading` ← `DownloadAllowed`, `EnableAllFolders`/`EnabledFolders` ← `LibraryIDs`. Replaces the currently hardcoded policy values.
|
||||
- **Requests service** (`internal/requests`): role-string comparisons → `IsAdmin`; otherwise unchanged.
|
||||
|
||||
## API surface
|
||||
|
||||
All new endpoints behind `RequireAdmin`, under the existing admin API namespace:
|
||||
|
||||
- `GET /api/admin/groups` — list groups with member counts (single aggregated count query)
|
||||
- `POST /api/admin/groups` — create; slug auto-derived from name
|
||||
- `GET /api/admin/groups/{id}` — group detail: policy + member **count** (members are paginated separately)
|
||||
- `GET /api/admin/groups/{id}/members?offset=&limit=` — paginated member list
|
||||
- `PATCH /api/admin/groups/{id}` — update name/description/policy; rejects removing `admin` from `administrators`
|
||||
- `DELETE /api/admin/groups/{id}` — rejects built-ins; memberships cascade
|
||||
- `PUT /api/admin/groups/{id}/members/{userID}` — add member
|
||||
- `DELETE /api/admin/groups/{id}/members/{userID}` — remove member; rejects removing the last enabled member of `administrators`
|
||||
|
||||
Changed endpoints:
|
||||
|
||||
- `POST`/`PATCH` admin user endpoints: per-user policy fields removed from request bodies, replaced by `group_ids`. Responses gain `groups: [{id, slug, name}]`; the existing top-level policy fields (`library_ids`, `max_streams`, …) become read-only effective values derived from group membership.
|
||||
- User-facing account/session responses keep a **derived** `role` field (`"admin"`/`"user"` from `IsAdmin`) so `silo-android` and `silo-apple` need no immediate changes; group info is added alongside.
|
||||
|
||||
Default-group setting rides the existing server-settings endpoints.
|
||||
|
||||
## Admin UI (`web/src/pages/admin`)
|
||||
|
||||
- New **Groups** page: list (name, member count, key-policy summary) and an editor with three sections — permissions (checkboxes), library access (all / picker, reusing the user editor's component), limits (streams, transcodes, quality, downloads, profiles). Member management with a paginated member list and user search to add.
|
||||
- **Users** page: per-user policy controls replaced by a group multi-select plus a read-only "effective policy" summary showing the resolved union — the admin's answer to "why can this user do X".
|
||||
- Built-in groups render with a badge; delete and protected fields disabled with explanatory tooltips.
|
||||
|
||||
## Migration
|
||||
|
||||
One transactional Goose migration created with `make migrate-create NAME=group_based_permissions`:
|
||||
|
||||
1. Create `groups` and `user_groups`; seed `administrators` and `users` (policy from current column defaults).
|
||||
2. Map members: `role = 'admin'` → `administrators` only. Non-admin users whose policy columns match the defaults → `users`.
|
||||
3. Preserve outliers **bucketed by distinct policy tuple**: non-admin users whose policy columns deviate from the defaults are grouped by their unique `(permissions, library_ids, max_streams, max_transcodes, max_profiles, max_playback_quality, download_allowed, download_transcode_allowed)` combination; one group per distinct tuple (`migrated-policy-1`, `-2`, …) is created carrying those values, and matching users are added **to that group only — not to `users`**. (Adding outliers to `users` would erase their restrictions via the most-permissive union: `users` grants all libraries, so a user previously limited to specific libraries would become unrestricted.) Group count is bounded by distinct policies in use, not user count. Post-migration effective policy is exactly each user's prior policy; nothing is silently lost or gained. (Admins were already unrestricted, so admin outliers need no preservation.)
|
||||
4. Drop the old `users` columns; bump all `access_policy_revision` values set-based.
|
||||
|
||||
Down-migration recreates the columns and writes back each user's flattened effective policy (best-effort; acceptable at WIP stage).
|
||||
|
||||
## Testing
|
||||
|
||||
- Union resolver unit tests: multi-group merges, `admin` short-circuit, zero-group empty policy, NULL-libraries propagation, quality ordering (extending `internal/access/quality_test.go` coverage).
|
||||
- Service-layer guard tests: delete built-in, strip `admin` from `administrators`, remove last enabled administrator.
|
||||
- Migration test against a seeded DB asserting before/after effective-policy equivalence, including outlier users sharing a policy tuple.
|
||||
- Handler tests for group endpoints incl. member pagination; updated user-endpoint tests for changed payloads.
|
||||
- Jellycompat test asserting `UserPolicy` reflects group-derived policy.
|
||||
|
||||
## Risks & follow-ups
|
||||
|
||||
- **Client coordination:** `silo-android` / `silo-apple` keep working via the derived `role` field; follow-up tickets to surface group membership in account UI.
|
||||
- **Revision-bump stampede:** bounded and acceptable (one re-resolve per active session); revisit with a cache only if profiling shows pressure.
|
||||
- **Invite-code group overrides** and an expanded permission catalog (requests, watch-together, etc.) are natural follow-ups, out of scope here.
|
||||
- **Plugin/API-key auth paths** (`internal/auth/api_key_repository.go`, jellycompat API keys) must be audited during implementation for any `role` reads not covered above.
|
||||
@@ -5,4 +5,9 @@ import "errors"
|
||||
var (
|
||||
ErrProfileNotFound = errors.New("profile not found")
|
||||
ErrProfileUnverified = errors.New("profile unverified")
|
||||
// ErrUserDisabled is returned when the viewer's account is disabled.
|
||||
// Scope resolution re-loads the user on every request, so disabling an
|
||||
// account denies content access on the next request for every surface
|
||||
// that resolves viewer scope (native API and Jellyfin compat alike).
|
||||
ErrUserDisabled = errors.New("user account disabled")
|
||||
)
|
||||
|
||||
@@ -79,3 +79,17 @@ func MinQuality(a, b string) string {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
// MaxQuality returns the more permissive quality ceiling. An empty string
|
||||
// means unrestricted and beats any concrete ceiling.
|
||||
func MaxQuality(a, b string) string {
|
||||
a = NormalizePlaybackQuality(a)
|
||||
b = NormalizePlaybackQuality(b)
|
||||
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
|
||||
return ""
|
||||
}
|
||||
if CompareQuality(a, b) >= 0 {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -54,3 +54,22 @@ func TestParsePlaybackQualityPreset(t *testing.T) {
|
||||
t.Fatal("expected invalid preset to return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxQuality(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b, want string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"", "1080p", ""}, // unrestricted beats any ceiling
|
||||
{"2160p", "", ""},
|
||||
{"1080p", "2160p", "2160p"},
|
||||
{"2160p", "1080p", "2160p"},
|
||||
{"1080p", "1080p", "1080p"},
|
||||
{"720p", "480p", "1080p"}, // presets normalize: 720p/480p both -> 1080p (standard)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MaxQuality(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("MaxQuality(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
|
||||
}
|
||||
if !user.Enabled {
|
||||
return Scope{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
scope := Scope{
|
||||
UserID: user.ID,
|
||||
|
||||
@@ -256,7 +256,7 @@ func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int)
|
||||
|
||||
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -285,7 +285,7 @@ func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
|
||||
|
||||
func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
|
||||
nil,
|
||||
)
|
||||
@@ -304,7 +304,7 @@ func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
|
||||
|
||||
func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -324,7 +324,7 @@ func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
|
||||
|
||||
func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -344,7 +344,7 @@ func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
|
||||
|
||||
func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{ID: "prof-1"},
|
||||
settings: map[string]string{"disabled_library_ids": "[3,5]"},
|
||||
@@ -367,7 +367,7 @@ func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
|
||||
|
||||
func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{ID: "prof-1"},
|
||||
settings: map[string]string{"disabled_library_ids": "[2,4]"},
|
||||
@@ -388,9 +388,22 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledUserIsDenied(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: false, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if !errors.Is(err, ErrUserDisabled) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrUserDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
settings: map[string]string{"disabled_library_ids": "[7]"},
|
||||
}},
|
||||
|
||||
+105
-206
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -103,11 +102,17 @@ func NewAdminHandler(
|
||||
pool *pgxpool.Pool,
|
||||
storeProv userstore.UserStoreProvider,
|
||||
) *AdminHandler {
|
||||
var groupResolver auth.GroupResolver
|
||||
if pool != nil {
|
||||
groupResolver = auth.NewGroupRepository(pool)
|
||||
}
|
||||
return &AdminHandler{
|
||||
userRepo: userRepo,
|
||||
pool: pool,
|
||||
storeProv: storeProv,
|
||||
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
|
||||
userRepo: userRepo,
|
||||
pool: pool,
|
||||
storeProv: storeProv,
|
||||
// No settings dependency at construction time: nil settings makes the
|
||||
// provisioner fall back to the built-in users group for defaults.
|
||||
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv, nil, groupResolver),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,115 +120,53 @@ func NewAdminHandler(
|
||||
|
||||
// createUserRequest represents the JSON body for POST /admin/users.
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Permissions createStringSliceField `json:"permissions"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
type createStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *createStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
type updateLibraryIDsField struct {
|
||||
Set bool
|
||||
Value []int
|
||||
}
|
||||
|
||||
func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = nil
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
func (f updateLibraryIDsField) Ptr() *[]int {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := append([]int(nil), f.Value...)
|
||||
return &value
|
||||
}
|
||||
|
||||
type updateStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
func (f updateStringSliceField) Ptr() *[]string {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := append([]string(nil), f.Value...)
|
||||
return &value
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
// GroupIDs are the group memberships to assign. Omitted (nil) means the
|
||||
// configured default groups are used.
|
||||
GroupIDs []int `json:"group_ids"`
|
||||
}
|
||||
|
||||
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
|
||||
type updateUserRequest struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Permissions updateStringSliceField `json:"permissions,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// GroupIDs replaces the user's group memberships. Omitted = unchanged.
|
||||
GroupIDs *[]int `json:"group_ids,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserResponse represents a user in admin JSON responses.
|
||||
// adminGroupRef is a lightweight group reference in admin user responses.
|
||||
type adminGroupRef struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// adminUserResponse represents a user in admin JSON responses. The policy
|
||||
// fields are the effective values derived from the user's groups.
|
||||
type adminUserResponse struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Groups []adminGroupRef `json:"groups"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
}
|
||||
|
||||
type adminPlaybackHistoryRow struct {
|
||||
@@ -269,15 +212,21 @@ func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// toAdminUserResponse converts a User model to an admin API response.
|
||||
// toAdminUserResponse converts a User model (with its group memberships
|
||||
// loaded into u.Groups at load time) to an admin API response.
|
||||
func toAdminUserResponse(u *models.User) adminUserResponse {
|
||||
refs := make([]adminGroupRef, 0, len(u.Groups))
|
||||
for _, g := range u.Groups {
|
||||
refs = append(refs, adminGroupRef{ID: g.ID, Slug: g.Slug, Name: g.Name})
|
||||
}
|
||||
return adminUserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
Role: auth.RoleForUser(u),
|
||||
Permissions: append([]string{}, u.Permissions...),
|
||||
Enabled: u.Enabled,
|
||||
Groups: refs,
|
||||
LibraryIDs: append([]int(nil), u.LibraryIDs...),
|
||||
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
|
||||
MaxStreams: u.MaxStreams,
|
||||
@@ -340,10 +289,13 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]adminUserResponse, 0, len(users))
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
|
||||
resp := make([]adminUserResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
resp = append(resp, toAdminUserResponse(u))
|
||||
}
|
||||
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
|
||||
@@ -393,44 +345,17 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
req.Username = auth.NormalizeUsername(req.Username)
|
||||
req.Email = auth.NormalizeEmail(req.Email)
|
||||
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required")
|
||||
return
|
||||
}
|
||||
|
||||
maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
|
||||
return
|
||||
}
|
||||
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
|
||||
return
|
||||
}
|
||||
permissions := auth.DefaultUserPermissions()
|
||||
if req.Permissions.Set {
|
||||
permissions = req.Permissions.Value
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
|
||||
User: models.CreateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
LibraryIDs: req.LibraryIDs,
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
GroupIDs: req.GroupIDs,
|
||||
},
|
||||
DefaultProfile: auth.DefaultProfileOptions{
|
||||
Enabled: req.CreateDefaultProfile,
|
||||
@@ -438,6 +363,10 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUnknownGroup) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
|
||||
return
|
||||
}
|
||||
@@ -461,43 +390,12 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var maxPlaybackQuality *string
|
||||
if req.MaxPlaybackQuality != nil {
|
||||
normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
|
||||
return
|
||||
}
|
||||
maxPlaybackQuality = &normalized
|
||||
}
|
||||
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
|
||||
return
|
||||
}
|
||||
var permissions *[]string
|
||||
if req.Permissions.Set {
|
||||
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
permissions = &normalized
|
||||
}
|
||||
|
||||
updateInput := models.UpdateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
Enabled: req.Enabled,
|
||||
LibraryIDs: req.LibraryIDs.Ptr(),
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Enabled: req.Enabled,
|
||||
GroupIDs: req.GroupIDs,
|
||||
}
|
||||
|
||||
var currentUser *models.User
|
||||
@@ -519,6 +417,14 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrLastAdministrator) {
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrUnknownGroup) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -549,6 +455,10 @@ func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
err = h.userRepo.Delete(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrLastAdministrator) {
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user")
|
||||
return
|
||||
}
|
||||
@@ -792,10 +702,8 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
|
||||
return input.Password != nil ||
|
||||
input.Role != nil ||
|
||||
input.Enabled != nil ||
|
||||
input.Permissions != nil ||
|
||||
input.MaxPlaybackQuality != nil
|
||||
input.GroupIDs != nil
|
||||
}
|
||||
|
||||
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
|
||||
@@ -805,22 +713,28 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs
|
||||
if current == nil {
|
||||
return updateMayRequireSessionRevocation(input)
|
||||
}
|
||||
if input.Role != nil && *input.Role != current.Role {
|
||||
return true
|
||||
}
|
||||
if input.Enabled != nil && *input.Enabled != current.Enabled {
|
||||
return true
|
||||
}
|
||||
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
|
||||
return true
|
||||
}
|
||||
if input.MaxPlaybackQuality != nil &&
|
||||
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
|
||||
// Group membership changes alter the effective policy (permissions,
|
||||
// quality ceilings, ...), so a real change revokes sessions.
|
||||
if input.GroupIDs != nil && !equalIntSets(*input.GroupIDs, current.GroupIDs) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// equalIntSets reports whether two int slices contain the same set of values,
|
||||
// ignoring order and duplicates.
|
||||
func equalIntSets(a, b []int) bool {
|
||||
normalize := func(values []int) []int {
|
||||
out := append([]int(nil), values...)
|
||||
sort.Ints(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
return slices.Equal(normalize(a), normalize(b))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
|
||||
if h.pool == nil {
|
||||
return nil
|
||||
@@ -1021,7 +935,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
|
||||
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponseForViewer(r, job, nil, isAdminRequest(r, h.userRepo)))
|
||||
}
|
||||
|
||||
// UpdateItemMetadataRequest contains the fields that can be updated via
|
||||
@@ -2080,21 +1994,6 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if key == "defaults.max_playback_quality" {
|
||||
normalized, ok := access.ParsePlaybackQualityPreset(req.Value)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
|
||||
return
|
||||
}
|
||||
req.Value = normalized
|
||||
}
|
||||
if key == "defaults.max_profiles" {
|
||||
n, err := strconv.Atoi(req.Value)
|
||||
if err != nil || n < 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "defaults.max_profiles must be an integer >= 1")
|
||||
return
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case markers.SettingMode, markers.SettingLazyPlayback:
|
||||
if normalized, err := markers.NormalizeSetting(key, req.Value); err != nil {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
// isAdminRequest reports whether the request's authenticated user currently
|
||||
// holds the admin permission. Admin authority is always derived from the
|
||||
// freshly loaded user (group-derived policy), never from token contents.
|
||||
// It first consults the user stashed by the RequireAdmin middleware and
|
||||
// falls back to loading via the given loader.
|
||||
func isAdminRequest(r *http.Request, loader auth.UserLoader) bool {
|
||||
ctx := r.Context()
|
||||
if user := apimw.GetUser(ctx); user != nil {
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
claims := apimw.GetClaims(ctx)
|
||||
if claims == nil || loader == nil {
|
||||
return false
|
||||
}
|
||||
user, err := loader.GetByID(ctx, claims.UserID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
@@ -27,7 +27,6 @@ func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// GroupStore defines the group repository operations the AdminGroupsHandler
|
||||
// needs, so tests can substitute a fake.
|
||||
type GroupStore interface {
|
||||
List(ctx context.Context) ([]auth.GroupWithMemberCount, error)
|
||||
GetByID(ctx context.Context, id int) (*models.Group, error)
|
||||
MemberCount(ctx context.Context, groupID int) (int, error)
|
||||
Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error)
|
||||
Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
ListMembers(ctx context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error)
|
||||
AddMember(ctx context.Context, groupID, userID int) error
|
||||
RemoveMember(ctx context.Context, groupID, userID int) error
|
||||
}
|
||||
|
||||
// AdminGroupsHandler handles admin-only group management endpoints.
|
||||
type AdminGroupsHandler struct {
|
||||
store GroupStore
|
||||
}
|
||||
|
||||
// NewAdminGroupsHandler creates an AdminGroupsHandler backed by the given
|
||||
// group store.
|
||||
func NewAdminGroupsHandler(store GroupStore) *AdminGroupsHandler {
|
||||
return &AdminGroupsHandler{store: store}
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
// createAdminGroupRequest represents the JSON body for POST /admin/groups.
|
||||
type createAdminGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LibraryIDs []int `json:"library_ids"` // null/absent = all libraries
|
||||
MaxStreams *int `json:"max_streams"`
|
||||
MaxTranscodes *int `json:"max_transcodes"`
|
||||
MaxProfiles *int `json:"max_profiles"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality"`
|
||||
DownloadAllowed *bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed"`
|
||||
}
|
||||
|
||||
// updateAdminGroupRequest represents the JSON body for PATCH /admin/groups/{id}.
|
||||
// Absent fields are left unchanged.
|
||||
type updateAdminGroupRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Permissions optionalStringSliceField `json:"permissions,omitempty"`
|
||||
LibraryIDs optionalIntSliceField `json:"library_ids,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// adminGroupResponse represents a group in admin JSON responses.
|
||||
type adminGroupResponse struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LibraryIDs []int `json:"library_ids"` // null = all libraries
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
MemberCount int `json:"member_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type adminGroupListResponse struct {
|
||||
Groups []adminGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
type adminGroupMemberRow struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type adminGroupMembersResponse struct {
|
||||
Members []adminGroupMemberRow `json:"members"`
|
||||
Total int `json:"total"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func toAdminGroupResponse(g *models.Group, memberCount int) adminGroupResponse {
|
||||
return adminGroupResponse{
|
||||
ID: g.ID,
|
||||
Slug: g.Slug,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
BuiltIn: g.BuiltIn,
|
||||
Permissions: append([]string{}, g.Permissions...),
|
||||
LibraryIDs: g.LibraryIDs,
|
||||
MaxStreams: g.MaxStreams,
|
||||
MaxTranscodes: g.MaxTranscodes,
|
||||
MaxProfiles: g.MaxProfiles,
|
||||
MaxPlaybackQuality: g.MaxPlaybackQuality,
|
||||
DownloadAllowed: g.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: g.DownloadTranscodeAllowed,
|
||||
MemberCount: memberCount,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// writeGroupError maps group repository sentinel errors to API responses.
|
||||
func writeGroupError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrGroupNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Group not found")
|
||||
case errors.Is(err, auth.ErrUnknownUser):
|
||||
writeError(w, http.StatusNotFound, "unknown_user", "User not found")
|
||||
case errors.Is(err, auth.ErrBuiltInGroup):
|
||||
writeError(w, http.StatusConflict, "built_in_group", "Built-in groups cannot be deleted")
|
||||
case errors.Is(err, auth.ErrAdminPermRequired):
|
||||
writeError(w, http.StatusConflict, "admin_permission_required", "The administrators group must keep the admin permission")
|
||||
case errors.Is(err, auth.ErrLastAdministrator):
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
case errors.Is(err, auth.ErrDuplicate):
|
||||
writeError(w, http.StatusConflict, "duplicate", "A group with that name already exists")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Group operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func parseGroupIDParam(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, name))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+name)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// --- Handler methods ---
|
||||
|
||||
// HandleList handles GET /admin/groups.
|
||||
func (h *AdminGroupsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := h.store.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list groups")
|
||||
return
|
||||
}
|
||||
resp := adminGroupListResponse{Groups: make([]adminGroupResponse, 0, len(groups))}
|
||||
for i := range groups {
|
||||
resp.Groups = append(resp.Groups, toAdminGroupResponse(&groups[i].Group, groups[i].MemberCount))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleCreate handles POST /admin/groups.
|
||||
func (h *AdminGroupsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req createAdminGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Group name is required")
|
||||
return
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.store.Create(r.Context(), models.CreateGroupInput{
|
||||
Name: name,
|
||||
Description: req.Description,
|
||||
Permissions: permissions,
|
||||
LibraryIDs: req.LibraryIDs,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
MaxPlaybackQuality: req.MaxPlaybackQuality,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
})
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAdminGroupResponse(group, 0))
|
||||
}
|
||||
|
||||
// HandleGet handles GET /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
group, err := h.store.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
memberCount, err := h.store.MemberCount(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
|
||||
}
|
||||
|
||||
// HandleUpdate handles PATCH /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateAdminGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Group name cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
input := models.UpdateGroupInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
LibraryIDs: req.LibraryIDs.Ptr(),
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
MaxPlaybackQuality: req.MaxPlaybackQuality,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
}
|
||||
if req.Permissions.Set {
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
input.Permissions = &permissions
|
||||
}
|
||||
|
||||
group, err := h.store.Update(r.Context(), id, input)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
memberCount, err := h.store.MemberCount(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
|
||||
}
|
||||
|
||||
// HandleDelete handles DELETE /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(r.Context(), id); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListMembers handles GET /admin/groups/{id}/members.
|
||||
func (h *AdminGroupsHandler) HandleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.GetByID(r.Context(), id); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
offset := 0
|
||||
if v := q.Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
limit := 50
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
members, total, err := h.store.ListMembers(r.Context(), id, offset, limit)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
resp := adminGroupMembersResponse{
|
||||
Members: make([]adminGroupMemberRow, 0, len(members)),
|
||||
Total: total,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
}
|
||||
for _, m := range members {
|
||||
resp.Members = append(resp.Members, adminGroupMemberRow{
|
||||
UserID: m.UserID,
|
||||
Username: m.Username,
|
||||
Email: m.Email,
|
||||
Enabled: m.Enabled,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleAddMember handles PUT /admin/groups/{id}/members/{userID}.
|
||||
func (h *AdminGroupsHandler) HandleAddMember(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parseGroupIDParam(w, r, "userID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.AddMember(r.Context(), groupID, userID); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleRemoveMember handles DELETE /admin/groups/{id}/members/{userID}.
|
||||
func (h *AdminGroupsHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parseGroupIDParam(w, r, "userID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.RemoveMember(r.Context(), groupID, userID); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeGroupStore is a configurable in-memory GroupStore that records the
|
||||
// inputs handlers pass to it.
|
||||
type fakeGroupStore struct {
|
||||
listGroups []auth.GroupWithMemberCount
|
||||
listErr error
|
||||
getGroup *models.Group
|
||||
getErr error
|
||||
memberCount int
|
||||
countErr error
|
||||
|
||||
createInput *models.CreateGroupInput
|
||||
createGroup *models.Group
|
||||
createErr error
|
||||
|
||||
updateID int
|
||||
updateInput *models.UpdateGroupInput
|
||||
updateGroup *models.Group
|
||||
updateErr error
|
||||
|
||||
deleteID int
|
||||
deleteErr error
|
||||
|
||||
membersGroupID int
|
||||
membersOffset int
|
||||
membersLimit int
|
||||
members []auth.GroupMember
|
||||
membersTotal int
|
||||
membersErr error
|
||||
|
||||
addGroupID int
|
||||
addUserID int
|
||||
addErr error
|
||||
removeGroupID int
|
||||
removeUserID int
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) List(context.Context) ([]auth.GroupWithMemberCount, error) {
|
||||
return f.listGroups, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) GetByID(_ context.Context, id int) (*models.Group, error) {
|
||||
return f.getGroup, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) MemberCount(_ context.Context, groupID int) (int, error) {
|
||||
return f.memberCount, f.countErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Create(_ context.Context, input models.CreateGroupInput) (*models.Group, error) {
|
||||
f.createInput = &input
|
||||
return f.createGroup, f.createErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Update(_ context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
|
||||
f.updateID = id
|
||||
f.updateInput = &input
|
||||
return f.updateGroup, f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Delete(_ context.Context, id int) error {
|
||||
f.deleteID = id
|
||||
return f.deleteErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) ListMembers(_ context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error) {
|
||||
f.membersGroupID = groupID
|
||||
f.membersOffset = offset
|
||||
f.membersLimit = limit
|
||||
return f.members, f.membersTotal, f.membersErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) AddMember(_ context.Context, groupID, userID int) error {
|
||||
f.addGroupID = groupID
|
||||
f.addUserID = userID
|
||||
return f.addErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) RemoveMember(_ context.Context, groupID, userID int) error {
|
||||
f.removeGroupID = groupID
|
||||
f.removeUserID = userID
|
||||
return f.removeErr
|
||||
}
|
||||
|
||||
// newGroupsTestRouter mounts the handler routes the same way the API router
|
||||
// does so tests exercise real chi URL params.
|
||||
func newGroupsTestRouter(store *fakeGroupStore) chi.Router {
|
||||
h := NewAdminGroupsHandler(store)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/groups", h.HandleList)
|
||||
r.Post("/groups", h.HandleCreate)
|
||||
r.Get("/groups/{id}", h.HandleGet)
|
||||
r.Patch("/groups/{id}", h.HandleUpdate)
|
||||
r.Delete("/groups/{id}", h.HandleDelete)
|
||||
r.Get("/groups/{id}/members", h.HandleListMembers)
|
||||
r.Put("/groups/{id}/members/{userID}", h.HandleAddMember)
|
||||
r.Delete("/groups/{id}/members/{userID}", h.HandleRemoveMember)
|
||||
return r
|
||||
}
|
||||
|
||||
func doGroupsRequest(t *testing.T, router chi.Router, method, target string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshaling request body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(payload)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, reader)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeGroupsJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decoding response %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertGroupsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("status = %d, want %d (body %q)", rec.Code, status, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["error"] != code {
|
||||
t.Fatalf("error code = %v, want %q (body %q)", body["error"], code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testGroup() *models.Group {
|
||||
return &models.Group{
|
||||
ID: 7,
|
||||
Slug: "film-club",
|
||||
Name: "Film Club",
|
||||
Description: "Movie nights",
|
||||
Permissions: []string{"marker_edit"},
|
||||
LibraryIDs: nil,
|
||||
MaxStreams: 2,
|
||||
MaxTranscodes: 1,
|
||||
MaxProfiles: 4,
|
||||
MaxPlaybackQuality: "1080p",
|
||||
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsList(t *testing.T) {
|
||||
store := &fakeGroupStore{
|
||||
listGroups: []auth.GroupWithMemberCount{
|
||||
{Group: models.Group{ID: 1, Slug: "administrators", Name: "Administrators", BuiltIn: true, Permissions: []string{"admin"}}, MemberCount: 1},
|
||||
{Group: models.Group{ID: 2, Slug: "users", Name: "Users", BuiltIn: true, LibraryIDs: []int{3}}, MemberCount: 12},
|
||||
},
|
||||
}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
groups, ok := body["groups"].([]any)
|
||||
if !ok || len(groups) != 2 {
|
||||
t.Fatalf("groups = %v, want 2 entries", body["groups"])
|
||||
}
|
||||
first := groups[0].(map[string]any)
|
||||
if first["member_count"] != float64(1) {
|
||||
t.Fatalf("member_count = %v, want 1", first["member_count"])
|
||||
}
|
||||
if first["library_ids"] != nil {
|
||||
t.Fatalf("library_ids = %v, want null for all-libraries", first["library_ids"])
|
||||
}
|
||||
second := groups[1].(map[string]any)
|
||||
if second["member_count"] != float64(12) {
|
||||
t.Fatalf("member_count = %v, want 12", second["member_count"])
|
||||
}
|
||||
if ids, ok := second["library_ids"].([]any); !ok || len(ids) != 1 || ids[0] != float64(3) {
|
||||
t.Fatalf("library_ids = %v, want [3]", second["library_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreate(t *testing.T) {
|
||||
store := &fakeGroupStore{createGroup: testGroup()}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": " Film Club ",
|
||||
"description": "Movie nights",
|
||||
"permissions": []string{"marker_edit"},
|
||||
"library_ids": []int{1, 2},
|
||||
"max_streams": 2,
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.createInput == nil {
|
||||
t.Fatal("store.Create was not called")
|
||||
}
|
||||
if store.createInput.Name != "Film Club" {
|
||||
t.Fatalf("Name = %q, want trimmed name", store.createInput.Name)
|
||||
}
|
||||
if len(store.createInput.LibraryIDs) != 2 {
|
||||
t.Fatalf("LibraryIDs = %v, want [1 2]", store.createInput.LibraryIDs)
|
||||
}
|
||||
if store.createInput.MaxStreams == nil || *store.createInput.MaxStreams != 2 {
|
||||
t.Fatalf("MaxStreams = %v, want 2", store.createInput.MaxStreams)
|
||||
}
|
||||
if store.createInput.MaxTranscodes != nil {
|
||||
t.Fatalf("MaxTranscodes = %v, want nil (omitted)", store.createInput.MaxTranscodes)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["slug"] != "film-club" {
|
||||
t.Fatalf("slug = %v, want film-club", body["slug"])
|
||||
}
|
||||
if body["member_count"] != float64(0) {
|
||||
t.Fatalf("member_count = %v, want 0 for new group", body["member_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateEmptyName(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": " ",
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.createInput != nil {
|
||||
t.Fatal("store.Create should not be called for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateUnknownPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": "Film Club",
|
||||
"permissions": []string{"server_owner"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.createInput != nil {
|
||||
t.Fatal("store.Create should not be called for unknown permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateDuplicate(t *testing.T) {
|
||||
store := &fakeGroupStore{createErr: auth.ErrDuplicate}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": "Film Club",
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "duplicate")
|
||||
}
|
||||
|
||||
func TestAdminGroupsGet(t *testing.T) {
|
||||
store := &fakeGroupStore{getGroup: testGroup(), memberCount: 5}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["id"] != float64(7) {
|
||||
t.Fatalf("id = %v, want 7", body["id"])
|
||||
}
|
||||
if body["member_count"] != float64(5) {
|
||||
t.Fatalf("member_count = %v, want 5", body["member_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsGetNotFound(t *testing.T) {
|
||||
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
func TestAdminGroupsGetInvalidID(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/abc", nil)
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchStripAdminPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{updateErr: auth.ErrAdminPermRequired}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/1", map[string]any{
|
||||
"permissions": []string{"marker_edit"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "admin_permission_required")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchUnknownPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/7", map[string]any{
|
||||
"permissions": []string{"server_owner"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.updateInput != nil {
|
||||
t.Fatal("store.Update should not be called for unknown permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchLibraryIDsSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want func(t *testing.T, input *models.UpdateGroupInput)
|
||||
}{
|
||||
{
|
||||
name: "absent means no change",
|
||||
body: `{"name":"Renamed"}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs != nil {
|
||||
t.Fatalf("LibraryIDs = %v, want nil (no change)", input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null means all libraries",
|
||||
body: `{"library_ids":null}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to nil slice (all libraries)")
|
||||
}
|
||||
if *input.LibraryIDs != nil {
|
||||
t.Fatalf("*LibraryIDs = %v, want nil slice (all libraries)", *input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty array means no libraries",
|
||||
body: `{"library_ids":[]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to empty slice (no libraries)")
|
||||
}
|
||||
if *input.LibraryIDs == nil || len(*input.LibraryIDs) != 0 {
|
||||
t.Fatalf("*LibraryIDs = %v, want non-nil empty slice", *input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit ids",
|
||||
body: `{"library_ids":[1,2]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to [1 2]")
|
||||
}
|
||||
ids := *input.LibraryIDs
|
||||
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Fatalf("*LibraryIDs = %v, want [1 2]", ids)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &fakeGroupStore{updateGroup: testGroup()}
|
||||
router := newGroupsTestRouter(store)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.updateInput == nil {
|
||||
t.Fatal("store.Update was not called")
|
||||
}
|
||||
tt.want(t, store.updateInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsDeleteBuiltIn(t *testing.T) {
|
||||
store := &fakeGroupStore{deleteErr: auth.ErrBuiltInGroup}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "built_in_group")
|
||||
}
|
||||
|
||||
func TestAdminGroupsDelete(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.deleteID != 7 {
|
||||
t.Fatalf("deleted id = %d, want 7", store.deleteID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersPagination(t *testing.T) {
|
||||
store := &fakeGroupStore{
|
||||
members: []auth.GroupMember{
|
||||
{UserID: 3, Username: "alice", Email: "alice@example.com", Enabled: true},
|
||||
},
|
||||
membersTotal: 42,
|
||||
}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members?offset=10&limit=5", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.membersGroupID != 7 || store.membersOffset != 10 || store.membersLimit != 5 {
|
||||
t.Fatalf("ListMembers(%d, %d, %d), want (7, 10, 5)",
|
||||
store.membersGroupID, store.membersOffset, store.membersLimit)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["total"] != float64(42) || body["offset"] != float64(10) || body["limit"] != float64(5) {
|
||||
t.Fatalf("pagination = total %v offset %v limit %v, want 42/10/5",
|
||||
body["total"], body["offset"], body["limit"])
|
||||
}
|
||||
members, ok := body["members"].([]any)
|
||||
if !ok || len(members) != 1 {
|
||||
t.Fatalf("members = %v, want 1 entry", body["members"])
|
||||
}
|
||||
member := members[0].(map[string]any)
|
||||
if member["user_id"] != float64(3) || member["username"] != "alice" ||
|
||||
member["email"] != "alice@example.com" || member["enabled"] != true {
|
||||
t.Fatalf("member = %v, want alice row", member)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersDefaults(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.membersOffset != 0 || store.membersLimit != 50 {
|
||||
t.Fatalf("ListMembers offset %d limit %d, want defaults 0/50",
|
||||
store.membersOffset, store.membersLimit)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if members, ok := body["members"].([]any); !ok || len(members) != 0 {
|
||||
t.Fatalf("members = %v, want empty array (not null)", body["members"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsAddMember(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/12", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.addGroupID != 7 || store.addUserID != 12 {
|
||||
t.Fatalf("AddMember(%d, %d), want (7, 12)", store.addGroupID, store.addUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsRemoveLastAdministrator(t *testing.T) {
|
||||
store := &fakeGroupStore{removeErr: auth.ErrLastAdministrator}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1/members/12", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestAdminGroupsRemoveMember(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7/members/12", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.removeGroupID != 7 || store.removeUserID != 12 {
|
||||
t.Fatalf("RemoveMember(%d, %d), want (7, 12)", store.removeGroupID, store.removeUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsAddMemberUnknownUser(t *testing.T) {
|
||||
store := &fakeGroupStore{addErr: auth.ErrUnknownUser}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/999", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "unknown_user")
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersNotFound(t *testing.T) {
|
||||
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999/members", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchPermissionsSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want func(t *testing.T, input *models.UpdateGroupInput)
|
||||
}{
|
||||
{
|
||||
name: "explicit permissions reach store as pointer to slice",
|
||||
body: `{"permissions":["marker_edit"]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions == nil {
|
||||
t.Fatal("Permissions = nil, want pointer to [\"marker_edit\"]")
|
||||
}
|
||||
perms := *input.Permissions
|
||||
if len(perms) != 1 || perms[0] != "marker_edit" {
|
||||
t.Fatalf("*Permissions = %v, want [\"marker_edit\"]", perms)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null permissions reaches store as pointer to empty slice",
|
||||
body: `{"permissions":null}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions == nil {
|
||||
t.Fatal("Permissions = nil, want pointer to empty slice")
|
||||
}
|
||||
if len(*input.Permissions) != 0 {
|
||||
t.Fatalf("*Permissions = %v, want empty slice", *input.Permissions)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absent permissions reaches store as nil (no change)",
|
||||
body: `{"name":"Renamed"}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions != nil {
|
||||
t.Fatalf("Permissions = %v, want nil (no change)", input.Permissions)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &fakeGroupStore{updateGroup: testGroup()}
|
||||
router := newGroupsTestRouter(store)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.updateInput == nil {
|
||||
t.Fatal("store.Update was not called")
|
||||
}
|
||||
tt.want(t, store.updateInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type AdminJobsHandler struct {
|
||||
store AdminJobArtifactStore
|
||||
CancelRegistry *adminjob.CancelRegistry
|
||||
RealtimeHub *notifications.Hub
|
||||
Users auth.UserLoader // for server-side admin checks on non-admin-gated routes
|
||||
}
|
||||
|
||||
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
|
||||
@@ -122,13 +123,14 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if !canReadAdminJob(claims, job) {
|
||||
viewerUserID := apimw.GetUserID(r.Context())
|
||||
viewerIsAdmin := isAdminRequest(r, h.Users)
|
||||
if !canReadAdminJob(viewerUserID, viewerIsAdmin, job) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForViewer(r, job, h.store, viewerIsAdmin))
|
||||
}
|
||||
|
||||
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -264,19 +266,19 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
|
||||
return resp
|
||||
}
|
||||
|
||||
func adminJobToResponseForClaims(
|
||||
func adminJobToResponseForViewer(
|
||||
r *http.Request,
|
||||
job *models.AdminJob,
|
||||
store AdminJobArtifactStore,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
) adminJobResponse {
|
||||
response := adminJobToResponse(r, job, store)
|
||||
sanitizeAdminJobResponseForClaims(&response, claims)
|
||||
sanitizeAdminJobResponseForViewer(&response, viewerIsAdmin)
|
||||
return response
|
||||
}
|
||||
|
||||
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
|
||||
if response == nil || (claims != nil && claims.Role == "admin") {
|
||||
func sanitizeAdminJobResponseForViewer(response *adminJobResponse, viewerIsAdmin bool) {
|
||||
if response == nil || viewerIsAdmin {
|
||||
return
|
||||
}
|
||||
response.RequestPayload = json.RawMessage(`{}`)
|
||||
@@ -384,12 +386,12 @@ func currentAdminUserID(r *http.Request) int {
|
||||
return claims.UserID
|
||||
}
|
||||
|
||||
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
|
||||
if claims == nil || job == nil {
|
||||
func canReadAdminJob(viewerUserID int, viewerIsAdmin bool, job *models.AdminJob) bool {
|
||||
if viewerUserID == 0 || job == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
if viewerIsAdmin {
|
||||
return true
|
||||
}
|
||||
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
|
||||
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == viewerUserID
|
||||
}
|
||||
|
||||
@@ -13,46 +13,40 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 1, Role: "admin"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if !canReadAdminJob(claims, job) {
|
||||
if !canReadAdminJob(1, true, job) {
|
||||
t.Fatal("admin should be allowed to read any job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if !canReadAdminJob(claims, job) {
|
||||
if !canReadAdminJob(2, false, job) {
|
||||
t.Fatal("creator should be allowed to read own item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if canReadAdminJob(claims, job) {
|
||||
if canReadAdminJob(2, false, job) {
|
||||
t.Fatal("non-admin should not read non-item-refresh jobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 3, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if canReadAdminJob(claims, job) {
|
||||
if canReadAdminJob(3, false, job) {
|
||||
t.Fatal("non-admin should not read another user's item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
|
||||
job := &models.AdminJob{
|
||||
JobType: adminjob.JobTypeItemRefresh,
|
||||
CreatedByUserID: 2,
|
||||
@@ -66,7 +60,7 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes
|
||||
PublicURL: "https://example.test/public",
|
||||
}
|
||||
|
||||
resp := adminJobToResponseForClaims(nil, job, nil, claims)
|
||||
resp := adminJobToResponseForViewer(nil, job, nil, false)
|
||||
|
||||
if string(resp.RequestPayload) != `{}` {
|
||||
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
|
||||
|
||||
@@ -111,7 +111,7 @@ func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter
|
||||
return
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
|
||||
result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil)
|
||||
if result.DispatchErr != nil {
|
||||
@@ -173,7 +173,7 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
|
||||
return
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
|
||||
command.DeadlineMS = int(deadline / time.Millisecond)
|
||||
|
||||
|
||||
@@ -1,34 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeAdminUserRepo is a configurable in-memory UserRepository for handler
|
||||
// tests.
|
||||
type fakeAdminUserRepo struct {
|
||||
user *models.User
|
||||
updateErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) List(context.Context) ([]*models.User, error) { return nil, nil }
|
||||
|
||||
func (f *fakeAdminUserRepo) Create(context.Context, models.CreateUserInput) (*models.User, error) {
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) Update(context.Context, int, models.UpdateUserInput) error {
|
||||
return f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) Delete(context.Context, int) error { return f.deleteErr }
|
||||
|
||||
func (f *fakeAdminUserRepo) GetByID(context.Context, int) (*models.User, error) {
|
||||
if f.user == nil {
|
||||
return nil, auth.ErrNotFound
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
// newAdminUsersTestRouter mounts the user-management routes the same way the
|
||||
// API router does so tests exercise real chi URL params.
|
||||
func newAdminUsersTestRouter(repo UserRepository) chi.Router {
|
||||
h := NewAdminHandler(repo, nil, nil)
|
||||
r := chi.NewRouter()
|
||||
r.Put("/admin/users/{id}", h.HandleUpdateUser)
|
||||
r.Delete("/admin/users/{id}", h.HandleDeleteUser)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminUpdateUserDisableLastAdministrator(t *testing.T) {
|
||||
repo := &fakeAdminUserRepo{
|
||||
user: &models.User{ID: 2, Enabled: true},
|
||||
updateErr: auth.ErrLastAdministrator,
|
||||
}
|
||||
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodPut,
|
||||
"/admin/users/2", map[string]any{"enabled": false})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestAdminDeleteUserLastAdministrator(t *testing.T) {
|
||||
repo := &fakeAdminUserRepo{deleteErr: auth.ErrLastAdministrator}
|
||||
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodDelete,
|
||||
"/admin/users/2", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
role := "admin"
|
||||
sameRole := "user"
|
||||
enabled := true
|
||||
disabled := false
|
||||
libraryIDs := []int{1, 2}
|
||||
sameLibraryIDs := []int{1}
|
||||
emptyLibraryIDs := []int{}
|
||||
var allLibraryIDs []int
|
||||
maxPlaybackQuality := "1080p"
|
||||
sameMaxPlaybackQuality := "original"
|
||||
password := "new-password"
|
||||
username := "renamed"
|
||||
maxStreams := 4
|
||||
permissions := []string{"metadata_curation"}
|
||||
samePermissions := []string{"download"}
|
||||
newGroups := []int{1, 2}
|
||||
sameGroups := []int{2, 1, 1} // order and duplicates must not matter
|
||||
emptyGroups := []int{}
|
||||
|
||||
current := &models.User{
|
||||
Role: "user",
|
||||
Permissions: []string{"download"},
|
||||
Enabled: false,
|
||||
LibraryIDs: []int{1},
|
||||
MaxPlaybackQuality: "original",
|
||||
Enabled: false,
|
||||
GroupIDs: []int{1, 2},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -36,26 +84,6 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
in models.UpdateUserInput
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "permissions set",
|
||||
in: models.UpdateUserInput{Permissions: &permissions},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "permissions unchanged",
|
||||
in: models.UpdateUserInput{Permissions: &samePermissions},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
in: models.UpdateUserInput{Role: &role},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "role unchanged",
|
||||
in: models.UpdateUserInput{Role: &sameRole},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
in: models.UpdateUserInput{Enabled: &enabled},
|
||||
@@ -67,30 +95,15 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
|
||||
name: "groups unchanged",
|
||||
in: models.UpdateUserInput{GroupIDs: &sameGroups},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids unchanged",
|
||||
in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids nil does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "max playback quality",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality},
|
||||
name: "groups cleared",
|
||||
in: models.UpdateUserInput{GroupIDs: &emptyGroups},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "max playback quality unchanged",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
in: models.UpdateUserInput{Password: &password},
|
||||
@@ -98,7 +111,7 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "non access fields",
|
||||
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
|
||||
in: models.UpdateUserInput{Username: &username},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
@@ -116,16 +129,18 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
unrestrictedCurrent := *current
|
||||
unrestrictedCurrent.LibraryIDs = nil
|
||||
t.Run("library ids empty does not revoke session", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
memberless := &models.User{Enabled: true, GroupIDs: nil}
|
||||
t.Run("groups assigned to memberless user", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(memberless, models.UpdateUserInput{GroupIDs: &newGroups}); !got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("library ids nil unchanged", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
|
||||
t.Run("unknown current user falls back to may-require", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{GroupIDs: &sameGroups}); !got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
|
||||
}
|
||||
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{Username: &username}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -344,7 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
const ttl = 5 * time.Minute
|
||||
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
|
||||
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.SessionID, ttl)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
|
||||
return
|
||||
@@ -527,7 +527,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
Role: auth.RoleForUser(user),
|
||||
Permissions: auth.EffectivePermissions(user),
|
||||
DownloadAllowed: user.DownloadAllowed,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ func newEbookReaderAuthRequest(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
@@ -421,7 +420,6 @@ func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
@@ -524,7 +522,6 @@ func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
@@ -608,7 +605,6 @@ func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -31,6 +32,10 @@ type activeScanLister interface {
|
||||
ListActive(ctx context.Context) ([]evt.ScanRun, error)
|
||||
}
|
||||
|
||||
// viewerRevalidateInterval is how often an open events WebSocket re-loads its
|
||||
// user to confirm the account is still enabled and admin status is unchanged.
|
||||
const viewerRevalidateInterval = 60 * time.Second
|
||||
|
||||
type EventsHandler struct {
|
||||
hub *evt.Hub
|
||||
jobs *AdminJobsHandler
|
||||
@@ -39,6 +44,11 @@ type EventsHandler struct {
|
||||
scans *evt.ScanRegistry
|
||||
persistedScans activeScanLister
|
||||
historyImports historyImportActiveLister
|
||||
users auth.UserLoader
|
||||
|
||||
// revalidateInterval overrides viewerRevalidateInterval in tests; zero
|
||||
// means the default.
|
||||
revalidateInterval time.Duration
|
||||
}
|
||||
|
||||
func NewEventsHandler(
|
||||
@@ -49,6 +59,7 @@ func NewEventsHandler(
|
||||
scans *evt.ScanRegistry,
|
||||
persistedScans *scanqueue.Service,
|
||||
historyImports historyImportActiveLister,
|
||||
users auth.UserLoader,
|
||||
) *EventsHandler {
|
||||
return &EventsHandler{
|
||||
hub: hub,
|
||||
@@ -58,6 +69,7 @@ func NewEventsHandler(
|
||||
scans: scans,
|
||||
persistedScans: persistedScans,
|
||||
historyImports: historyImports,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +85,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Admin status is checked server-side once per connection; it never comes
|
||||
// from token contents.
|
||||
viewerIsAdmin := isAdminRequest(r, h.users)
|
||||
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -89,7 +105,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return writeWebSocketControl(conn, websocket.PingMessage, nil)
|
||||
})
|
||||
|
||||
allowedChannels := allowedChannelsForRole(claims.Role)
|
||||
allowedChannels := allowedChannelsForViewer(viewerIsAdmin)
|
||||
connectionID := ulid.Make().String()
|
||||
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
|
||||
Type: "hello",
|
||||
@@ -121,6 +137,17 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
|
||||
// Channel authorization is decided once at upgrade, so a long-lived
|
||||
// connection must periodically re-load its user: a disabled account or a
|
||||
// changed admin role would otherwise keep its channel set until the
|
||||
// client disconnects on its own.
|
||||
revalidateEvery := h.revalidateInterval
|
||||
if revalidateEvery <= 0 {
|
||||
revalidateEvery = viewerRevalidateInterval
|
||||
}
|
||||
revalidate := time.NewTicker(revalidateEvery)
|
||||
defer revalidate.Stop()
|
||||
|
||||
subscriptions := make(map[evt.EventChannel]struct{})
|
||||
subscribedOnce := false
|
||||
|
||||
@@ -130,6 +157,33 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
case <-readDone:
|
||||
return
|
||||
case <-revalidate.C:
|
||||
if h.users == nil {
|
||||
// No loader wired: the viewer was never granted admin
|
||||
// channels (isAdminRequest fails closed), so there is no
|
||||
// stale privilege to revoke.
|
||||
continue
|
||||
}
|
||||
user, err := h.users.GetByID(ctx, claims.UserID)
|
||||
if err != nil && !auth.IsNotFound(err) {
|
||||
// Transient lookup failure: keep the connection and retry on
|
||||
// the next tick rather than dropping every viewer on a DB
|
||||
// blip. A definitive miss (ErrNotFound) leaves user nil and
|
||||
// closes below.
|
||||
slog.Warn("events: viewer revalidation failed; retrying next tick",
|
||||
"user_id", claims.UserID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if closeConn, reason := revalidateViewer(user, viewerIsAdmin); closeConn {
|
||||
_ = writeWebSocketControl(
|
||||
conn,
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason),
|
||||
)
|
||||
return
|
||||
}
|
||||
case <-deadline.C:
|
||||
if subscribedOnce {
|
||||
continue
|
||||
@@ -142,7 +196,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
)
|
||||
return
|
||||
case data := <-readMessages:
|
||||
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
|
||||
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, viewerIsAdmin, data, allowedChannels)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -164,10 +218,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
if _, subscribed := subscriptions[env.Channel]; !subscribed {
|
||||
continue
|
||||
}
|
||||
if !allowsEventForClaims(claims, env) {
|
||||
if !allowsEventForViewer(claims.UserID, viewerIsAdmin, env) {
|
||||
continue
|
||||
}
|
||||
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
|
||||
if err := h.writeEventFrame(conn, r, claims, viewerIsAdmin, env); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -178,6 +232,7 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
data []byte,
|
||||
allowed []evt.EventChannel,
|
||||
) (map[evt.EventChannel]struct{}, bool, bool) {
|
||||
@@ -262,7 +317,7 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
}
|
||||
|
||||
for _, channel := range accepted {
|
||||
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
|
||||
if err := h.writeSnapshotFrame(conn, r, claims, viewerIsAdmin, channel); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
}
|
||||
@@ -270,13 +325,32 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
return nextSubs, true, true
|
||||
}
|
||||
|
||||
func allowedChannelsForRole(role string) []evt.EventChannel {
|
||||
// revalidateViewer decides whether an open events WebSocket must be closed
|
||||
// after its user is re-loaded. The connection closes when the account is gone
|
||||
// or disabled, and when admin status changed in either direction — the
|
||||
// allowed channel set is fixed at upgrade, so the client must reconnect to
|
||||
// receive the correct set; closing on change is simpler and safer than
|
||||
// re-assigning channels mid-stream. Other policy changes (AccessPolicyRevision
|
||||
// bumps from group/library edits) deliberately keep the connection: event
|
||||
// authorization (allowedChannelsForViewer, allowsEventForViewer) depends only
|
||||
// on admin status and user ID, never on per-user library or rating policy.
|
||||
func revalidateViewer(user *models.User, wasAdmin bool) (closeConn bool, reason string) {
|
||||
if user == nil || !user.Enabled {
|
||||
return true, "account disabled"
|
||||
}
|
||||
if user.IsAdmin != wasAdmin {
|
||||
return true, "permissions changed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
|
||||
channels := []evt.EventChannel{
|
||||
evt.ChannelCatalog,
|
||||
evt.ChannelHistoryImport,
|
||||
evt.ChannelUserState,
|
||||
}
|
||||
if role == "admin" {
|
||||
if viewerIsAdmin {
|
||||
channels = append(channels,
|
||||
evt.ChannelJobs,
|
||||
evt.ChannelSessions,
|
||||
@@ -287,14 +361,14 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
|
||||
return channels
|
||||
}
|
||||
|
||||
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
|
||||
if claims == nil {
|
||||
func allowsEventForViewer(viewerUserID int, viewerIsAdmin bool, env evt.Envelope) bool {
|
||||
if viewerUserID == 0 {
|
||||
return false
|
||||
}
|
||||
if env.AdminOnly && claims.Role != "admin" {
|
||||
if env.AdminOnly && !viewerIsAdmin {
|
||||
return false
|
||||
}
|
||||
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
|
||||
if env.UserID > 0 && !viewerIsAdmin && env.UserID != viewerUserID {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -314,6 +388,7 @@ func marshalJSON(value any) json.RawMessage {
|
||||
func (h *EventsHandler) snapshotForChannel(
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
channel evt.EventChannel,
|
||||
) (json.RawMessage, error) {
|
||||
switch channel {
|
||||
@@ -366,7 +441,7 @@ func (h *EventsHandler) snapshotForChannel(
|
||||
if h == nil || h.historyImports == nil {
|
||||
return json.RawMessage("[]"), nil
|
||||
}
|
||||
if claims != nil && claims.Role == "admin" {
|
||||
if viewerIsAdmin {
|
||||
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -387,9 +462,10 @@ func (h *EventsHandler) writeSnapshotFrame(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
channel evt.EventChannel,
|
||||
) error {
|
||||
data, err := h.snapshotForChannel(r, claims, channel)
|
||||
data, err := h.snapshotForChannel(r, claims, viewerIsAdmin, channel)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"events: failed to build initial snapshot",
|
||||
@@ -415,11 +491,12 @@ func (h *EventsHandler) writeEventFrame(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
env evt.Envelope,
|
||||
) error {
|
||||
data := env.Data
|
||||
if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") {
|
||||
snapshot, err := h.snapshotForChannel(r, claims, env.Channel)
|
||||
snapshot, err := h.snapshotForChannel(r, claims, viewerIsAdmin, env.Channel)
|
||||
if err != nil {
|
||||
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestRevalidateViewer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user *models.User
|
||||
wasAdmin bool
|
||||
wantClose bool
|
||||
}{
|
||||
{
|
||||
name: "user deleted",
|
||||
user: nil,
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "user disabled",
|
||||
user: &models.User{ID: 1, Enabled: false},
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "admin disabled keeps no access even with admin flag",
|
||||
user: &models.User{ID: 1, Enabled: false, IsAdmin: true},
|
||||
wasAdmin: true,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "admin demoted",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
|
||||
wasAdmin: true,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "viewer promoted to admin",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "enabled non-admin unchanged",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
|
||||
wasAdmin: false,
|
||||
wantClose: false,
|
||||
},
|
||||
{
|
||||
name: "enabled admin unchanged",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
|
||||
wasAdmin: true,
|
||||
wantClose: false,
|
||||
},
|
||||
{
|
||||
// Policy revision bumps (group/library edits) do not affect event
|
||||
// authorization, which depends only on admin status and user ID.
|
||||
name: "policy revision change without admin change",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false, AccessPolicyRevision: 42},
|
||||
wasAdmin: false,
|
||||
wantClose: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
closeConn, reason := revalidateViewer(tc.user, tc.wasAdmin)
|
||||
if closeConn != tc.wantClose {
|
||||
t.Fatalf("revalidateViewer() close = %v, want %v", closeConn, tc.wantClose)
|
||||
}
|
||||
if closeConn && reason == "" {
|
||||
t.Fatal("expected a close reason")
|
||||
}
|
||||
if !closeConn && reason != "" {
|
||||
t.Fatalf("expected empty reason for kept connection, got %q", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mutableUserLoader is a swappable auth.UserLoader for revalidation tests.
|
||||
type mutableUserLoader struct {
|
||||
mu sync.Mutex
|
||||
user *models.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *mutableUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.err != nil {
|
||||
return nil, l.err
|
||||
}
|
||||
if l.user == nil || l.user.ID != id {
|
||||
return nil, auth.ErrNotFound
|
||||
}
|
||||
userCopy := *l.user
|
||||
return &userCopy, nil
|
||||
}
|
||||
|
||||
func (l *mutableUserLoader) set(user *models.User, err error) {
|
||||
l.mu.Lock()
|
||||
l.user = user
|
||||
l.err = err
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection covers the
|
||||
// revalidation ticker end to end: an open connection is closed by the server
|
||||
// once the periodic user re-load reports the account disabled.
|
||||
func TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection(t *testing.T) {
|
||||
loader := &mutableUserLoader{user: &models.User{ID: 1, Enabled: true}}
|
||||
handler := NewEventsHandler(evt.NewHub("test", nil), nil, nil, nil, nil, nil, nil, loader)
|
||||
handler.revalidateInterval = 20 * time.Millisecond
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
handler.HandleWebSocket(w, r.WithContext(ctx))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial websocket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Complete the handshake: hello, subscribe, subscribed + snapshot.
|
||||
var hello evt.EventsHelloMessage
|
||||
if err := conn.ReadJSON(&hello); err != nil {
|
||||
t.Fatalf("read hello: %v", err)
|
||||
}
|
||||
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
|
||||
Type: "subscribe",
|
||||
Channels: []evt.EventChannel{evt.ChannelCatalog},
|
||||
}); err != nil {
|
||||
t.Fatalf("write subscribe: %v", err)
|
||||
}
|
||||
|
||||
// The account is disabled while the connection is open; the next
|
||||
// revalidation tick must close it with a normal close frame.
|
||||
loader.set(&models.User{ID: 1, Enabled: false}, nil)
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
for {
|
||||
var frame json.RawMessage
|
||||
if err := conn.ReadJSON(&frame); err != nil {
|
||||
var closeErr *websocket.CloseError
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
return // server closed the connection as required
|
||||
}
|
||||
t.Fatalf("expected normal close, got %v (%T %v)", err, err, closeErr)
|
||||
}
|
||||
// Drain subscribed/snapshot frames until the close arrives.
|
||||
}
|
||||
}
|
||||
@@ -1800,9 +1800,6 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.UserRepo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package handlers
|
||||
|
||||
// This file holds tri-state JSON field wrappers for PATCH-style request
|
||||
// bodies. Plain Go pointers can only distinguish two states (absent/nil vs.
|
||||
// value); these wrappers track three:
|
||||
//
|
||||
// - absent: the key was not present in the JSON body (Set = false) —
|
||||
// the caller should leave the current value unchanged
|
||||
// - null: the key was present with an explicit JSON null (Set = true)
|
||||
// - value: the key was present with a concrete value (Set = true)
|
||||
//
|
||||
// How null is decoded depends on the wrapper: optionalIntSliceField keeps a
|
||||
// nil slice (e.g. "all libraries"), optionalStringSliceField normalizes null
|
||||
// to an empty slice.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// optionalIntSliceField distinguishes an absent JSON field from an explicit
|
||||
// null or array value. Absent = don't update; null = nil slice ("all
|
||||
// libraries"); [] = empty slice ("none").
|
||||
type optionalIntSliceField struct {
|
||||
Set bool
|
||||
Value []int
|
||||
}
|
||||
|
||||
func (f *optionalIntSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = nil
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
// Ptr returns nil when the field was absent, otherwise a pointer to the
|
||||
// decoded slice (which may itself be nil for JSON null).
|
||||
func (f optionalIntSliceField) Ptr() *[]int {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := f.Value
|
||||
return &value
|
||||
}
|
||||
|
||||
// optionalStringSliceField distinguishes an absent JSON field from an
|
||||
// explicit null or array value. JSON null decodes to an empty slice.
|
||||
type optionalStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
@@ -288,9 +288,6 @@ func (h *MarkersHandler) requireMarkerEdit(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.Users == nil {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
|
||||
return false
|
||||
|
||||
@@ -90,7 +90,6 @@ func markerItemPutRequest(body string) *http.Request {
|
||||
func withMarkerAdminClaims(req *http.Request) *http.Request {
|
||||
return req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
SessionID: "session-1",
|
||||
}))
|
||||
@@ -98,7 +97,12 @@ func withMarkerAdminClaims(req *http.Request) *http.Request {
|
||||
|
||||
func newMarkersHandler(writer ManualMarkerWriter) *MarkersHandler {
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
return NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{
|
||||
1: {ID: 1, Enabled: true, IsAdmin: true},
|
||||
7: {ID: 7, Enabled: true, IsAdmin: true},
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestSetFileMarkersWritesManual(t *testing.T) {
|
||||
@@ -131,6 +135,7 @@ func TestSetItemMarkersWritesPrimaryEpisodeFile(t *testing.T) {
|
||||
episodeFiles: []*models.MediaFile{file},
|
||||
}
|
||||
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetItemMarkers(rec, markerItemPutRequest(`{"recap":{"start":0,"end":45}}`))
|
||||
@@ -209,13 +214,13 @@ func TestGetItemMarkersUsesFirstAuthorizedFile(t *testing.T) {
|
||||
func TestGetFileMarkersDoesNotRequireMarkerEditPermission(t *testing.T) {
|
||||
h := newMarkersHandler(nil)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/markers/files/5", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetFileMarkers(rec, req)
|
||||
@@ -229,10 +234,10 @@ func TestSetFileMarkersRejectsUserWithoutMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
@@ -249,10 +254,10 @@ func TestSetFileMarkersAllowsUserWithMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{"marker_edit"}},
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: []string{"marker_edit"}},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
@@ -274,7 +279,6 @@ func TestSetFileMarkersPassesAuditContextToWriter(t *testing.T) {
|
||||
ctx := clientip.SetContext(req.Context(), "203.0.113.10")
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{
|
||||
UserID: 7,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAPIKey,
|
||||
APIKeyID: apiKeyID,
|
||||
})
|
||||
@@ -382,6 +386,7 @@ func TestSetFileMarkersTriggersBackgroundContribution(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":0,"end":60}}`))
|
||||
@@ -403,6 +408,7 @@ func TestSetFileMarkersClearOnlyDoesNotContribute(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
|
||||
|
||||
@@ -162,7 +162,7 @@ func newPlaybackTestStore(t *testing.T) userstore.UserStore {
|
||||
|
||||
func newAuthorizedPlaybackContext() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
return apimw.SetProfileID(ctx, "profile-1")
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,12 @@ type verifyPINResponse struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// isAdminProfileRequest reports whether the request user currently holds the
|
||||
// admin permission, checked server-side against the loaded user.
|
||||
func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
|
||||
return isAdminRequest(r, h.UserRepo)
|
||||
}
|
||||
|
||||
// canManageHouseholdProfiles reports whether the caller may create/update/delete
|
||||
// profiles belonging to their user. Server admins always can. Otherwise the
|
||||
// caller's active profile must be the primary profile for the household.
|
||||
@@ -133,7 +139,7 @@ type verifyPINResponse struct {
|
||||
// bypass the profile lock by sending only X-Profile-Id.
|
||||
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
|
||||
ctx := r.Context()
|
||||
if apimw.IsAdmin(ctx) {
|
||||
if h.isAdminProfileRequest(r) {
|
||||
return true, nil
|
||||
}
|
||||
activeProfileID := apimw.GetProfileID(ctx)
|
||||
@@ -311,7 +317,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
|
||||
// Access-policy fields only make sense when set by a manager on a managed
|
||||
// profile. On bootstrap the caller is becoming primary themselves, so non-
|
||||
// admin bootstrap creations must leave those fields at their defaults.
|
||||
if isBootstrap && !apimw.IsAdmin(r.Context()) &&
|
||||
if isBootstrap && !h.isAdminProfileRequest(r) &&
|
||||
(req.IsChild || req.MaxContentRating != "" ||
|
||||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
|
||||
req.MaxPlaybackQuality != "") {
|
||||
|
||||
@@ -45,7 +45,6 @@ func newAuthorizedProfileRequestWithSession(
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: role,
|
||||
SessionID: sessionID,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
@@ -99,7 +98,7 @@ func TestHandleCreateProfile_EnforcesUserProfileLimit(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
||||
handler.UserRepo = testProfileUserRepo{
|
||||
user: &models.User{ID: 1, MaxProfiles: 1},
|
||||
user: &models.User{ID: 1, MaxProfiles: 1, Enabled: true, IsAdmin: true},
|
||||
}
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
@@ -50,6 +51,7 @@ type RequestService interface {
|
||||
|
||||
type RequestsHandler struct {
|
||||
service RequestService
|
||||
Users auth.UserLoader // for server-side admin checks
|
||||
}
|
||||
|
||||
func NewRequestsHandler(service RequestService) *RequestsHandler {
|
||||
@@ -57,7 +59,7 @@ func NewRequestsHandler(service RequestService) *RequestsHandler {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -80,7 +82,7 @@ func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -95,7 +97,7 @@ func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -112,7 +114,7 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -127,7 +129,7 @@ func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -142,7 +144,7 @@ func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -157,7 +159,7 @@ func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -176,7 +178,7 @@ func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -195,7 +197,7 @@ func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -215,7 +217,7 @@ func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -234,7 +236,7 @@ func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -252,7 +254,7 @@ func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -267,7 +269,7 @@ func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -280,7 +282,7 @@ func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -295,7 +297,7 @@ func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -308,7 +310,7 @@ func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -330,7 +332,7 @@ func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -352,7 +354,7 @@ func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -365,7 +367,7 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -378,7 +380,7 @@ func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -391,7 +393,7 @@ func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -409,7 +411,7 @@ func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -424,7 +426,7 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -442,7 +444,7 @@ func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -461,7 +463,7 @@ func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -473,7 +475,7 @@ func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -496,7 +498,7 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -513,7 +515,7 @@ func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -535,7 +537,7 @@ func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.R
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
||||
func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil || claims.UserID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
@@ -549,7 +551,7 @@ func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool)
|
||||
return mediarequests.Viewer{
|
||||
UserID: claims.UserID,
|
||||
ProfileID: profileID,
|
||||
IsAdmin: claims.Role == "admin",
|
||||
IsAdmin: isAdminRequest(r, h.Users),
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,6 @@ func authedRequest(method, target string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
|
||||
@@ -891,8 +891,8 @@ func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *ht
|
||||
allowCustom = v == "true"
|
||||
}
|
||||
isAdmin := false
|
||||
if claims := apimw.GetClaims(r.Context()); claims != nil {
|
||||
isAdmin = claims.Role == "admin"
|
||||
if h.UserRepo != nil {
|
||||
isAdmin = isAdminRequest(r, h.UserRepo)
|
||||
}
|
||||
|
||||
for _, o := range req.Overrides {
|
||||
|
||||
@@ -452,7 +452,7 @@ func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testin
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Authenticate as a non-admin profile.
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -471,7 +471,7 @@ func TestSaveProfileOverridesUnknownRecipeReturnsBadRequest(t *testing.T) {
|
||||
body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"no_such_recipe","user_config":{}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -495,7 +495,7 @@ func TestSaveProfileOverridesLegacyShapeCannotBypassGate(t *testing.T) {
|
||||
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"admin_curated_list","config":{"item_ids":["a"]}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -515,7 +515,7 @@ func TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected(t *testing.T) {
|
||||
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"no_such_recipe","config":{}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -118,7 +118,7 @@ func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest)
|
||||
if command.Reason == "" {
|
||||
command.Reason = "server_restart_requested"
|
||||
}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
return command, 0, nil, nil
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestHandleSessionWebSocket_RequiresHelloBeforeRealtimeReady(t *testing.T) {
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/playback/ws/{session_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
handler.HandleSessionWebSocket(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
@@ -26,6 +27,9 @@ type SubtitleAIHandler struct {
|
||||
// StoreProvider resolves household profiles for the transcription quota
|
||||
// exemption check; when nil the whole admin account is exempt.
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
// Users loads the account server-side for the quota exemption's admin
|
||||
// check; admin status is never read from token contents.
|
||||
Users auth.UserLoader
|
||||
}
|
||||
|
||||
// NewSubtitleAIHandler creates a handler backed by the given service.
|
||||
@@ -154,7 +158,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
// budgeting gate, not a security boundary; the quota itself is account-scoped.
|
||||
func (h *SubtitleAIHandler) quotaExempt(r *http.Request) bool {
|
||||
ctx := r.Context()
|
||||
if !apimw.IsAdmin(ctx) {
|
||||
if !isAdminRequest(r, h.Users) {
|
||||
return false
|
||||
}
|
||||
profileID := apimw.GetProfileID(ctx)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -63,6 +64,7 @@ type SubtitleSearchHandler struct {
|
||||
repo subtitles.Repository
|
||||
mediaResolver SubtitleMediaResolver
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
Users auth.UserLoader // for server-side admin checks
|
||||
}
|
||||
|
||||
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
|
||||
@@ -334,8 +336,8 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
isAdmin := claims != nil && claims.Role == "admin"
|
||||
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
|
||||
isAdmin := !isOwner && isAdminRequest(r, h.Users)
|
||||
if !isAdmin && !isOwner {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Not authorized to delete this subtitle")
|
||||
return
|
||||
|
||||
@@ -56,7 +56,6 @@ func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
|
||||
@@ -19,6 +19,9 @@ type contextKey string
|
||||
// claimsKey is the context key for storing JWT claims.
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
// userKey is the context key for the loaded authenticated user.
|
||||
const userKey contextKey = "user"
|
||||
|
||||
// SessionValidator checks whether a session is still valid (not revoked/expired).
|
||||
type SessionValidator interface {
|
||||
IsValid(ctx context.Context, sessionID string) (bool, error)
|
||||
@@ -35,28 +38,25 @@ type APIKeyValidator interface {
|
||||
UpdateLastUsed(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// APIKeyUserLoader loads a user by ID for API key authentication.
|
||||
type APIKeyUserLoader interface {
|
||||
GetByID(ctx context.Context, id int) (*models.User, error)
|
||||
}
|
||||
|
||||
// AuthMiddleware provides HTTP middleware for JWT-based authentication with
|
||||
// session validity caching.
|
||||
type AuthMiddleware struct {
|
||||
tokenValidator TokenValidator
|
||||
sessionValidator SessionValidator
|
||||
apiKeyValidator APIKeyValidator // nil if API keys not configured
|
||||
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
|
||||
apiKeyValidator APIKeyValidator // nil if API keys not configured
|
||||
apiKeyUserLoader auth.UserLoader // nil if API keys not configured
|
||||
userLoader auth.UserLoader // nil if no user store; RequireAdmin then denies
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
|
||||
// and session validator.
|
||||
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
|
||||
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul auth.UserLoader, ul auth.UserLoader) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
tokenValidator: tv,
|
||||
sessionValidator: sv,
|
||||
apiKeyValidator: akv,
|
||||
apiKeyUserLoader: akul,
|
||||
userLoader: ul,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
||||
|
||||
claims = &auth.Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
SessionID: "",
|
||||
TokenType: auth.TokenTypeAPIKey,
|
||||
APIKeyID: apiKey.ID,
|
||||
@@ -144,23 +143,28 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAdmin is a standalone HTTP middleware that checks if the authenticated
|
||||
// user has the "admin" role. It expects RequireAuth to have already placed
|
||||
// claims in the request context.
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
// RequireAdmin enforces that the authenticated user currently holds the
|
||||
// admin permission. The check is server-side against group-derived policy —
|
||||
// never against token contents — so revoking admin takes effect on the next
|
||||
// request. The loaded user is stashed in the context for handlers.
|
||||
func (am *AuthMiddleware) RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
writeUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Role != "admin" {
|
||||
if am.userLoader == nil {
|
||||
writeForbidden(w, "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
user, err := am.userLoader.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled || !user.IsAdmin {
|
||||
writeForbidden(w, "Admin access required")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,12 +185,13 @@ func GetClaims(ctx context.Context) *auth.Claims {
|
||||
return claims
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the context's authenticated user account has the
|
||||
// admin role. Returns false when no claims are present. Note this is the
|
||||
// account-level role; it says nothing about which household profile is active.
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
claims := GetClaims(ctx)
|
||||
return claims != nil && claims.Role == "admin"
|
||||
// GetUser returns the user loaded by RequireAdmin, or nil.
|
||||
func GetUser(ctx context.Context) *models.User {
|
||||
user, ok := ctx.Value(userKey).(*models.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// GetUserID retrieves the user ID from the JWT claims in the context.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeAdminUserLoader implements auth.UserLoader for unit tests.
|
||||
type fakeAdminUserLoader struct {
|
||||
user *models.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserLoader) GetByID(_ context.Context, _ int) (*models.User, error) {
|
||||
return f.user, f.err
|
||||
}
|
||||
|
||||
// newTestAuthMiddleware returns an AuthMiddleware wired only with a userLoader;
|
||||
// the JWT/session fields are unused by RequireAdmin.
|
||||
func newTestAuthMiddleware(ul auth.UserLoader) *AuthMiddleware {
|
||||
return NewAuthMiddleware(nil, nil, nil, nil, ul)
|
||||
}
|
||||
|
||||
// adminClaims injects a minimal Claims into the given request context.
|
||||
func adminClaims(r *http.Request, userID int) *http.Request {
|
||||
ctx := SetClaims(r.Context(), &auth.Claims{UserID: userID, TokenType: auth.TokenTypeAccess})
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupReq func(*http.Request) *http.Request
|
||||
loader auth.UserLoader
|
||||
wantStatus int
|
||||
checkUser bool // if true, assert GetUser is non-nil inside the next handler
|
||||
}{
|
||||
{
|
||||
name: "no claims in context returns 401",
|
||||
setupReq: func(r *http.Request) *http.Request { return r }, // no claims injected
|
||||
loader: &fakeAdminUserLoader{user: &models.User{ID: 1, Enabled: true, IsAdmin: true}},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "nil user loader returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
|
||||
loader: nil,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "loader returns error returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
|
||||
loader: &fakeAdminUserLoader{err: errors.New("db error")},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "loader returns nil user returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 1) },
|
||||
loader: &fakeAdminUserLoader{user: nil, err: nil},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "disabled admin user returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 2) },
|
||||
loader: &fakeAdminUserLoader{
|
||||
user: &models.User{ID: 2, Enabled: false, IsAdmin: true},
|
||||
},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled non-admin user returns 403",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 3) },
|
||||
loader: &fakeAdminUserLoader{
|
||||
user: &models.User{ID: 3, Enabled: true, IsAdmin: false},
|
||||
},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled admin reaches next handler and user is stashed in context",
|
||||
setupReq: func(r *http.Request) *http.Request { return adminClaims(r, 4) },
|
||||
loader: &fakeAdminUserLoader{
|
||||
user: &models.User{ID: 4, Enabled: true, IsAdmin: true},
|
||||
},
|
||||
wantStatus: http.StatusNoContent,
|
||||
checkUser: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
am := newTestAuthMiddleware(tc.loader)
|
||||
|
||||
var gotUser *models.User
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUser = GetUser(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/something", nil)
|
||||
req = tc.setupReq(req)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
am.RequireAdmin(next).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
|
||||
if tc.checkUser {
|
||||
if gotUser == nil {
|
||||
t.Fatal("GetUser returned nil inside next handler, want a non-nil user")
|
||||
}
|
||||
if gotUser.ID != tc.loader.(*fakeAdminUserLoader).user.ID {
|
||||
t.Fatalf("stashed user ID = %d, want %d", gotUser.ID, tc.loader.(*fakeAdminUserLoader).user.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
// DemoSettingsReader is the subset of ServerSettingsStore needed by DemoGuard.
|
||||
@@ -20,11 +22,12 @@ type DemoSettingsReader interface {
|
||||
// Blocked: API key management, downloads, history imports, subtitle downloads.
|
||||
type DemoGuard struct {
|
||||
settings DemoSettingsReader
|
||||
users auth.UserLoader // nil means no admin bypass
|
||||
}
|
||||
|
||||
// NewDemoGuard creates a new DemoGuard.
|
||||
func NewDemoGuard(settings DemoSettingsReader) *DemoGuard {
|
||||
return &DemoGuard{settings: settings}
|
||||
func NewDemoGuard(settings DemoSettingsReader, users auth.UserLoader) *DemoGuard {
|
||||
return &DemoGuard{settings: settings, users: users}
|
||||
}
|
||||
|
||||
// blockedRoute defines a method + path prefix combination that is blocked in demo mode.
|
||||
@@ -59,14 +62,8 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Admins bypass all demo restrictions.
|
||||
claims := GetClaims(r.Context())
|
||||
if claims != nil && claims.Role == "admin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this request matches a blocked route.
|
||||
blocked := false
|
||||
path := r.URL.Path
|
||||
for _, br := range demoBlockedRoutes {
|
||||
if !strings.HasPrefix(path, br.prefix) {
|
||||
@@ -74,16 +71,51 @@ func (dg *DemoGuard) Guard(next http.Handler) http.Handler {
|
||||
}
|
||||
for _, m := range br.methods {
|
||||
if r.Method == m {
|
||||
writeDemoBlocked(w)
|
||||
return
|
||||
blocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if blocked {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !blocked {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
// Admins bypass all demo restrictions. Admin status is checked against
|
||||
// the loaded user (group-derived), never against token contents.
|
||||
if dg.isAdmin(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
writeDemoBlocked(w)
|
||||
})
|
||||
}
|
||||
|
||||
// isAdmin reports whether the request's authenticated user currently holds
|
||||
// the admin permission. It first consults the user stashed in the context by
|
||||
// RequireAdmin and falls back to a fresh load.
|
||||
func (dg *DemoGuard) isAdmin(r *http.Request) bool {
|
||||
if user := GetUser(r.Context()); user != nil {
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
if dg.users == nil {
|
||||
return false
|
||||
}
|
||||
claims := GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
user, err := dg.users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
|
||||
func writeDemoBlocked(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
@@ -40,26 +40,28 @@ func (m *PermissionMiddleware) RequireMetadataCurationForItem(next http.Handler)
|
||||
writeUnauthorized(w, "Authentication required")
|
||||
return
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if m == nil || m.users == nil || m.libraries == nil {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
// Admins bypass the per-library restriction checks below.
|
||||
if user.IsAdmin {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writePermissionError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
}
|
||||
if !auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) {
|
||||
writeForbidden(w, "Metadata curation permission required")
|
||||
return
|
||||
|
||||
@@ -30,16 +30,16 @@ func (f fakeTargetLibraryResolver) ResolveMetadataTargetLibraryIDs(context.Conte
|
||||
return f.ids, f.err
|
||||
}
|
||||
|
||||
func requestWithItemID(role string) *http.Request {
|
||||
func requestWithItemID() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-1/refresh-metadata", nil)
|
||||
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: role, TokenType: auth.TokenTypeAccess})
|
||||
ctx := SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess})
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", "item-1")
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role string) int {
|
||||
func runMetadataCurationMiddleware(user *models.User, libraryIDs []int) int {
|
||||
mw := NewPermissionMiddleware(
|
||||
fakePermissionUserLoader{user: user},
|
||||
fakeTargetLibraryResolver{ids: libraryIDs},
|
||||
@@ -48,52 +48,52 @@ func runMetadataCurationMiddleware(user *models.User, libraryIDs []int, role str
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
next.ServeHTTP(rec, requestWithItemID(role))
|
||||
next.ServeHTTP(rec, requestWithItemID())
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsAdmin(t *testing.T) {
|
||||
code := runMetadataCurationMiddleware(nil, nil, "admin")
|
||||
code := runMetadataCurationMiddleware(&models.User{ID: 7, Enabled: true, IsAdmin: true}, nil)
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_RejectsUserWithoutPermission(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
|
||||
code := runMetadataCurationMiddleware(user, []int{1}, "user")
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: nil}
|
||||
code := runMetadataCurationMiddleware(user, []int{1})
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsUnrestrictedCurator(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2})
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_AllowsWhenAllTargetLibrariesAreAllowed(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 3}, "user")
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1, 2, 3}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 3})
|
||||
if code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_RejectsWhenAnyTargetLibraryIsOutsideAccess(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2}, "user")
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: []int{1}, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, []int{1, 2})
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMetadataCurationForItem_NotFoundWhenTargetHasNoLibraries(t *testing.T) {
|
||||
user := &models.User{ID: 7, Role: "user", Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, nil, "user")
|
||||
user := &models.User{ID: 7, Enabled: true, LibraryIDs: nil, Permissions: []string{"metadata_curation"}}
|
||||
code := runMetadataCurationMiddleware(user, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ func (m *ViewerAccessMiddleware) RequireViewerAccess(next http.Handler) http.Han
|
||||
scope, err := m.resolver.Resolve(r.Context(), input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, access.ErrUserDisabled):
|
||||
// Disabling a user also revokes their sessions, so RequireAuth
|
||||
// normally rejects first; this covers any session that is
|
||||
// still live (e.g. the account was disabled out of band).
|
||||
writeUnauthorized(w, "User account is disabled")
|
||||
return
|
||||
case errors.Is(err, access.ErrProfileUnverified):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
+51
-21
@@ -277,7 +277,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
deviceLoginService = auth.NewDeviceLoginService(deps.DB, userRepo, jwtService, sessionRepo)
|
||||
authHandler = handlers.NewAuthHandler(authService, jwtService, deviceLoginService)
|
||||
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo)
|
||||
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo, userRepo)
|
||||
profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0)
|
||||
if deps.UserStoreProvider != nil {
|
||||
viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService)
|
||||
@@ -306,7 +306,11 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Build demo guard middleware if server settings are available.
|
||||
var demoGuard *apimw.DemoGuard
|
||||
if settingsRepo != nil {
|
||||
demoGuard = apimw.NewDemoGuard(settingsRepo)
|
||||
var demoUsers auth.UserLoader
|
||||
if userRepo != nil {
|
||||
demoUsers = userRepo
|
||||
}
|
||||
demoGuard = apimw.NewDemoGuard(settingsRepo, demoUsers)
|
||||
}
|
||||
|
||||
// Build library handler if folder repo is available.
|
||||
@@ -479,6 +483,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
|
||||
}
|
||||
requestHandler = handlers.NewRequestsHandler(requestSvc)
|
||||
if userRepo != nil {
|
||||
requestHandler.Users = userRepo
|
||||
}
|
||||
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && deps.PluginService != nil {
|
||||
@@ -739,8 +746,12 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
|
||||
// Build admin handler if we have a user repo.
|
||||
var adminHandler *handlers.AdminHandler
|
||||
var adminGroupsHandler *handlers.AdminGroupsHandler
|
||||
var catalogSeedHandler *handlers.CatalogSeedHandler
|
||||
var adminJobsHandler *handlers.AdminJobsHandler
|
||||
if deps.DB != nil {
|
||||
adminGroupsHandler = handlers.NewAdminGroupsHandler(auth.NewGroupRepository(deps.DB))
|
||||
}
|
||||
if userRepo != nil {
|
||||
adminHandler = handlers.NewAdminHandler(userRepo, deps.DB, deps.UserStoreProvider)
|
||||
adminHandler.SessionsLoader = playbackSessionsLoader
|
||||
@@ -774,6 +785,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
adminJobsHandler = handlers.NewAdminJobsHandler(jobRepo, privateStore)
|
||||
adminJobsHandler.CancelRegistry = deps.AdminJobCancelRegistry
|
||||
adminJobsHandler.RealtimeHub = deps.RealtimeHub
|
||||
if userRepo != nil {
|
||||
adminJobsHandler.Users = userRepo
|
||||
}
|
||||
if adminHandler != nil && deps.FolderRepo != nil && deps.FileRepo != nil && itemRepo != nil && episodeRepo != nil {
|
||||
adminHandler.JobRepo = jobRepo
|
||||
adminHandler.ItemRefreshResolver = adminjob.NewItemRefreshResolver(
|
||||
@@ -903,6 +917,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
|
||||
mediaResolver := &pgSubtitleMediaResolver{pool: deps.DB}
|
||||
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
|
||||
if userRepo != nil {
|
||||
subtitleSearchHandler.Users = userRepo
|
||||
}
|
||||
}
|
||||
|
||||
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
|
||||
@@ -977,6 +994,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
aiService.Recover()
|
||||
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
|
||||
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
|
||||
subtitleAIHandler.Users = userRepo
|
||||
}
|
||||
|
||||
// Metadata AI translation (descriptions into the localization tables).
|
||||
@@ -1313,7 +1331,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Use(authMiddleware.RequireAuth)
|
||||
r.Get("/theme/catalog", themeHandler.HandleCatalog)
|
||||
r.Get("/theme/download", themeHandler.HandleDownload)
|
||||
r.With(apimw.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
|
||||
r.With(authMiddleware.RequireAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1340,7 +1358,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
authenticated, admin := resolveOptionalPluginAccess(r, jwtService, sessionRepo)
|
||||
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, apiKeyRepo, userRepo)
|
||||
deps.PluginHTTPProxy.ServeAsset(w, r.WithContext(plugins.WithPluginAccess(r.Context(), authenticated, admin)), installationID, assetPath)
|
||||
})
|
||||
}
|
||||
@@ -1471,6 +1489,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
deps.ScanRegistry,
|
||||
deps.LibraryScanQueue,
|
||||
historyImportSvc,
|
||||
userRepo,
|
||||
)
|
||||
r.Get("/events/ws", eventsHandler.HandleWebSocket)
|
||||
}
|
||||
@@ -1493,7 +1512,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Library management routes (admin-only).
|
||||
if libraryHandler != nil {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.RequireAdmin)
|
||||
r.Use(authMiddleware.RequireAdmin)
|
||||
|
||||
r.Route("/libraries", func(r chi.Router) {
|
||||
r.Get("/", libraryHandler.HandleListLibraries)
|
||||
@@ -2005,7 +2024,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Admin routes.
|
||||
if adminHandler != nil {
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
metadataItemAccess := apimw.RequireAdmin
|
||||
metadataItemAccess := authMiddleware.RequireAdmin
|
||||
if permissionMiddleware != nil {
|
||||
metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem
|
||||
}
|
||||
@@ -2032,7 +2051,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.RequireAdmin)
|
||||
r.Use(authMiddleware.RequireAdmin)
|
||||
|
||||
r.Get("/users", adminHandler.HandleListUsers)
|
||||
r.Post("/users", adminHandler.HandleCreateUser)
|
||||
@@ -2054,6 +2073,17 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/devices", adminHandler.HandleListDevices)
|
||||
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
|
||||
|
||||
if adminGroupsHandler != nil {
|
||||
r.Get("/groups", adminGroupsHandler.HandleList)
|
||||
r.Post("/groups", adminGroupsHandler.HandleCreate)
|
||||
r.Get("/groups/{id}", adminGroupsHandler.HandleGet)
|
||||
r.Patch("/groups/{id}", adminGroupsHandler.HandleUpdate)
|
||||
r.Delete("/groups/{id}", adminGroupsHandler.HandleDelete)
|
||||
r.Get("/groups/{id}/members", adminGroupsHandler.HandleListMembers)
|
||||
r.Put("/groups/{id}/members/{userID}", adminGroupsHandler.HandleAddMember)
|
||||
r.Delete("/groups/{id}/members/{userID}", adminGroupsHandler.HandleRemoveMember)
|
||||
}
|
||||
|
||||
r.Get("/sessions", adminHandler.HandleListSessions)
|
||||
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
|
||||
r.Get("/unmatched", adminHandler.HandleListUnmatched)
|
||||
@@ -2453,18 +2483,11 @@ func (r *pgSubtitleMediaResolver) GetMediaFileWithMetadata(ctx context.Context,
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func resolveOptionalPluginAccess(
|
||||
r *http.Request,
|
||||
jwtService *auth.JWTService,
|
||||
sessionRepo *auth.SessionRepository,
|
||||
) (bool, bool) {
|
||||
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, nil, nil)
|
||||
return authenticated, admin
|
||||
}
|
||||
|
||||
// resolveOptionalPluginAccessUser is like resolveOptionalPluginAccess but also
|
||||
// returns the authenticated user's ID, and accepts API-key bearer tokens
|
||||
// (sa_*) when apiKeyRepo + userRepo are provided.
|
||||
// resolveOptionalPluginAccessUser resolves the optionally authenticated user
|
||||
// for plugin HTTP routes and assets. It accepts API-key bearer tokens (sa_*)
|
||||
// when apiKeyRepo + userRepo are provided. Admin status is always derived from
|
||||
// the freshly loaded user, never from token contents; when userRepo is nil the
|
||||
// caller is treated as non-admin.
|
||||
func resolveOptionalPluginAccessUser(
|
||||
r *http.Request,
|
||||
jwtService *auth.JWTService,
|
||||
@@ -2507,7 +2530,7 @@ func resolveOptionalPluginAccessUser(
|
||||
if err != nil || !user.Enabled {
|
||||
return false, false, 0
|
||||
}
|
||||
return true, user.Role == "admin", user.ID
|
||||
return true, user.IsAdmin, user.ID
|
||||
}
|
||||
|
||||
claims, err := jwtService.ValidateToken(token)
|
||||
@@ -2518,7 +2541,14 @@ func resolveOptionalPluginAccessUser(
|
||||
if err != nil || !valid {
|
||||
return false, false, 0
|
||||
}
|
||||
return true, claims.Role == "admin", claims.UserID
|
||||
if userRepo == nil {
|
||||
return true, false, claims.UserID
|
||||
}
|
||||
user, err := userRepo.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
return false, false, 0
|
||||
}
|
||||
return true, user.IsAdmin, user.ID
|
||||
}
|
||||
|
||||
// NewTMDBCollectionFetcher creates a TMDBCollectionFetcher from an API key.
|
||||
|
||||
@@ -2,18 +2,30 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// DefaultGroupSlugsSettingKey is the server setting holding the JSON array of
|
||||
// group slugs assigned to newly provisioned accounts.
|
||||
const DefaultGroupSlugsSettingKey = "users.default_group_slugs"
|
||||
|
||||
type AccountUserRepository interface {
|
||||
Create(ctx context.Context, input models.CreateUserInput) (*models.User, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
// GroupResolver resolves group slugs to groups. Implemented by GroupRepository.
|
||||
type GroupResolver interface {
|
||||
GetBySlug(ctx context.Context, slug string) (*models.Group, error)
|
||||
}
|
||||
|
||||
type DefaultProfileOptions struct {
|
||||
Enabled bool
|
||||
Name string
|
||||
@@ -27,15 +39,21 @@ type CreateAccountInput struct {
|
||||
type AccountProvisioner struct {
|
||||
users AccountUserRepository
|
||||
storeProvider userstore.UserStoreProvider
|
||||
settings SettingsGetter
|
||||
groups GroupResolver
|
||||
}
|
||||
|
||||
func NewAccountProvisioner(
|
||||
users AccountUserRepository,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
settings SettingsGetter,
|
||||
groups GroupResolver,
|
||||
) *AccountProvisioner {
|
||||
return &AccountProvisioner{
|
||||
users: users,
|
||||
storeProvider: storeProvider,
|
||||
settings: settings,
|
||||
groups: groups,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +61,14 @@ func (p *AccountProvisioner) CreateAccount(
|
||||
ctx context.Context,
|
||||
input CreateAccountInput,
|
||||
) (*models.User, error) {
|
||||
if input.User.GroupIDs == nil && p.groups != nil {
|
||||
ids, err := p.defaultGroupIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input.User.GroupIDs = ids
|
||||
}
|
||||
|
||||
user, err := p.users.Create(ctx, input.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,6 +92,48 @@ func (p *AccountProvisioner) CreateAccount(
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// defaultGroupIDs resolves the configured default group slugs (falling back to
|
||||
// the built-in users group) to group IDs. Unknown slugs are skipped so a stale
|
||||
// setting never blocks signups; if no configured slug resolves, the built-in
|
||||
// users group is used so accounts never start with an empty policy.
|
||||
func (p *AccountProvisioner) defaultGroupIDs(ctx context.Context) ([]int, error) {
|
||||
slugs := []string{models.GroupSlugUsers}
|
||||
if p.settings != nil {
|
||||
raw, err := p.settings.Get(ctx, DefaultGroupSlugsSettingKey)
|
||||
if err != nil {
|
||||
slog.Warn("reading default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", err)
|
||||
} else if strings.TrimSpace(raw) != "" {
|
||||
var configured []string
|
||||
if jsonErr := json.Unmarshal([]byte(raw), &configured); jsonErr != nil {
|
||||
slog.Warn("parsing default group slugs setting failed", "key", DefaultGroupSlugsSettingKey, "error", jsonErr)
|
||||
} else if len(configured) > 0 {
|
||||
slugs = configured
|
||||
}
|
||||
}
|
||||
}
|
||||
ids := make([]int, 0, len(slugs))
|
||||
for _, slug := range slugs {
|
||||
group, err := p.groups.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGroupNotFound) {
|
||||
// A stale setting must not block signups.
|
||||
slog.Warn("skipping unknown default group slug", "slug", slug)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("resolving default group %q: %w", slug, err)
|
||||
}
|
||||
ids = append(ids, group.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
group, err := p.groups.GetBySlug(ctx, models.GroupSlugUsers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving fallback group %q: %w", models.GroupSlugUsers, err)
|
||||
}
|
||||
ids = append(ids, group.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (p *AccountProvisioner) createDefaultProfile(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -58,6 +59,8 @@ func TestAccountProvisionerCreateAccount_SkipsProfileByDefault(t *testing.T) {
|
||||
},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
user, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
@@ -90,6 +93,8 @@ func TestAccountProvisionerCreateAccount_CreatesDefaultProfile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
@@ -125,6 +130,8 @@ func TestAccountProvisionerCreateAccount_UsesExplicitProfileName(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
@@ -158,6 +165,8 @@ func TestAccountProvisionerCreateAccount_BlankExplicitProfileNameFallsBackToUser
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
@@ -194,6 +203,8 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
@@ -209,3 +220,173 @@ func TestAccountProvisionerCreateAccount_DeletesUserWhenProfileCreationFails(t *
|
||||
t.Fatalf("deletedUserID = %d, want 9", deletedUserID)
|
||||
}
|
||||
}
|
||||
|
||||
type stubGroupResolver struct {
|
||||
groups map[string]*models.Group
|
||||
}
|
||||
|
||||
func (s stubGroupResolver) GetBySlug(_ context.Context, slug string) (*models.Group, error) {
|
||||
if group, ok := s.groups[slug]; ok {
|
||||
return group, nil
|
||||
}
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
|
||||
type stubSettings map[string]string
|
||||
|
||||
func (s stubSettings) Get(_ context.Context, key string) (string, error) {
|
||||
return s[key], nil
|
||||
}
|
||||
|
||||
func TestAccountProvisionerCreateAccount_NilGroupIDsResolvesConfiguredDefaults(t *testing.T) {
|
||||
var gotInput models.CreateUserInput
|
||||
provisioner := NewAccountProvisioner(
|
||||
stubAccountUsers{
|
||||
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
|
||||
gotInput = input
|
||||
return &models.User{ID: 1, Username: "alex"}, nil
|
||||
},
|
||||
},
|
||||
nil,
|
||||
stubSettings{DefaultGroupSlugsSettingKey: `["family","kids"]`},
|
||||
stubGroupResolver{groups: map[string]*models.Group{
|
||||
"family": {ID: 11, Slug: "family"},
|
||||
"kids": {ID: 12, Slug: "kids"},
|
||||
}},
|
||||
)
|
||||
|
||||
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
User: models.CreateUserInput{Username: "alex"},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateAccount returned error: %v", err)
|
||||
}
|
||||
want := []int{11, 12}
|
||||
if !reflect.DeepEqual(gotInput.GroupIDs, want) {
|
||||
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProvisionerCreateAccount_FallsBackToBuiltInUsersGroup(t *testing.T) {
|
||||
var gotInput models.CreateUserInput
|
||||
provisioner := NewAccountProvisioner(
|
||||
stubAccountUsers{
|
||||
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
|
||||
gotInput = input
|
||||
return &models.User{ID: 1, Username: "alex"}, nil
|
||||
},
|
||||
},
|
||||
nil,
|
||||
nil, // no settings configured
|
||||
stubGroupResolver{groups: map[string]*models.Group{
|
||||
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
|
||||
}},
|
||||
)
|
||||
|
||||
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
User: models.CreateUserInput{Username: "alex"},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateAccount returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
|
||||
t.Fatalf("GroupIDs = %#v, want [2]", gotInput.GroupIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProvisionerCreateAccount_ExplicitGroupIDsPassThrough(t *testing.T) {
|
||||
var gotInput models.CreateUserInput
|
||||
provisioner := NewAccountProvisioner(
|
||||
stubAccountUsers{
|
||||
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
|
||||
gotInput = input
|
||||
return &models.User{ID: 1, Username: "alex"}, nil
|
||||
},
|
||||
},
|
||||
nil,
|
||||
stubSettings{DefaultGroupSlugsSettingKey: `["family"]`},
|
||||
stubGroupResolver{groups: map[string]*models.Group{
|
||||
"family": {ID: 11, Slug: "family"},
|
||||
}},
|
||||
)
|
||||
|
||||
explicit := []int{99}
|
||||
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
User: models.CreateUserInput{Username: "alex", GroupIDs: explicit},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateAccount returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput.GroupIDs, explicit) {
|
||||
t.Fatalf("GroupIDs = %#v, want %#v", gotInput.GroupIDs, explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProvisionerCreateAccount_SkipsUnknownDefaultSlugs(t *testing.T) {
|
||||
var gotInput models.CreateUserInput
|
||||
provisioner := NewAccountProvisioner(
|
||||
stubAccountUsers{
|
||||
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
|
||||
gotInput = input
|
||||
return &models.User{ID: 1, Username: "alex"}, nil
|
||||
},
|
||||
},
|
||||
nil,
|
||||
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","family"]`},
|
||||
stubGroupResolver{groups: map[string]*models.Group{
|
||||
"family": {ID: 11, Slug: "family"},
|
||||
}},
|
||||
)
|
||||
|
||||
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
User: models.CreateUserInput{Username: "alex"},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateAccount returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput.GroupIDs, []int{11}) {
|
||||
t.Fatalf("GroupIDs = %#v, want [11]", gotInput.GroupIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProvisionerCreateAccount_AllUnknownSlugsFallBackToUsersGroup(t *testing.T) {
|
||||
var gotInput models.CreateUserInput
|
||||
provisioner := NewAccountProvisioner(
|
||||
stubAccountUsers{
|
||||
createFn: func(_ context.Context, input models.CreateUserInput) (*models.User, error) {
|
||||
gotInput = input
|
||||
return &models.User{ID: 1, Username: "alex"}, nil
|
||||
},
|
||||
},
|
||||
nil,
|
||||
stubSettings{DefaultGroupSlugsSettingKey: `["ghost","phantom"]`},
|
||||
stubGroupResolver{groups: map[string]*models.Group{
|
||||
models.GroupSlugUsers: {ID: 2, Slug: models.GroupSlugUsers},
|
||||
}},
|
||||
)
|
||||
|
||||
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
User: models.CreateUserInput{Username: "alex"},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateAccount returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput.GroupIDs, []int{2}) {
|
||||
t.Fatalf("GroupIDs = %#v, want [2] (built-in users fallback)", gotInput.GroupIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProvisionerCreateAccount_FallbackGroupMissingReturnsError(t *testing.T) {
|
||||
provisioner := NewAccountProvisioner(
|
||||
stubAccountUsers{
|
||||
createFn: func(context.Context, models.CreateUserInput) (*models.User, error) {
|
||||
t.Fatal("Create should not be called when no default group resolves")
|
||||
return nil, nil
|
||||
},
|
||||
},
|
||||
nil,
|
||||
stubSettings{DefaultGroupSlugsSettingKey: `["ghost"]`},
|
||||
stubGroupResolver{groups: map[string]*models.Group{}},
|
||||
)
|
||||
|
||||
if _, err := provisioner.CreateAccount(context.Background(), CreateAccountInput{
|
||||
User: models.CreateUserInput{Username: "alex"},
|
||||
}); err == nil {
|
||||
t.Fatal("CreateAccount returned nil error, want fallback resolution failure")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +389,6 @@ func (s *DeviceLoginService) Poll(ctx context.Context, deviceCode string) (*Devi
|
||||
|
||||
pair, err := s.generateTokenPair(Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// ApplyEffectivePolicy populates u's policy fields with the most-permissive
|
||||
// union of the given groups. Groups are the only source of policy:
|
||||
//
|
||||
// - permissions: set union; "admin" implies everything
|
||||
// - libraries: union; any nil (= all) or admin makes it unrestricted
|
||||
// - limits: 0 means unlimited at enforcement, so any group with 0
|
||||
// wins; otherwise max across groups
|
||||
// - quality: most permissive; "" (unrestricted) wins
|
||||
// - booleans: OR
|
||||
//
|
||||
// Zero groups yields the empty policy: no permissions, an empty non-nil
|
||||
// library list, downloads denied. Lockout is enforced by the empty library
|
||||
// list (access to no content), which makes the stream/transcode limits
|
||||
// irrelevant; they stay at 0. MaxProfiles is the exception: 0 means
|
||||
// unlimited at the profile-creation check, which is independent of library
|
||||
// access, so it is floored at 1 to keep zero-group accounts from creating
|
||||
// profiles freely. The quality ceiling is moot with no accessible content
|
||||
// and is left at the lowest concrete value.
|
||||
func ApplyEffectivePolicy(u *models.User, groups []models.Group) {
|
||||
u.GroupIDs = make([]int, 0, len(groups))
|
||||
u.IsAdmin = false
|
||||
u.Permissions = []string{}
|
||||
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles = 0, 0, 0
|
||||
u.DownloadAllowed, u.DownloadTranscodeAllowed = false, false
|
||||
u.MaxPlaybackQuality = "480p"
|
||||
u.LibraryIDs = []int{}
|
||||
|
||||
if len(groups) == 0 {
|
||||
u.MaxProfiles = 1
|
||||
return
|
||||
}
|
||||
|
||||
permSet := make(map[string]struct{})
|
||||
libSet := make(map[int]struct{})
|
||||
allLibraries := false
|
||||
|
||||
for i, g := range groups {
|
||||
u.GroupIDs = append(u.GroupIDs, g.ID)
|
||||
for _, p := range g.Permissions {
|
||||
permSet[p] = struct{}{}
|
||||
}
|
||||
if g.LibraryIDs == nil {
|
||||
allLibraries = true
|
||||
} else {
|
||||
for _, id := range g.LibraryIDs {
|
||||
libSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if i == 0 {
|
||||
u.MaxStreams = g.MaxStreams
|
||||
u.MaxTranscodes = g.MaxTranscodes
|
||||
u.MaxProfiles = g.MaxProfiles
|
||||
u.MaxPlaybackQuality = g.MaxPlaybackQuality
|
||||
} else {
|
||||
u.MaxStreams = mergeLimit(u.MaxStreams, g.MaxStreams)
|
||||
u.MaxTranscodes = mergeLimit(u.MaxTranscodes, g.MaxTranscodes)
|
||||
u.MaxProfiles = mergeLimit(u.MaxProfiles, g.MaxProfiles)
|
||||
u.MaxPlaybackQuality = access.MaxQuality(u.MaxPlaybackQuality, g.MaxPlaybackQuality)
|
||||
}
|
||||
u.DownloadAllowed = u.DownloadAllowed || g.DownloadAllowed
|
||||
u.DownloadTranscodeAllowed = u.DownloadTranscodeAllowed || g.DownloadTranscodeAllowed
|
||||
}
|
||||
|
||||
// Admin implies unrestricted libraries, quality, and downloads. Stream,
|
||||
// transcode, and profile limits intentionally keep the merged group
|
||||
// values: before groups, admins were subject to their per-user limits
|
||||
// (default 6 streams) with no bypass, and the playback session manager
|
||||
// still enforces whatever it is handed.
|
||||
if _, ok := permSet[string(PermissionAdmin)]; ok {
|
||||
u.IsAdmin = true
|
||||
allLibraries = true
|
||||
u.MaxPlaybackQuality = ""
|
||||
u.DownloadAllowed = true
|
||||
}
|
||||
|
||||
u.Permissions = make([]string, 0, len(permSet))
|
||||
for p := range permSet {
|
||||
u.Permissions = append(u.Permissions, p)
|
||||
}
|
||||
sort.Strings(u.Permissions)
|
||||
|
||||
if allLibraries {
|
||||
u.LibraryIDs = nil
|
||||
} else {
|
||||
u.LibraryIDs = make([]int, 0, len(libSet))
|
||||
for id := range libSet {
|
||||
u.LibraryIDs = append(u.LibraryIDs, id)
|
||||
}
|
||||
sort.Ints(u.LibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeLimit folds group limits where 0 means unlimited at enforcement
|
||||
// (max_streams, max_transcodes, max_profiles), so any unlimited group wins
|
||||
// the most-permissive union; otherwise the higher cap wins.
|
||||
func mergeLimit(current, next int) int {
|
||||
if current == 0 || next == 0 {
|
||||
return 0
|
||||
}
|
||||
if next > current {
|
||||
return next
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestApplyEffectivePolicyZeroGroups(t *testing.T) {
|
||||
u := &models.User{ID: 1, Enabled: true}
|
||||
ApplyEffectivePolicy(u, nil)
|
||||
|
||||
if u.IsAdmin {
|
||||
t.Error("zero groups must not grant admin")
|
||||
}
|
||||
if len(u.Permissions) != 0 {
|
||||
t.Errorf("permissions = %v, want empty", u.Permissions)
|
||||
}
|
||||
if u.LibraryIDs == nil || len(u.LibraryIDs) != 0 {
|
||||
t.Errorf("library ids = %v, want empty non-nil (access to none)", u.LibraryIDs)
|
||||
}
|
||||
// Lockout comes from the empty library list; stream/transcode limits are
|
||||
// irrelevant and stay 0. MaxProfiles is floored at 1 because 0 means
|
||||
// unlimited at the profile-creation check.
|
||||
if u.MaxStreams != 0 || u.MaxTranscodes != 0 {
|
||||
t.Errorf("stream limits = %d/%d, want 0/0", u.MaxStreams, u.MaxTranscodes)
|
||||
}
|
||||
if u.MaxProfiles != 1 {
|
||||
t.Errorf("max profiles = %d, want 1 (0 would mean unlimited)", u.MaxProfiles)
|
||||
}
|
||||
if u.DownloadAllowed || u.DownloadTranscodeAllowed {
|
||||
t.Error("downloads must be denied for zero groups")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEffectivePolicyUnion(t *testing.T) {
|
||||
u := &models.User{ID: 1, Enabled: true}
|
||||
ApplyEffectivePolicy(u, []models.Group{
|
||||
{ID: 10, Permissions: []string{"marker_edit"}, LibraryIDs: []int{1, 2},
|
||||
MaxStreams: 2, MaxTranscodes: 1, MaxProfiles: 3,
|
||||
MaxPlaybackQuality: "1080p", DownloadAllowed: false},
|
||||
{ID: 20, Permissions: []string{"metadata_curation"}, LibraryIDs: []int{2, 3},
|
||||
MaxStreams: 4, MaxTranscodes: 2, MaxProfiles: 1,
|
||||
MaxPlaybackQuality: "2160p", DownloadAllowed: true},
|
||||
})
|
||||
|
||||
wantPerms := []string{"marker_edit", "metadata_curation"}
|
||||
sort.Strings(u.Permissions)
|
||||
if !reflect.DeepEqual(u.Permissions, wantPerms) {
|
||||
t.Errorf("permissions = %v, want %v", u.Permissions, wantPerms)
|
||||
}
|
||||
wantLibs := []int{1, 2, 3}
|
||||
sort.Ints(u.LibraryIDs)
|
||||
if !reflect.DeepEqual(u.LibraryIDs, wantLibs) {
|
||||
t.Errorf("libraries = %v, want %v", u.LibraryIDs, wantLibs)
|
||||
}
|
||||
if u.MaxStreams != 4 || u.MaxTranscodes != 2 || u.MaxProfiles != 3 {
|
||||
t.Errorf("limits = %d/%d/%d, want 4/2/3", u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
|
||||
}
|
||||
if u.MaxPlaybackQuality != "2160p" {
|
||||
t.Errorf("quality = %q, want 2160p", u.MaxPlaybackQuality)
|
||||
}
|
||||
if !u.DownloadAllowed {
|
||||
t.Error("downloads must be OR-ed")
|
||||
}
|
||||
wantGroups := []int{10, 20}
|
||||
sort.Ints(u.GroupIDs)
|
||||
if !reflect.DeepEqual(u.GroupIDs, wantGroups) {
|
||||
t.Errorf("group ids = %v, want %v", u.GroupIDs, wantGroups)
|
||||
}
|
||||
if u.IsAdmin {
|
||||
t.Error("no group granted admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEffectivePolicyUnlimitedLimitWins(t *testing.T) {
|
||||
// 0 means unlimited at enforcement for all three limits, so a group with
|
||||
// 0 must win the most-permissive union over any finite cap.
|
||||
u := &models.User{}
|
||||
ApplyEffectivePolicy(u, []models.Group{
|
||||
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
|
||||
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
|
||||
})
|
||||
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
|
||||
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins)",
|
||||
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
|
||||
}
|
||||
|
||||
// Order must not matter: unlimited in the first group sticks.
|
||||
u = &models.User{}
|
||||
ApplyEffectivePolicy(u, []models.Group{
|
||||
{ID: 2, MaxStreams: 0, MaxTranscodes: 0, MaxProfiles: 0},
|
||||
{ID: 1, MaxStreams: 5, MaxTranscodes: 5, MaxProfiles: 5},
|
||||
})
|
||||
if u.MaxStreams != 0 || u.MaxTranscodes != 0 || u.MaxProfiles != 0 {
|
||||
t.Errorf("limits = %d/%d/%d, want 0/0/0 (unlimited wins regardless of order)",
|
||||
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEffectivePolicyFiniteLimitsTakeMax(t *testing.T) {
|
||||
u := &models.User{}
|
||||
ApplyEffectivePolicy(u, []models.Group{
|
||||
{ID: 1, MaxStreams: 5, MaxTranscodes: 2, MaxProfiles: 4},
|
||||
{ID: 2, MaxStreams: 2, MaxTranscodes: 5, MaxProfiles: 1},
|
||||
})
|
||||
if u.MaxStreams != 5 || u.MaxTranscodes != 5 || u.MaxProfiles != 4 {
|
||||
t.Errorf("limits = %d/%d/%d, want 5/5/4 (max of finite caps)",
|
||||
u.MaxStreams, u.MaxTranscodes, u.MaxProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEffectivePolicyNilLibrariesMeansAll(t *testing.T) {
|
||||
u := &models.User{}
|
||||
ApplyEffectivePolicy(u, []models.Group{
|
||||
{ID: 1, LibraryIDs: []int{1}},
|
||||
{ID: 2, LibraryIDs: nil}, // all libraries
|
||||
})
|
||||
if u.LibraryIDs != nil {
|
||||
t.Errorf("library ids = %v, want nil (unrestricted)", u.LibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEffectivePolicyAdminShortCircuit(t *testing.T) {
|
||||
u := &models.User{}
|
||||
ApplyEffectivePolicy(u, []models.Group{
|
||||
{ID: 1, Permissions: []string{"admin"}, LibraryIDs: []int{1},
|
||||
MaxStreams: 1, MaxPlaybackQuality: "480p"},
|
||||
})
|
||||
if !u.IsAdmin {
|
||||
t.Fatal("admin permission must set IsAdmin")
|
||||
}
|
||||
if u.LibraryIDs != nil {
|
||||
t.Error("admin must be library-unrestricted")
|
||||
}
|
||||
if u.MaxPlaybackQuality != "" {
|
||||
t.Error("admin must be quality-unrestricted")
|
||||
}
|
||||
// Admin does not bypass stream/transcode/profile limits (matching the
|
||||
// pre-group behavior where admins were subject to per-user limits); the
|
||||
// merged group values stand.
|
||||
if u.MaxStreams != 1 {
|
||||
t.Errorf("max streams = %d, want 1 (admin keeps merged group limits)", u.MaxStreams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEffectivePolicyQualityUnrestrictedWins(t *testing.T) {
|
||||
u := &models.User{}
|
||||
ApplyEffectivePolicy(u, []models.Group{
|
||||
{ID: 1, MaxPlaybackQuality: "1080p"},
|
||||
{ID: 2, MaxPlaybackQuality: ""}, // unrestricted
|
||||
})
|
||||
if u.MaxPlaybackQuality != "" {
|
||||
t.Errorf("quality = %q, want unrestricted", u.MaxPlaybackQuality)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// Sentinel errors for group operations.
|
||||
var (
|
||||
ErrGroupNotFound = errors.New("group not found")
|
||||
ErrBuiltInGroup = errors.New("built-in group cannot be deleted")
|
||||
ErrAdminPermRequired = errors.New("administrators group must keep the admin permission")
|
||||
ErrLastAdministrator = errors.New("cannot remove the last enabled administrator")
|
||||
ErrUnknownGroup = errors.New("unknown group")
|
||||
ErrUnknownUser = errors.New("unknown user")
|
||||
)
|
||||
|
||||
// GroupRepository provides CRUD and membership operations for groups.
|
||||
// All mutations bump access_policy_revision for affected users set-based so
|
||||
// the access resolver invalidates stale scopes.
|
||||
type GroupRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewGroupRepository(pool *pgxpool.Pool) *GroupRepository {
|
||||
return &GroupRepository{pool: pool}
|
||||
}
|
||||
|
||||
// groupColumns is table-qualified so it stays unambiguous in queries that
|
||||
// join user_groups (which also has a created_at column). Qualified names are
|
||||
// valid in plain SELECTs and in INSERT/UPDATE ... RETURNING alike.
|
||||
const groupColumns = `groups.id, groups.slug, groups.name, groups.description,
|
||||
groups.built_in, groups.permissions, groups.library_ids, groups.max_streams,
|
||||
groups.max_transcodes, groups.max_profiles, groups.max_playback_quality,
|
||||
groups.download_allowed, groups.download_transcode_allowed,
|
||||
groups.created_at, groups.updated_at`
|
||||
|
||||
func scanGroupFields(row pgx.Row, g *models.Group, extra ...any) error {
|
||||
dest := []any{
|
||||
&g.ID, &g.Slug, &g.Name, &g.Description, &g.BuiltIn, &g.Permissions,
|
||||
&g.LibraryIDs, &g.MaxStreams, &g.MaxTranscodes, &g.MaxProfiles,
|
||||
&g.MaxPlaybackQuality, &g.DownloadAllowed, &g.DownloadTranscodeAllowed,
|
||||
&g.CreatedAt, &g.UpdatedAt,
|
||||
}
|
||||
dest = append(dest, extra...)
|
||||
return row.Scan(dest...)
|
||||
}
|
||||
|
||||
func scanGroup(row pgx.Row) (*models.Group, error) {
|
||||
var g models.Group
|
||||
if err := scanGroupFields(row, &g); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("scanning group: %w", err)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
var slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// slugifyGroupName derives a stable slug from a display name.
|
||||
// Accented characters are decomposed via NFD and their combining marks are
|
||||
// stripped so that e.g. "ï" → "i" before the ASCII-only filter is applied.
|
||||
func slugifyGroupName(name string) string {
|
||||
// NFD-decompose so accented letters split into base + combining mark.
|
||||
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(name)))
|
||||
// Drop combining (diacritic) runes; keep everything else for the regex.
|
||||
var b strings.Builder
|
||||
for _, r := range decomposed {
|
||||
if unicode.Is(unicode.Mn, r) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
s := slugStripRe.ReplaceAllString(b.String(), "-")
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
|
||||
// GroupWithMemberCount pairs a group with its member count for list views.
|
||||
type GroupWithMemberCount struct {
|
||||
models.Group
|
||||
MemberCount int
|
||||
}
|
||||
|
||||
// List returns all groups with member counts, built-ins first.
|
||||
func (r *GroupRepository) List(ctx context.Context) ([]GroupWithMemberCount, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+groupColumns+`,
|
||||
(SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = groups.id) AS member_count
|
||||
FROM groups
|
||||
ORDER BY built_in DESC, name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GroupWithMemberCount
|
||||
for rows.Next() {
|
||||
var g GroupWithMemberCount
|
||||
if err := scanGroupFields(rows, &g.Group, &g.MemberCount); err != nil {
|
||||
return nil, fmt.Errorf("scanning group row: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *GroupRepository) GetByID(ctx context.Context, id int) (*models.Group, error) {
|
||||
return scanGroup(r.pool.QueryRow(ctx,
|
||||
`SELECT `+groupColumns+` FROM groups WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (r *GroupRepository) GetBySlug(ctx context.Context, slug string) (*models.Group, error) {
|
||||
return scanGroup(r.pool.QueryRow(ctx,
|
||||
`SELECT `+groupColumns+` FROM groups WHERE slug = $1`, slug))
|
||||
}
|
||||
|
||||
// GroupsForUser returns the groups a user belongs to.
|
||||
func (r *GroupRepository) GroupsForUser(ctx context.Context, userID int) ([]models.Group, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+groupColumns+`
|
||||
FROM groups
|
||||
JOIN user_groups ug ON ug.group_id = groups.id
|
||||
WHERE ug.user_id = $1
|
||||
ORDER BY groups.id`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading user groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []models.Group
|
||||
for rows.Next() {
|
||||
var g models.Group
|
||||
if err := scanGroupFields(rows, &g); err != nil {
|
||||
return nil, fmt.Errorf("scanning group row: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GroupsForUsers returns group memberships for many users in one query.
|
||||
func (r *GroupRepository) GroupsForUsers(ctx context.Context, userIDs []int) (map[int][]models.Group, error) {
|
||||
out := make(map[int][]models.Group, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+groupColumns+`, ug.user_id
|
||||
FROM groups
|
||||
JOIN user_groups ug ON ug.group_id = groups.id
|
||||
WHERE ug.user_id = ANY($1::int[])
|
||||
ORDER BY groups.id`, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading users' groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var userID int
|
||||
var g models.Group
|
||||
if err := scanGroupFields(rows, &g, &userID); err != nil {
|
||||
return nil, fmt.Errorf("scanning user group row: %w", err)
|
||||
}
|
||||
out[userID] = append(out[userID], g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Create inserts a new (non-built-in) group.
|
||||
func (r *GroupRepository) Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error) {
|
||||
permissions, err := NormalizePermissions(input.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slug := slugifyGroupName(input.Name)
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("group name must contain at least one alphanumeric character")
|
||||
}
|
||||
|
||||
cols := []string{"slug", "name", "description", "permissions", "library_ids"}
|
||||
args := []any{slug, strings.TrimSpace(input.Name), input.Description, permissions, input.LibraryIDs}
|
||||
type optionalCol struct {
|
||||
col string
|
||||
val any
|
||||
set bool
|
||||
}
|
||||
optional := []optionalCol{
|
||||
{"max_streams", derefAny(input.MaxStreams), input.MaxStreams != nil},
|
||||
{"max_transcodes", derefAny(input.MaxTranscodes), input.MaxTranscodes != nil},
|
||||
{"max_profiles", derefAny(input.MaxProfiles), input.MaxProfiles != nil},
|
||||
{"max_playback_quality", derefAny(input.MaxPlaybackQuality), input.MaxPlaybackQuality != nil},
|
||||
{"download_allowed", derefAny(input.DownloadAllowed), input.DownloadAllowed != nil},
|
||||
{"download_transcode_allowed", derefAny(input.DownloadTranscodeAllowed), input.DownloadTranscodeAllowed != nil},
|
||||
}
|
||||
for _, o := range optional {
|
||||
if o.set {
|
||||
cols = append(cols, o.col)
|
||||
args = append(args, o.val)
|
||||
}
|
||||
}
|
||||
placeholders := make([]string, len(args))
|
||||
for i := range args {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
query := fmt.Sprintf("INSERT INTO groups (%s) VALUES (%s) RETURNING %s",
|
||||
strings.Join(cols, ", "), strings.Join(placeholders, ", "), groupColumns)
|
||||
|
||||
group, err := scanGroup(r.pool.QueryRow(ctx, query, args...))
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
|
||||
}
|
||||
return nil, fmt.Errorf("creating group: %w", err)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func derefAny[T any](p *T) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Update modifies a group and bumps all members' access_policy_revision when
|
||||
// a policy field changes. The administrators group cannot lose the admin
|
||||
// permission.
|
||||
func (r *GroupRepository) Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("beginning group update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// Lock the group row: serializes concurrent policy/membership mutations.
|
||||
current, err := scanGroup(tx.QueryRow(ctx,
|
||||
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var normalizedPerms []string
|
||||
if input.Permissions != nil {
|
||||
var err error
|
||||
normalizedPerms, err = NormalizePermissions(*input.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.Slug == models.GroupSlugAdministrators {
|
||||
hasAdmin := false
|
||||
for _, p := range normalizedPerms {
|
||||
if p == string(PermissionAdmin) {
|
||||
hasAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAdmin {
|
||||
return nil, ErrAdminPermRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClauses := []string{}
|
||||
policyChanged := false
|
||||
args := []any{}
|
||||
idx := 1
|
||||
add := func(col string, val any, policy bool) {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
if policy {
|
||||
policyChanged = true
|
||||
}
|
||||
}
|
||||
if input.Name != nil {
|
||||
add("name", strings.TrimSpace(*input.Name), false)
|
||||
}
|
||||
if input.Description != nil {
|
||||
add("description", *input.Description, false)
|
||||
}
|
||||
if input.Permissions != nil {
|
||||
add("permissions", normalizedPerms, true)
|
||||
}
|
||||
if input.LibraryIDs != nil {
|
||||
add("library_ids", *input.LibraryIDs, true)
|
||||
}
|
||||
if input.MaxStreams != nil {
|
||||
add("max_streams", *input.MaxStreams, true)
|
||||
}
|
||||
if input.MaxTranscodes != nil {
|
||||
add("max_transcodes", *input.MaxTranscodes, true)
|
||||
}
|
||||
if input.MaxProfiles != nil {
|
||||
add("max_profiles", *input.MaxProfiles, true)
|
||||
}
|
||||
if input.MaxPlaybackQuality != nil {
|
||||
add("max_playback_quality", *input.MaxPlaybackQuality, true)
|
||||
}
|
||||
if input.DownloadAllowed != nil {
|
||||
add("download_allowed", *input.DownloadAllowed, true)
|
||||
}
|
||||
if input.DownloadTranscodeAllowed != nil {
|
||||
add("download_transcode_allowed", *input.DownloadTranscodeAllowed, true)
|
||||
}
|
||||
if len(setClauses) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE groups SET %s WHERE id = $%d RETURNING %s",
|
||||
strings.Join(setClauses, ", "), idx, groupColumns)
|
||||
updated, err := scanGroup(tx.QueryRow(ctx, query, args...))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updating group: %w", err)
|
||||
}
|
||||
|
||||
if policyChanged {
|
||||
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("committing group update: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// bumpGroupMemberRevisions invalidates all members' resolved scopes in one
|
||||
// set-based statement (never loop per user — groups can have thousands of
|
||||
// members).
|
||||
func bumpGroupMemberRevisions(ctx context.Context, tx pgx.Tx, groupID int) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE users u
|
||||
SET access_policy_revision = access_policy_revision + 1
|
||||
FROM user_groups ug
|
||||
WHERE ug.user_id = u.id AND ug.group_id = $1`, groupID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bumping member policy revisions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a non-built-in group. Member revisions are bumped before the
|
||||
// memberships cascade away.
|
||||
func (r *GroupRepository) Delete(ctx context.Context, id int) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning group delete: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
group, err := scanGroup(tx.QueryRow(ctx,
|
||||
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group.BuiltIn {
|
||||
return ErrBuiltInGroup
|
||||
}
|
||||
if err := bumpGroupMemberRevisions(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM groups WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("deleting group: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// MemberCount returns the number of users in a group.
|
||||
func (r *GroupRepository) MemberCount(ctx context.Context, groupID int) (int, error) {
|
||||
var count int
|
||||
if err := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM user_groups WHERE group_id = $1`, groupID).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("counting group members: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GroupMember is one row of a paginated member listing.
|
||||
type GroupMember struct {
|
||||
UserID int
|
||||
Username string
|
||||
Email string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// ListMembers returns one page of a group's members plus the total count.
|
||||
func (r *GroupRepository) ListMembers(ctx context.Context, groupID, offset, limit int) ([]GroupMember, int, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
total, err := r.MemberCount(ctx, groupID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT u.id, u.username, u.email, u.enabled
|
||||
FROM user_groups ug
|
||||
JOIN users u ON u.id = ug.user_id
|
||||
WHERE ug.group_id = $1
|
||||
ORDER BY u.username
|
||||
OFFSET $2 LIMIT $3`, groupID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("listing members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []GroupMember
|
||||
for rows.Next() {
|
||||
var m GroupMember
|
||||
if err := rows.Scan(&m.UserID, &m.Username, &m.Email, &m.Enabled); err != nil {
|
||||
return nil, 0, fmt.Errorf("scanning member: %w", err)
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
return members, total, rows.Err()
|
||||
}
|
||||
|
||||
// AddMember adds a user to a group (idempotent) and bumps their revision.
|
||||
func (r *GroupRepository) AddMember(ctx context.Context, groupID, userID int) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning add member: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := scanGroup(tx.QueryRow(ctx,
|
||||
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID)); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, userID, groupID)
|
||||
if err != nil {
|
||||
if isForeignKeyError(err) {
|
||||
return ErrUnknownUser
|
||||
}
|
||||
return fmt.Errorf("adding member: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET access_policy_revision = access_policy_revision + 1
|
||||
WHERE id = $1`, userID); err != nil {
|
||||
return fmt.Errorf("bumping member revision: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// lastEnabledAdministratorGuard returns ErrLastAdministrator when userID is an
|
||||
// enabled member of the administrators group and no other enabled member
|
||||
// exists, i.e. when removing, disabling, or deleting userID would leave the
|
||||
// server without an enabled administrator. It locks the administrators group
|
||||
// row (FOR UPDATE) so concurrent membership/disable/delete mutations
|
||||
// serialize; re-locking the same row later in the same transaction is a no-op.
|
||||
func lastEnabledAdministratorGuard(ctx context.Context, tx pgx.Tx, excludingUserID int) error {
|
||||
var adminGroupID int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
|
||||
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
|
||||
return fmt.Errorf("locking administrators group: %w", err)
|
||||
}
|
||||
|
||||
// Only an enabled administrator can be the last enabled administrator;
|
||||
// mutating a non-member or disabled member never breaks the invariant.
|
||||
var enabledMember bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_groups ug
|
||||
JOIN users u ON u.id = ug.user_id
|
||||
WHERE ug.group_id = $1 AND ug.user_id = $2 AND u.enabled)`,
|
||||
adminGroupID, excludingUserID).Scan(&enabledMember); err != nil {
|
||||
return fmt.Errorf("checking administrators membership: %w", err)
|
||||
}
|
||||
if !enabledMember {
|
||||
return nil
|
||||
}
|
||||
|
||||
var remaining int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM user_groups ug
|
||||
JOIN users u ON u.id = ug.user_id
|
||||
WHERE ug.group_id = $1 AND u.enabled AND ug.user_id <> $2`,
|
||||
adminGroupID, excludingUserID).Scan(&remaining); err != nil {
|
||||
return fmt.Errorf("counting administrators: %w", err)
|
||||
}
|
||||
if remaining == 0 {
|
||||
return ErrLastAdministrator
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMember removes a user from a group. Removing the last enabled member
|
||||
// of the administrators group is rejected.
|
||||
func (r *GroupRepository) RemoveMember(ctx context.Context, groupID, userID int) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning remove member: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
group, err := scanGroup(tx.QueryRow(ctx,
|
||||
`SELECT `+groupColumns+` FROM groups WHERE id = $1 FOR UPDATE`, groupID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group.Slug == models.GroupSlugAdministrators {
|
||||
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx,
|
||||
`DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`, groupID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing member: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET access_policy_revision = access_policy_revision + 1
|
||||
WHERE id = $1`, userID); err != nil {
|
||||
return fmt.Errorf("bumping member revision: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ReplaceUserGroups sets a user's memberships to exactly groupIDs, applying
|
||||
// the last-administrator guard if administrators membership is being removed.
|
||||
func (r *GroupRepository) ReplaceUserGroups(ctx context.Context, userID int, groupIDs []int) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning group replacement: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if err := r.replaceUserGroupsTx(ctx, tx, userID, groupIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// replaceUserGroupsTx performs the membership replacement inside the caller's
|
||||
// transaction so it can be composed with other writes atomically.
|
||||
func (r *GroupRepository) replaceUserGroupsTx(ctx context.Context, tx pgx.Tx, userID int, groupIDs []int) error {
|
||||
// Lock the administrators group row so concurrent removals serialize.
|
||||
var adminGroupID int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM groups WHERE slug = $1 FOR UPDATE`,
|
||||
models.GroupSlugAdministrators).Scan(&adminGroupID); err != nil {
|
||||
return fmt.Errorf("locking administrators group: %w", err)
|
||||
}
|
||||
|
||||
keepingAdmin := false
|
||||
for _, id := range groupIDs {
|
||||
if id == adminGroupID {
|
||||
keepingAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !keepingAdmin {
|
||||
if err := lastEnabledAdministratorGuard(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM user_groups WHERE user_id = $1`, userID); err != nil {
|
||||
return fmt.Errorf("clearing memberships: %w", err)
|
||||
}
|
||||
if len(groupIDs) > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT $1, unnest($2::int[])
|
||||
ON CONFLICT DO NOTHING`, userID, groupIDs); err != nil {
|
||||
if isForeignKeyError(err) {
|
||||
return fmt.Errorf("%w", ErrUnknownGroup)
|
||||
}
|
||||
return fmt.Errorf("adding memberships: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users SET access_policy_revision = access_policy_revision + 1
|
||||
WHERE id = $1`, userID); err != nil {
|
||||
return fmt.Errorf("bumping user revision: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSlugifyGroupName(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Family", "family"},
|
||||
{"Power Users", "power-users"},
|
||||
{" Trimmed ", "trimmed"},
|
||||
{"Ünïcode & Symbols!", "unicode-symbols"},
|
||||
{"--multi---dash--", "multi-dash"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := slugifyGroupName(tc.in); got != tc.want {
|
||||
t.Errorf("slugifyGroupName(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ var (
|
||||
// Claims represents the custom JWT claims used for authentication.
|
||||
type Claims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"`
|
||||
@@ -63,31 +62,28 @@ func (j *JWTService) RefreshExpiry() time.Duration {
|
||||
|
||||
// GenerateAccessToken creates a signed JWT access token with the configured
|
||||
// access token expiry duration.
|
||||
func (j *JWTService) GenerateAccessToken(userID int, role, sessionID string) (string, error) {
|
||||
func (j *JWTService) GenerateAccessToken(userID int, sessionID string) (string, error) {
|
||||
return j.generateAccessToken(Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a signed JWT refresh token with the configured
|
||||
// refresh token expiry duration.
|
||||
func (j *JWTService) GenerateRefreshToken(userID int, role, sessionID string) (string, error) {
|
||||
func (j *JWTService) GenerateRefreshToken(userID int, sessionID string) (string, error) {
|
||||
return j.generateRefreshToken(Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
func (j *JWTService) GeneratePluginAccessToken(userID int, role, sessionID string, ttl time.Duration) (string, error) {
|
||||
func (j *JWTService) GeneratePluginAccessToken(userID int, sessionID string, ttl time.Duration) (string, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Minute
|
||||
}
|
||||
return j.generateToken(Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
SessionID: sessionID,
|
||||
}, TokenTypePluginAccess, ttl)
|
||||
}
|
||||
|
||||
+19
-20
@@ -18,7 +18,7 @@ func newTestJWTService() *auth.JWTService {
|
||||
func TestJWT_GenerateAccessToken(t *testing.T) {
|
||||
svc := newTestJWTService()
|
||||
|
||||
token, err := svc.GenerateAccessToken(42, "admin", "sess-abc-123")
|
||||
token, err := svc.GenerateAccessToken(42, "sess-abc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken() error: %v", err)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func TestJWT_GenerateAccessToken(t *testing.T) {
|
||||
func TestJWT_GenerateRefreshToken(t *testing.T) {
|
||||
svc := newTestJWTService()
|
||||
|
||||
token, err := svc.GenerateRefreshToken(42, "user", "sess-def-456")
|
||||
token, err := svc.GenerateRefreshToken(42, "sess-def-456")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() error: %v", err)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestJWT_GenerateRefreshToken(t *testing.T) {
|
||||
func TestJWT_ValidateAccessToken(t *testing.T) {
|
||||
svc := newTestJWTService()
|
||||
|
||||
token, err := svc.GenerateAccessToken(1, "user", "sess-uuid-1")
|
||||
token, err := svc.GenerateAccessToken(1, "sess-uuid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken() error: %v", err)
|
||||
}
|
||||
@@ -61,9 +61,6 @@ func TestJWT_ValidateAccessToken(t *testing.T) {
|
||||
if claims.UserID != 1 {
|
||||
t.Errorf("UserID = %d, want 1", claims.UserID)
|
||||
}
|
||||
if claims.Role != "user" {
|
||||
t.Errorf("Role = %q, want %q", claims.Role, "user")
|
||||
}
|
||||
if claims.SessionID != "sess-uuid-1" {
|
||||
t.Errorf("SessionID = %q, want %q", claims.SessionID, "sess-uuid-1")
|
||||
}
|
||||
@@ -91,7 +88,7 @@ func TestJWT_ValidateAccessToken(t *testing.T) {
|
||||
func TestJWT_ValidateRefreshToken(t *testing.T) {
|
||||
svc := newTestJWTService()
|
||||
|
||||
token, err := svc.GenerateRefreshToken(99, "admin", "sess-uuid-2")
|
||||
token, err := svc.GenerateRefreshToken(99, "sess-uuid-2")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() error: %v", err)
|
||||
}
|
||||
@@ -104,9 +101,6 @@ func TestJWT_ValidateRefreshToken(t *testing.T) {
|
||||
if claims.UserID != 99 {
|
||||
t.Errorf("UserID = %d, want 99", claims.UserID)
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("Role = %q, want %q", claims.Role, "admin")
|
||||
}
|
||||
if claims.SessionID != "sess-uuid-2" {
|
||||
t.Errorf("SessionID = %q, want %q", claims.SessionID, "sess-uuid-2")
|
||||
}
|
||||
@@ -118,12 +112,12 @@ func TestJWT_ValidateRefreshToken(t *testing.T) {
|
||||
func TestJWT_AccessTokenExpiry(t *testing.T) {
|
||||
svc := newTestJWTService()
|
||||
|
||||
accessToken, err := svc.GenerateAccessToken(1, "user", "sess-1")
|
||||
accessToken, err := svc.GenerateAccessToken(1, "sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken() error: %v", err)
|
||||
}
|
||||
|
||||
refreshToken, err := svc.GenerateRefreshToken(1, "user", "sess-1")
|
||||
refreshToken, err := svc.GenerateRefreshToken(1, "sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() error: %v", err)
|
||||
}
|
||||
@@ -162,7 +156,7 @@ func TestJWT_ExpiredToken(t *testing.T) {
|
||||
// Create a service with a negative expiry so tokens are immediately expired.
|
||||
svc := auth.NewJWTService(testSecret, -1*time.Second, -1*time.Second)
|
||||
|
||||
token, err := svc.GenerateAccessToken(1, "user", "sess-expired")
|
||||
token, err := svc.GenerateAccessToken(1, "sess-expired")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken() error: %v", err)
|
||||
}
|
||||
@@ -176,13 +170,19 @@ func TestJWT_ExpiredToken(t *testing.T) {
|
||||
func TestJWT_TamperedToken(t *testing.T) {
|
||||
svc := newTestJWTService()
|
||||
|
||||
token, err := svc.GenerateAccessToken(1, "user", "sess-tamper")
|
||||
token, err := svc.GenerateAccessToken(1, "sess-tamper")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken() error: %v", err)
|
||||
}
|
||||
|
||||
// Tamper with the token by modifying the last character of the signature.
|
||||
tampered := token[:len(token)-1] + "X"
|
||||
// Tamper with the token by replacing the last character of the signature
|
||||
// with a different one (a fixed substitute would be a no-op whenever the
|
||||
// real signature already ends in that character).
|
||||
replacement := "X"
|
||||
if token[len(token)-1] == 'X' {
|
||||
replacement = "Y"
|
||||
}
|
||||
tampered := token[:len(token)-1] + replacement
|
||||
|
||||
_, err = svc.ValidateToken(tampered)
|
||||
if err == nil {
|
||||
@@ -194,7 +194,7 @@ func TestJWT_WrongSecret(t *testing.T) {
|
||||
svc1 := auth.NewJWTService("secret-one", 15*time.Minute, 7*24*time.Hour)
|
||||
svc2 := auth.NewJWTService("secret-two", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
token, err := svc1.GenerateAccessToken(1, "user", "sess-wrong-secret")
|
||||
token, err := svc1.GenerateAccessToken(1, "sess-wrong-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken() error: %v", err)
|
||||
}
|
||||
@@ -210,7 +210,6 @@ func TestJWT_WrongSigningMethod(t *testing.T) {
|
||||
// We construct a token with alg=none to ensure the validator rejects it.
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": 1,
|
||||
"role": "admin",
|
||||
"session_id": "sess-none",
|
||||
"token_type": auth.TokenTypeAccess,
|
||||
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
||||
@@ -250,12 +249,12 @@ func TestJWT_GarbageToken(t *testing.T) {
|
||||
func TestJWT_DifferentUsersGetDifferentTokens(t *testing.T) {
|
||||
svc := newTestJWTService()
|
||||
|
||||
token1, err := svc.GenerateAccessToken(1, "user", "sess-1")
|
||||
token1, err := svc.GenerateAccessToken(1, "sess-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken(1) error: %v", err)
|
||||
}
|
||||
|
||||
token2, err := svc.GenerateAccessToken(2, "admin", "sess-2")
|
||||
token2, err := svc.GenerateAccessToken(2, "sess-2")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken(2) error: %v", err)
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermissionAdmin Permission = "admin"
|
||||
PermissionMarkerEdit Permission = "marker_edit"
|
||||
PermissionMetadataCuration Permission = "metadata_curation"
|
||||
)
|
||||
|
||||
var assignablePermissions = map[Permission]struct{}{
|
||||
PermissionAdmin: {},
|
||||
PermissionMarkerEdit: {},
|
||||
PermissionMetadataCuration: {},
|
||||
}
|
||||
@@ -60,10 +62,6 @@ func NormalizePermissions(values []string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func DefaultUserPermissions() []string {
|
||||
return []string{string(PermissionMarkerEdit)}
|
||||
}
|
||||
|
||||
func HasAssignedPermission(user *models.User, permission Permission) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
@@ -80,7 +78,7 @@ func HasEffectivePermission(user *models.User, permission Permission) bool {
|
||||
if user == nil || !user.Enabled {
|
||||
return false
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
if user.IsAdmin {
|
||||
return isAssignablePermission(permission)
|
||||
}
|
||||
return HasAssignedPermission(user, permission)
|
||||
@@ -90,7 +88,7 @@ func EffectivePermissions(user *models.User) []string {
|
||||
if user == nil || !user.Enabled {
|
||||
return []string{}
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
if user.IsAdmin {
|
||||
return assignablePermissionList()
|
||||
}
|
||||
permissions, err := NormalizePermissions(user.Permissions)
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestNormalizePermissions_RejectsUnknownPermission(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHasEffectivePermission_AdminImpliesAssignablePermissions(t *testing.T) {
|
||||
user := &models.User{Role: "admin", Enabled: true}
|
||||
user := &models.User{IsAdmin: true, Enabled: true}
|
||||
if !HasEffectivePermission(user, PermissionMetadataCuration) {
|
||||
t.Fatal("admin should have metadata curation")
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestHasEffectivePermission_AdminImpliesAssignablePermissions(t *testing.T)
|
||||
}
|
||||
|
||||
func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
|
||||
user := &models.User{Role: "user", Enabled: true}
|
||||
user := &models.User{Enabled: true}
|
||||
if HasEffectivePermission(user, PermissionMetadataCuration) {
|
||||
t.Fatal("plain user should not have metadata curation")
|
||||
}
|
||||
@@ -50,10 +50,22 @@ func TestHasEffectivePermission_UserRequiresAssignedPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultUserPermissionsIncludesMarkerEditOnly(t *testing.T) {
|
||||
got := DefaultUserPermissions()
|
||||
want := []string{"marker_edit"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("default permissions = %#v, want %#v", got, want)
|
||||
func TestAdminPermissionIsAssignable(t *testing.T) {
|
||||
normalized, err := NormalizePermissions([]string{"admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("admin must be an assignable permission: %v", err)
|
||||
}
|
||||
if len(normalized) != 1 || normalized[0] != "admin" {
|
||||
t.Fatalf("got %v, want [admin]", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAdminGrantsAllPermissions(t *testing.T) {
|
||||
u := &models.User{Enabled: true, IsAdmin: true}
|
||||
if !HasEffectivePermission(u, PermissionMarkerEdit) {
|
||||
t.Error("admin should hold marker_edit")
|
||||
}
|
||||
if !HasEffectivePermission(u, PermissionMetadataCuration) {
|
||||
t.Error("admin should hold metadata_curation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func NewPluginProviderWithClientFactory(
|
||||
sessions: sessions,
|
||||
users: users,
|
||||
identityPool: pool,
|
||||
accounts: NewAccountProvisioner(users, nil),
|
||||
accounts: NewAccountProvisioner(users, nil, nil, groupResolverFor(users)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,6 @@ func (p *PluginProvider) autoProvisionUser(
|
||||
Username: username,
|
||||
Password: password,
|
||||
LocalPasswordLoginEnabled: &localPasswordLoginEnabled,
|
||||
Role: "user",
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
+187
-178
@@ -30,6 +30,11 @@ func IsDuplicate(err error) bool {
|
||||
return errors.Is(err, ErrDuplicate)
|
||||
}
|
||||
|
||||
// UserLoader loads a user by ID with effective policy hydrated.
|
||||
type UserLoader interface {
|
||||
GetByID(ctx context.Context, id int) (*models.User, error)
|
||||
}
|
||||
|
||||
// CheckPassword verifies a plaintext password against the user's bcrypt hash.
|
||||
// This is a standalone function, not a repository method.
|
||||
func CheckPassword(user *models.User, password string) bool {
|
||||
@@ -37,22 +42,25 @@ func CheckPassword(user *models.User, password string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// UserRepository provides CRUD operations for the users table.
|
||||
// UserRepository provides CRUD operations for the users table. Effective
|
||||
// access policy is derived from group memberships when users are loaded.
|
||||
type UserRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
pool *pgxpool.Pool
|
||||
groups *GroupRepository
|
||||
}
|
||||
|
||||
// NewUserRepository creates a new UserRepository backed by the given pool.
|
||||
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
|
||||
return &UserRepository{pool: pool}
|
||||
return &UserRepository{pool: pool, groups: NewGroupRepository(pool)}
|
||||
}
|
||||
|
||||
// Groups exposes the group repository sharing this repository's pool.
|
||||
func (r *UserRepository) Groups() *GroupRepository { return r.groups }
|
||||
|
||||
// allColumns is the list of columns returned by all SELECT queries.
|
||||
// Kept in one place so scanUser stays in sync.
|
||||
const allColumns = `id, email, username, password_hash, local_password_login_enabled, role, permissions, enabled,
|
||||
library_ids, max_playback_quality, access_policy_revision,
|
||||
max_streams, max_transcodes, max_profiles, download_allowed,
|
||||
download_transcode_allowed, created_at, updated_at`
|
||||
const allColumns = `id, email, username, password_hash, local_password_login_enabled, enabled,
|
||||
access_policy_revision, created_at, updated_at`
|
||||
|
||||
// scanUser scans a single row into a *models.User.
|
||||
func scanUser(row pgx.Row) (*models.User, error) {
|
||||
@@ -63,17 +71,8 @@ func scanUser(row pgx.Row) (*models.User, error) {
|
||||
&u.Username,
|
||||
&u.PasswordHash,
|
||||
&u.LocalPasswordLoginEnabled,
|
||||
&u.Role,
|
||||
&u.Permissions,
|
||||
&u.Enabled,
|
||||
&u.LibraryIDs,
|
||||
&u.MaxPlaybackQuality,
|
||||
&u.AccessPolicyRevision,
|
||||
&u.MaxStreams,
|
||||
&u.MaxTranscodes,
|
||||
&u.MaxProfiles,
|
||||
&u.DownloadAllowed,
|
||||
&u.DownloadTranscodeAllowed,
|
||||
&u.CreatedAt,
|
||||
&u.UpdatedAt,
|
||||
)
|
||||
@@ -90,31 +89,11 @@ func scanUser(row pgx.Row) (*models.User, error) {
|
||||
func scanUsers(rows pgx.Rows) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
err := rows.Scan(
|
||||
&u.ID,
|
||||
&u.Email,
|
||||
&u.Username,
|
||||
&u.PasswordHash,
|
||||
&u.LocalPasswordLoginEnabled,
|
||||
&u.Role,
|
||||
&u.Permissions,
|
||||
&u.Enabled,
|
||||
&u.LibraryIDs,
|
||||
&u.MaxPlaybackQuality,
|
||||
&u.AccessPolicyRevision,
|
||||
&u.MaxStreams,
|
||||
&u.MaxTranscodes,
|
||||
&u.MaxProfiles,
|
||||
&u.DownloadAllowed,
|
||||
&u.DownloadTranscodeAllowed,
|
||||
&u.CreatedAt,
|
||||
&u.UpdatedAt,
|
||||
)
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning user row: %w", err)
|
||||
}
|
||||
users = append(users, &u)
|
||||
users = append(users, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating user rows: %w", err)
|
||||
@@ -122,77 +101,46 @@ func scanUsers(rows pgx.Rows) ([]*models.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Create inserts a new user with a bcrypt-hashed password and returns the created user.
|
||||
// hydrate loads the user's group memberships and applies the effective policy.
|
||||
func (r *UserRepository) hydrate(ctx context.Context, u *models.User) error {
|
||||
groups, err := r.groups.GroupsForUser(ctx, u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ApplyEffectivePolicy(u, groups)
|
||||
u.Groups = groups
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create inserts a new user with a bcrypt-hashed password and the given group
|
||||
// memberships, then returns the created user with its effective policy.
|
||||
func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInput) (*models.User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
|
||||
// Base columns that are always included.
|
||||
localPasswordLoginEnabled := true
|
||||
if input.LocalPasswordLoginEnabled != nil {
|
||||
localPasswordLoginEnabled = *input.LocalPasswordLoginEnabled
|
||||
}
|
||||
|
||||
permissions := append([]string(nil), input.Permissions...)
|
||||
if input.Permissions == nil && input.Role != "admin" {
|
||||
permissions = DefaultUserPermissions()
|
||||
}
|
||||
permissions, err = NormalizePermissions(permissions)
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("beginning user create: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
cols := []string{"email", "username", "password_hash", "local_password_login_enabled", "role", "permissions", "library_ids", "max_playback_quality"}
|
||||
args := []any{
|
||||
var id int
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, username, password_hash, local_password_login_enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id`,
|
||||
NormalizeEmail(input.Email),
|
||||
NormalizeUsername(input.Username),
|
||||
string(hash),
|
||||
localPasswordLoginEnabled,
|
||||
input.Role,
|
||||
permissions,
|
||||
input.LibraryIDs,
|
||||
input.MaxPlaybackQuality,
|
||||
}
|
||||
|
||||
// Optional columns: nil means use DB default.
|
||||
if input.MaxStreams != nil {
|
||||
cols = append(cols, "max_streams")
|
||||
args = append(args, *input.MaxStreams)
|
||||
}
|
||||
if input.MaxTranscodes != nil {
|
||||
cols = append(cols, "max_transcodes")
|
||||
args = append(args, *input.MaxTranscodes)
|
||||
}
|
||||
if input.MaxProfiles != nil {
|
||||
cols = append(cols, "max_profiles")
|
||||
args = append(args, *input.MaxProfiles)
|
||||
}
|
||||
if input.DownloadAllowed != nil {
|
||||
cols = append(cols, "download_allowed")
|
||||
args = append(args, *input.DownloadAllowed)
|
||||
}
|
||||
if input.DownloadTranscodeAllowed != nil {
|
||||
cols = append(cols, "download_transcode_allowed")
|
||||
args = append(args, *input.DownloadTranscodeAllowed)
|
||||
}
|
||||
|
||||
// Build placeholders: $1, $2, ..., $N
|
||||
placeholders := make([]string, len(args))
|
||||
for i := range args {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO users (%s) VALUES (%s) RETURNING %s",
|
||||
strings.Join(cols, ", "),
|
||||
strings.Join(placeholders, ", "),
|
||||
allColumns,
|
||||
)
|
||||
|
||||
row := r.pool.QueryRow(ctx, query, args...)
|
||||
|
||||
user, err := scanUser(row)
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
|
||||
@@ -200,29 +148,67 @@ func (r *UserRepository) Create(ctx context.Context, input models.CreateUserInpu
|
||||
return nil, fmt.Errorf("creating user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
if len(input.GroupIDs) > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT $1, unnest($2::int[])
|
||||
ON CONFLICT DO NOTHING`, id, input.GroupIDs); err != nil {
|
||||
if isForeignKeyError(err) {
|
||||
return nil, fmt.Errorf("%w", ErrUnknownGroup)
|
||||
}
|
||||
return nil, fmt.Errorf("adding memberships: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("committing user create: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// GetByID retrieves a user by their numeric ID.
|
||||
func (r *UserRepository) GetByID(ctx context.Context, id int) (*models.User, error) {
|
||||
query := `SELECT ` + allColumns + ` FROM users WHERE id = $1`
|
||||
return scanUser(r.pool.QueryRow(ctx, query, id))
|
||||
user, err := scanUser(r.pool.QueryRow(ctx, query, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.hydrate(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByUsername retrieves a user by their username (case-insensitive).
|
||||
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
query := `SELECT ` + allColumns + ` FROM users WHERE username = $1`
|
||||
return scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username)))
|
||||
user, err := scanUser(r.pool.QueryRow(ctx, query, NormalizeUsername(username)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.hydrate(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by their email address (case-insensitive).
|
||||
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
query := `SELECT ` + allColumns + ` FROM users WHERE email = $1`
|
||||
return scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email)))
|
||||
user, err := scanUser(r.pool.QueryRow(ctx, query, NormalizeEmail(email)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.hydrate(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Update modifies a user's fields. Only non-nil fields in the input are updated.
|
||||
// If the input contains a Password, it is bcrypt-hashed before storage.
|
||||
// If the input contains a Password, it is bcrypt-hashed before storage. A
|
||||
// non-nil GroupIDs replaces the user's group memberships.
|
||||
func (r *UserRepository) Update(ctx context.Context, id int, input models.UpdateUserInput) error {
|
||||
setClauses := []string{}
|
||||
accessPolicyPredicates := []string{}
|
||||
@@ -253,113 +239,107 @@ func (r *UserRepository) Update(ctx context.Context, id int, input models.Update
|
||||
args = append(args, *input.LocalPasswordLoginEnabled)
|
||||
argIndex++
|
||||
}
|
||||
if input.Role != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("role = $%d", argIndex))
|
||||
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("role IS DISTINCT FROM $%d", argIndex))
|
||||
args = append(args, *input.Role)
|
||||
argIndex++
|
||||
}
|
||||
if input.Permissions != nil {
|
||||
permissions, err := NormalizePermissions(*input.Permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("permissions = $%d", argIndex))
|
||||
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("permissions IS DISTINCT FROM $%d", argIndex))
|
||||
args = append(args, permissions)
|
||||
argIndex++
|
||||
}
|
||||
if input.Enabled != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", argIndex))
|
||||
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("enabled IS DISTINCT FROM $%d", argIndex))
|
||||
args = append(args, *input.Enabled)
|
||||
argIndex++
|
||||
}
|
||||
if input.LibraryIDs != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("library_ids = $%d", argIndex))
|
||||
// Library scope is resolved from users.library_ids on each request, so
|
||||
// changing it must not invalidate durable profile/session tokens.
|
||||
args = append(args, *input.LibraryIDs)
|
||||
argIndex++
|
||||
|
||||
// Identity and membership writes share one transaction so a failure in
|
||||
// either (e.g. ErrLastAdministrator) leaves the user fully unchanged.
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning user update: %w", err)
|
||||
}
|
||||
if input.MaxPlaybackQuality != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_playback_quality = $%d", argIndex))
|
||||
accessPolicyPredicates = append(accessPolicyPredicates, fmt.Sprintf("max_playback_quality IS DISTINCT FROM $%d", argIndex))
|
||||
args = append(args, *input.MaxPlaybackQuality)
|
||||
argIndex++
|
||||
}
|
||||
if input.MaxStreams != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_streams = $%d", argIndex))
|
||||
args = append(args, *input.MaxStreams)
|
||||
argIndex++
|
||||
}
|
||||
if input.MaxTranscodes != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_transcodes = $%d", argIndex))
|
||||
args = append(args, *input.MaxTranscodes)
|
||||
argIndex++
|
||||
}
|
||||
if input.MaxProfiles != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("max_profiles = $%d", argIndex))
|
||||
args = append(args, *input.MaxProfiles)
|
||||
argIndex++
|
||||
}
|
||||
if input.DownloadAllowed != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("download_allowed = $%d", argIndex))
|
||||
args = append(args, *input.DownloadAllowed)
|
||||
argIndex++
|
||||
}
|
||||
if input.DownloadTranscodeAllowed != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("download_transcode_allowed = $%d", argIndex))
|
||||
args = append(args, *input.DownloadTranscodeAllowed)
|
||||
argIndex++
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// Disabling the last enabled administrator would brick admin access just
|
||||
// like removing their membership, so the same transactional guard applies.
|
||||
// It runs before the identity UPDATE so the administrators group row is
|
||||
// always locked before the user row (replaceUserGroupsTx re-locks the same
|
||||
// group row later in this transaction, which is a no-op).
|
||||
if input.Enabled != nil && !*input.Enabled {
|
||||
if err := lastEnabledAdministratorGuard(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
// Nothing to update; still verify the user exists.
|
||||
_, err := r.GetByID(ctx, id)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(accessPolicyPredicates) > 0 {
|
||||
setClauses = append(setClauses, fmt.Sprintf(
|
||||
"access_policy_revision = CASE WHEN %s THEN access_policy_revision + 1 ELSE access_policy_revision END",
|
||||
strings.Join(accessPolicyPredicates, " OR "),
|
||||
))
|
||||
}
|
||||
|
||||
// Always bump updated_at.
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d",
|
||||
strings.Join(setClauses, ", "), argIndex)
|
||||
args = append(args, id)
|
||||
|
||||
tag, err := r.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
return fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
|
||||
// Nothing to update on the users row; still verify the user exists.
|
||||
var exists int
|
||||
if err := tx.QueryRow(ctx, `SELECT 1 FROM users WHERE id = $1`, id).Scan(&exists); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("checking user exists: %w", err)
|
||||
}
|
||||
} else {
|
||||
if len(accessPolicyPredicates) > 0 {
|
||||
setClauses = append(setClauses, fmt.Sprintf(
|
||||
"access_policy_revision = CASE WHEN %s THEN access_policy_revision + 1 ELSE access_policy_revision END",
|
||||
strings.Join(accessPolicyPredicates, " OR "),
|
||||
))
|
||||
}
|
||||
|
||||
// Always bump updated_at.
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
|
||||
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d",
|
||||
strings.Join(setClauses, ", "), argIndex)
|
||||
args = append(args, id)
|
||||
|
||||
tag, err := tx.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
return fmt.Errorf("%w: %s", ErrDuplicate, extractConstraint(err))
|
||||
}
|
||||
return fmt.Errorf("updating user: %w", err)
|
||||
}
|
||||
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("updating user: %w", err)
|
||||
}
|
||||
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
if input.GroupIDs != nil {
|
||||
if err := r.groups.replaceUserGroupsTx(ctx, tx, id, *input.GroupIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("committing user update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a user by their ID.
|
||||
// Delete removes a user by their ID. Deleting the last enabled administrator
|
||||
// is rejected with ErrLastAdministrator; the guard and the delete share one
|
||||
// transaction so concurrent admin mutations serialize on the administrators
|
||||
// group row.
|
||||
func (r *UserRepository) Delete(ctx context.Context, id int) error {
|
||||
tag, err := r.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id)
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning user delete: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if err := lastEnabledAdministratorGuard(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx, "DELETE FROM users WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting user: %w", err)
|
||||
}
|
||||
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("committing user delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -372,7 +352,27 @@ func (r *UserRepository) List(ctx context.Context) ([]*models.User, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanUsers(rows)
|
||||
users, err := scanUsers(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bulk-hydrate effective policy with a single membership query.
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
groupsByUser, err := r.groups.GroupsForUsers(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range users {
|
||||
groups := groupsByUser[u.ID]
|
||||
ApplyEffectivePolicy(u, groups)
|
||||
u.Groups = groups
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Count returns the number of users in the database.
|
||||
@@ -393,6 +393,15 @@ func isDuplicateKeyError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isForeignKeyError checks if the error is a PostgreSQL foreign_key_violation (code 23503).
|
||||
func isForeignKeyError(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23503"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractConstraint extracts the constraint name from a PgError for diagnostic messages.
|
||||
func extractConstraint(err error) string {
|
||||
var pgErr *pgconn.PgError
|
||||
|
||||
+43
-11
@@ -35,6 +35,33 @@ type SettingsGetter interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// Legacy role strings exposed in API response bodies. Presentation-only;
|
||||
// intentionally distinct from Permission values even though "admin" overlaps
|
||||
// textually.
|
||||
const (
|
||||
roleAdmin = "admin"
|
||||
roleUser = "user"
|
||||
)
|
||||
|
||||
// RoleForUser derives the legacy role string exposed in API response bodies
|
||||
// from the user's group-derived admin flag. It is never used for
|
||||
// authorization decisions and is no longer carried in JWT claims.
|
||||
func RoleForUser(u *models.User) string {
|
||||
if u != nil && u.IsAdmin {
|
||||
return roleAdmin
|
||||
}
|
||||
return roleUser
|
||||
}
|
||||
|
||||
// groupResolverFor returns the user repository's group repository as a
|
||||
// GroupResolver, avoiding a typed-nil interface when users is nil.
|
||||
func groupResolverFor(users *UserRepository) GroupResolver {
|
||||
if users == nil {
|
||||
return nil
|
||||
}
|
||||
return users.Groups()
|
||||
}
|
||||
|
||||
type claimsContextKey struct{}
|
||||
|
||||
// WithClaims stores auth claims on the context for auth-owned flows.
|
||||
@@ -100,7 +127,7 @@ func NewService(
|
||||
settings: settings,
|
||||
providers: map[string]AuthProvider{},
|
||||
metadata: map[string]LoginProviderInfo{},
|
||||
accounts: NewAccountProvisioner(users, storeProvider),
|
||||
accounts: NewAccountProvisioner(users, storeProvider, settings, groupResolverFor(users)),
|
||||
}
|
||||
if provider != nil {
|
||||
service.RegisterProvider(LoginProviderInfo{
|
||||
@@ -209,7 +236,6 @@ func (s *Service) CompleteOAuthLogin(ctx context.Context, in OAuthLoginInput) (*
|
||||
}
|
||||
pair, err := s.generateTokenPair(Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -271,7 +297,6 @@ func (s *Service) loginWithProvider(
|
||||
|
||||
pair, err := s.generateTokenPair(Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -306,12 +331,20 @@ func (s *Service) SetupInitialUser(
|
||||
return nil, nil, ErrSetupAlreadyComplete
|
||||
}
|
||||
|
||||
adminGroup, err := s.users.Groups().GetBySlug(ctx, models.GroupSlugAdministrators)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("loading administrators group: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
|
||||
User: models.CreateUserInput{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: password,
|
||||
Role: "admin",
|
||||
// Administrators membership is written inside the same transaction
|
||||
// as the user row, so a failure leaves setup fully retryable. The
|
||||
// explicit list also keeps configured signup defaults from applying.
|
||||
GroupIDs: []int{adminGroup.ID},
|
||||
},
|
||||
DefaultProfile: DefaultProfileOptions{
|
||||
Enabled: createDefaultProfile,
|
||||
@@ -322,6 +355,7 @@ func (s *Service) SetupInitialUser(
|
||||
}
|
||||
|
||||
// Reuse the standard login flow so setup creates a normal session pair.
|
||||
// Login reloads the user, so the returned user carries the admin policy.
|
||||
return s.Login(ctx, username, password, deviceName, ip)
|
||||
}
|
||||
|
||||
@@ -352,13 +386,13 @@ func (s *Service) Signup(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Create the user with standard role and access to all libraries.
|
||||
// Create the user; nil GroupIDs lets the provisioner resolve the
|
||||
// configured default groups.
|
||||
if _, err := s.accounts.CreateAccount(ctx, CreateAccountInput{
|
||||
User: models.CreateUserInput{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: password,
|
||||
Role: "user",
|
||||
},
|
||||
DefaultProfile: DefaultProfileOptions{
|
||||
Enabled: createDefaultProfile,
|
||||
@@ -412,7 +446,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
|
||||
}
|
||||
return nil, nil, nil, fmt.Errorf("getting admin user: %w", err)
|
||||
}
|
||||
if admin.Role != "admin" || !admin.Enabled {
|
||||
if !admin.IsAdmin || !admin.Enabled {
|
||||
return nil, nil, nil, ErrImpersonationNotAllowed
|
||||
}
|
||||
if adminUserID == targetUserID {
|
||||
@@ -423,7 +457,7 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("getting target user: %w", err)
|
||||
}
|
||||
if !target.Enabled || target.Role == "admin" {
|
||||
if !target.Enabled || target.IsAdmin {
|
||||
return nil, nil, nil, ErrImpersonationNotAllowed
|
||||
}
|
||||
|
||||
@@ -446,7 +480,6 @@ func (s *Service) StartImpersonation(ctx context.Context, adminUserID, targetUse
|
||||
|
||||
pair, err := s.generateTokenPair(Claims{
|
||||
UserID: target.ID,
|
||||
Role: target.Role,
|
||||
SessionID: sessionID,
|
||||
ImpersonatorUserID: &impersonatorUserID,
|
||||
})
|
||||
@@ -519,7 +552,6 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair,
|
||||
|
||||
return s.generateTokenPair(Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
SessionID: session.ID,
|
||||
ImpersonatorUserID: session.ImpersonatorUserID,
|
||||
})
|
||||
@@ -537,7 +569,7 @@ func (s *Service) validateImpersonator(ctx context.Context, impersonatorUserID *
|
||||
}
|
||||
return fmt.Errorf("getting impersonator user: %w", err)
|
||||
}
|
||||
if !impersonator.Enabled || impersonator.Role != "admin" {
|
||||
if !impersonator.Enabled || !impersonator.IsAdmin {
|
||||
return ErrSessionRevoked
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -15,10 +15,10 @@ type ScopeResolver interface {
|
||||
}
|
||||
|
||||
// NewScopeAccessFilter returns an AccessFilterResolver backed by the shared
|
||||
// access scope resolver, so account-level library restrictions
|
||||
// (users.library_ids), profile library restrictions, user-disabled
|
||||
// libraries, content-rating ceilings, and playback-quality ceilings all
|
||||
// apply to the Jellyfin-compat API exactly as they do to the native API.
|
||||
// access scope resolver, so account-level library restrictions (derived from
|
||||
// group membership), profile library restrictions, user-disabled libraries,
|
||||
// content-rating ceilings, and playback-quality ceilings all apply to the
|
||||
// Jellyfin-compat API exactly as they do to the native API.
|
||||
//
|
||||
// PIN verification is skipped because compat login already verified the
|
||||
// profile PIN (password#pin convention). Resolution failures fail closed:
|
||||
|
||||
@@ -26,12 +26,15 @@ const (
|
||||
type Authenticator struct {
|
||||
sessions *SessionStore
|
||||
authService *auth.Service
|
||||
users auth.UserLoader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewAuthenticator creates a new compat authenticator.
|
||||
func NewAuthenticator(sessions *SessionStore, authService *auth.Service) *Authenticator {
|
||||
return &Authenticator{sessions: sessions, authService: authService, now: time.Now}
|
||||
// NewAuthenticator creates a new compat authenticator. users re-validates the
|
||||
// session's account on every request (see compatUserEnabled); a nil loader
|
||||
// fails closed.
|
||||
func NewAuthenticator(sessions *SessionStore, authService *auth.Service, users auth.UserLoader) *Authenticator {
|
||||
return &Authenticator{sessions: sessions, authService: authService, users: users, now: time.Now}
|
||||
}
|
||||
|
||||
// ExtractToken extracts a compat token from Jellyfin-style request auth.
|
||||
@@ -94,6 +97,19 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Compat sessions cache only identity, never account status: the user
|
||||
// is re-loaded on EVERY request so disabling (or deleting) an account
|
||||
// cuts off Jellyfin clients on the next request rather than at
|
||||
// compat-session expiry — matching the native API and the admin
|
||||
// API-key path. The session itself is left in place: transient lookup
|
||||
// failures also deny (fail closed), and deleting here would force a
|
||||
// re-login after a recovered DB blip. Session eviction on disable is
|
||||
// handled separately (OnUserSessionsRevoked).
|
||||
if !compatUserEnabled(r.Context(), a.users, session.StreamAppUserID) {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized", "User account is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh underlying Silo tokens if they're about to expire.
|
||||
// Use a detached context so a client aborting the request mid-refresh
|
||||
// (common on flaky mobile networks) doesn't revoke the compat session.
|
||||
@@ -148,15 +164,46 @@ func serveWithSession(next http.Handler, w http.ResponseWriter, r *http.Request,
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// compatUserEnabled reports whether a compat session's account still exists
|
||||
// and is enabled. The user is loaded fresh on every request — never cached on
|
||||
// the session — so disabling an account takes effect on the next request.
|
||||
// Every failure mode fails closed: a missing user is a definitive deny, and
|
||||
// an unexpected lookup error denies rather than serving content for an
|
||||
// account whose status cannot be verified. A nil loader also fails closed;
|
||||
// production wiring always provides one alongside the database.
|
||||
func compatUserEnabled(ctx context.Context, users auth.UserLoader, userID int) bool {
|
||||
if users == nil {
|
||||
return false
|
||||
}
|
||||
user, err := users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
if !auth.IsNotFound(err) {
|
||||
slog.Warn("jellycompat auth: user status check failed; denying request",
|
||||
"user_id", userID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return user != nil && user.Enabled
|
||||
}
|
||||
|
||||
// resolveCompatToken resolves a token to a compat session: a session-store token
|
||||
// (normal login) or, matching Jellyfin, an sa_ admin API key (synthesized
|
||||
// session bound to the key user's primary profile). Returns false when the token
|
||||
// matches neither. keyAuth may be nil (resolveSession handles a nil receiver).
|
||||
func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, token string) (*Session, bool) {
|
||||
// matches neither or when the session's account is no longer enabled. keyAuth
|
||||
// may be nil (resolveSession handles a nil receiver).
|
||||
func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *AdminAPIKeyAuthenticator, users auth.UserLoader, token string) (*Session, bool) {
|
||||
if token == "" {
|
||||
return nil, false
|
||||
}
|
||||
if session, ok := sessions.Get(token); ok {
|
||||
// sa_-key sessions below re-validate the owning user inside
|
||||
// resolveSession; session-store sessions must do it here so a
|
||||
// disabled account cannot keep streaming until session expiry.
|
||||
if !compatUserEnabled(ctx, users, session.StreamAppUserID) {
|
||||
return nil, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
if strings.HasPrefix(token, "sa_") {
|
||||
@@ -170,13 +217,13 @@ func resolveCompatToken(ctx context.Context, sessions *SessionStore, keyAuth *Ad
|
||||
// PlaybackSessionAuth creates middleware that falls back to playback session
|
||||
// authentication for media stream endpoints where external players (e.g. libmpv)
|
||||
// don't forward auth headers or query parameters.
|
||||
func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator) func(next http.Handler) http.Handler {
|
||||
func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionStore, keyAuth *AdminAPIKeyAuthenticator, users auth.UserLoader) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try standard token auth first — a compat session token or an sa_
|
||||
// admin key (synthesized session).
|
||||
if token, ok := ExtractToken(r); ok {
|
||||
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, token); ok {
|
||||
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, users, token); ok {
|
||||
serveWithSession(next, w, r, session)
|
||||
return
|
||||
}
|
||||
@@ -193,7 +240,7 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
|
||||
// miss it and 401 the stream — forcing a needless transcode fallback.
|
||||
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
|
||||
if playSession, found := playbackStore.Get(playSessionID); found {
|
||||
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
|
||||
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, users, playSession.CompatToken); ok {
|
||||
serveWithSession(next, w, r, session)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
@@ -22,13 +23,9 @@ type apiKeyValidator interface {
|
||||
UpdateLastUsed(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type apiKeyUserLoader interface {
|
||||
GetByID(ctx context.Context, id int) (*models.User, error)
|
||||
}
|
||||
|
||||
type AdminAPIKeyAuthenticator struct {
|
||||
keys apiKeyValidator
|
||||
users apiKeyUserLoader
|
||||
users auth.UserLoader
|
||||
provider userstore.UserStoreProvider
|
||||
now func() time.Time
|
||||
|
||||
@@ -53,7 +50,12 @@ type adminAPIKeyAuthResult struct {
|
||||
// when present, an admin key can synthesize a compat session (see
|
||||
// resolveSession); when nil, only the admin-bool path (RequireAdminAPIKey /
|
||||
// RequireSessionOrAdminAPIKey) is available.
|
||||
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
|
||||
//
|
||||
// users loads Silo users by ID with their group-derived effective policy
|
||||
// hydrated (IsAdmin, DownloadAllowed, LibraryIDs). Policy is always read from
|
||||
// a freshly loaded user — never cached on sessions — so group edits and role
|
||||
// changes take effect on the next request.
|
||||
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users auth.UserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
|
||||
if keys == nil || users == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -172,7 +174,7 @@ func (a *AdminAPIKeyAuthenticator) validate(ctx context.Context, token string) (
|
||||
if err != nil || user == nil || !user.Enabled {
|
||||
return nil, nil, unauthorized
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
if !user.IsAdmin {
|
||||
return nil, nil, adminAPIKeyAuthResult{
|
||||
status: http.StatusForbidden,
|
||||
code: "Forbidden",
|
||||
|
||||
@@ -48,7 +48,7 @@ func (p *fakeUserStoreProvider) Close() error { return nil }
|
||||
func adminKeyAuthWithPrimary(t *testing.T, clock func() time.Time) (*AdminAPIKeyAuthenticator, *fakeUserStoreProvider, *fakeAPIKeyValidator) {
|
||||
t.Helper()
|
||||
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
|
||||
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", Role: "admin", Enabled: true}}
|
||||
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", IsAdmin: true, Enabled: true}}
|
||||
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{
|
||||
{ID: "kids", Name: "Kids", IsPrimary: false},
|
||||
{ID: "p1", Name: "Parent", IsPrimary: true},
|
||||
@@ -115,7 +115,7 @@ func TestRequireSessionOrAPIKeySession_RejectsNonAdminKey(t *testing.T) {
|
||||
now := fixedNow()
|
||||
clock := func() time.Time { return now }
|
||||
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
|
||||
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Role: "user", Enabled: true}}
|
||||
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Enabled: true}}
|
||||
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{{ID: "p1", IsPrimary: true}}}}
|
||||
keyAuth := NewAdminAPIKeyAuthenticator(validator, users, provider, clock)
|
||||
sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)}
|
||||
@@ -165,7 +165,7 @@ func TestRequireSessionOrAPIKeySession_FallsThroughWhenSynthesisUnavailable(t *t
|
||||
"nil authenticator": nil,
|
||||
"nil provider": NewAdminAPIKeyAuthenticator(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
|
||||
nil, clock,
|
||||
),
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func TestPlaybackSessionAuth_APIKeyViaPlaySessionId(t *testing.T) {
|
||||
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
|
||||
|
||||
var got *Session
|
||||
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := PlaybackSessionAuth(sessions, playback, keyAuth, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = SessionFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
@@ -283,7 +283,7 @@ func TestPlaybackSessionAuth_RevokedAPIKeyViaPlaySessionId(t *testing.T) {
|
||||
playback := NewPlaybackSessionStore(time.Hour, clock)
|
||||
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
|
||||
|
||||
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
h := PlaybackSessionAuth(sessions, playback, keyAuth, nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler should not run for a revoked key")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/Videos/x/hls/p/seg0.ts?PlaySessionId=ps1", nil)
|
||||
@@ -315,8 +315,9 @@ func TestValidatePseudoUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
|
||||
h := NewAuthHandler(&config.Config{}, nil, nil)
|
||||
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"}
|
||||
h := NewAuthHandler(&config.Config{}, nil, nil,
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", IsAdmin: true, Enabled: true}})
|
||||
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), StreamAppUserID: 2, Username: "admin"}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Users", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session))
|
||||
@@ -342,7 +343,7 @@ func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleUsers_RequiresSession(t *testing.T) {
|
||||
h := NewAuthHandler(&config.Config{}, nil, nil)
|
||||
h := NewAuthHandler(&config.Config{}, nil, nil, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -27,7 +28,7 @@ func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) {
|
||||
StreamAppTokenExpiry: now.Add(3 * time.Minute),
|
||||
})
|
||||
|
||||
authn := &Authenticator{sessions: store, authService: nil}
|
||||
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Emby-Token", "valid-tok")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -62,7 +63,7 @@ func TestRequireSession_PassesThroughNonExpiringToken(t *testing.T) {
|
||||
StreamAppTokenExpiry: now.Add(30 * time.Minute),
|
||||
})
|
||||
|
||||
authn := &Authenticator{sessions: store}
|
||||
authn := &Authenticator{sessions: store, users: enabledUserLoader(1)}
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Emby-Token", "fresh-tok")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -97,7 +98,7 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
|
||||
StreamAppTokenExpiry: now.Add(-1 * time.Hour), // already expired
|
||||
})
|
||||
|
||||
authn := &Authenticator{sessions: store, authService: nil}
|
||||
authn := &Authenticator{sessions: store, authService: nil, users: enabledUserLoader(1)}
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Emby-Token", "expired-tok")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -119,6 +120,75 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
|
||||
}
|
||||
}
|
||||
|
||||
// enabledUserLoader returns a loader serving a single enabled, non-admin user.
|
||||
func enabledUserLoader(id int) auth.UserLoader {
|
||||
return &fakeAPIKeyUserLoader{user: &models.User{ID: id, Enabled: true}}
|
||||
}
|
||||
|
||||
func TestRequireSession_RejectsDisabledUser(t *testing.T) {
|
||||
now := fixedNow()
|
||||
clock := func() time.Time { return now }
|
||||
store := NewSessionStore(30*24*time.Hour, clock)
|
||||
_ = store.Put(Session{Token: "tok", StreamAppUserID: 1})
|
||||
|
||||
cases := map[string]auth.UserLoader{
|
||||
"disabled account": &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: false}},
|
||||
"deleted account": &fakeAPIKeyUserLoader{}, // loader returns auth.ErrNotFound
|
||||
"no loader wired": nil, // fail closed
|
||||
}
|
||||
for name, users := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
authn := &Authenticator{sessions: store, users: users}
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Emby-Token", "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
authn.RequireSession(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler should not run for a disabled/missing account")
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The compat session must survive: a transient lookup failure also
|
||||
// denies, and deleting here would force a re-login after recovery.
|
||||
if _, ok := store.Get("tok"); !ok {
|
||||
t.Fatal("compat session must not be deleted by a per-request denial")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionAuth_RejectsDisabledUser(t *testing.T) {
|
||||
now := fixedNow()
|
||||
clock := func() time.Time { return now }
|
||||
sessions := NewSessionStore(30*24*time.Hour, clock)
|
||||
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
|
||||
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
|
||||
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
|
||||
|
||||
disabled := &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: false}}
|
||||
mw := PlaybackSessionAuth(sessions, playbackStore, nil, disabled)
|
||||
|
||||
// Both the direct-token path and the PlaySessionId fallback must deny.
|
||||
for name, target := range map[string]string{
|
||||
"token": "/Videos/itm/stream?api_key=compat-tok",
|
||||
"play session": "/Videos/itm/stream?PlaySessionId=ps-abc",
|
||||
"lowercase psi": "/Videos/itm/stream?playSessionId=ps-abc",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", target, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler should not run for a disabled account")
|
||||
})).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
|
||||
now := fixedNow()
|
||||
clock := func() time.Time { return now }
|
||||
@@ -127,7 +197,7 @@ func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
|
||||
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
|
||||
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
|
||||
|
||||
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
|
||||
mw := PlaybackSessionAuth(sessions, playbackStore, nil, enabledUserLoader(1))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -173,10 +243,48 @@ func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPolicyFromEffectivePolicy(t *testing.T) {
|
||||
u := &models.User{
|
||||
ID: 7, Enabled: true, IsAdmin: false,
|
||||
LibraryIDs: []int{3, 5},
|
||||
DownloadAllowed: false,
|
||||
}
|
||||
policy := buildUserPolicy(u)
|
||||
if policy.IsAdministrator {
|
||||
t.Error("non-admin must not be administrator")
|
||||
}
|
||||
if policy.EnableAllFolders {
|
||||
t.Error("restricted user must not see all folders")
|
||||
}
|
||||
if policy.EnableContentDownloading {
|
||||
t.Error("downloads disabled must map through")
|
||||
}
|
||||
// Pin the Jellyfin folder GUID encoding: EncodeNumericID packs the
|
||||
// EncodedIDLibrary type byte (0x01) into byte 0 and the library ID
|
||||
// big-endian into bytes 8-15. Clients persist these GUIDs, so the
|
||||
// encoding must stay stable.
|
||||
wantFolders := []string{
|
||||
"01000000-0000-0000-0000-000000000003",
|
||||
"01000000-0000-0000-0000-000000000005",
|
||||
}
|
||||
if !slices.Equal(policy.EnabledFolders, wantFolders) {
|
||||
t.Errorf("enabled folders = %v, want %v", policy.EnabledFolders, wantFolders)
|
||||
}
|
||||
|
||||
admin := &models.User{ID: 1, Enabled: true, IsAdmin: true, LibraryIDs: nil, DownloadAllowed: true}
|
||||
adminPolicy := buildUserPolicy(admin)
|
||||
if !adminPolicy.IsAdministrator || !adminPolicy.EnableAllFolders || !adminPolicy.EnableContentDownloading {
|
||||
t.Error("admin policy must be unrestricted")
|
||||
}
|
||||
if len(adminPolicy.EnabledFolders) != 0 {
|
||||
t.Errorf("unrestricted policy must not enumerate folders, got %v", adminPolicy.EnabledFolders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
|
||||
authn := newAdminAPIKeyAuthForTest(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
@@ -197,7 +305,7 @@ func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
|
||||
func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
|
||||
authn := newAdminAPIKeyAuthForTest(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("POST", "/Library/Media/Updated", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
@@ -215,7 +323,7 @@ func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
|
||||
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
|
||||
authn := newAdminAPIKeyAuthForTest(
|
||||
&fakeAPIKeyValidator{returnNilWithoutError: true},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
@@ -241,7 +349,7 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
||||
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, IsAdmin: true, Enabled: true}},
|
||||
)
|
||||
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
|
||||
req.Header.Set("X-Emby-Token", "sa_test")
|
||||
@@ -266,7 +374,7 @@ func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
|
||||
|
||||
// newAdminAPIKeyAuthForTest builds an authenticator without a UserStoreProvider,
|
||||
// exercising the admin-bool path only (no session synthesis).
|
||||
func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator {
|
||||
func newAdminAPIKeyAuthForTest(keys apiKeyValidator, users auth.UserLoader) *AdminAPIKeyAuthenticator {
|
||||
return NewAdminAPIKeyAuthenticator(keys, users, nil, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,18 +42,10 @@ func TestHandleDownload_ServesOriginalFile(t *testing.T) {
|
||||
codec: codec,
|
||||
content: &stubContentService{detail: detail},
|
||||
fileResolver: testCompatFileResolver{file: &models.MediaFile{ID: 42, FilePath: filePath}},
|
||||
users: &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: true, DownloadAllowed: true}},
|
||||
}
|
||||
|
||||
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
|
||||
req := httptest.NewRequest("GET", "/Items/"+encodedID+"/Download", nil)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", encodedID)
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
|
||||
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleDownload(rec, req)
|
||||
rec := serveDownloadRequest(handler, codec, contentID)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
@@ -66,6 +58,57 @@ func TestHandleDownload_ServesOriginalFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDownload_ForbiddenWhenDownloadsDisabled verifies the download
|
||||
// route enforces the group-derived DownloadAllowed policy from a fresh user
|
||||
// load and returns 403 for restricted users.
|
||||
func TestHandleDownload_ForbiddenWhenDownloadsDisabled(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
handler := &PlaybackHandler{
|
||||
codec: codec,
|
||||
content: &stubContentService{},
|
||||
users: &fakeAPIKeyUserLoader{user: &models.User{ID: 1, Enabled: true, DownloadAllowed: false}},
|
||||
}
|
||||
|
||||
rec := serveDownloadRequest(handler, codec, "movie-1")
|
||||
|
||||
if rec.Code != 403 {
|
||||
t.Fatalf("expected status 403; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDownload_FailsClosedWhenUserLoadFails verifies a user-load error
|
||||
// never falls back to permissive defaults.
|
||||
func TestHandleDownload_FailsClosedWhenUserLoadFails(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
handler := &PlaybackHandler{
|
||||
codec: codec,
|
||||
content: &stubContentService{},
|
||||
users: &fakeAPIKeyUserLoader{}, // GetByID errors for every ID
|
||||
}
|
||||
|
||||
rec := serveDownloadRequest(handler, codec, "movie-1")
|
||||
|
||||
if rec.Code != 500 {
|
||||
t.Fatalf("expected status 500; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// serveDownloadRequest issues GET /Items/{id}/Download for user 1 against
|
||||
// the handler and returns the recorded response.
|
||||
func serveDownloadRequest(handler *PlaybackHandler, codec *ResourceIDCodec, contentID string) *httptest.ResponseRecorder {
|
||||
encodedID := codec.EncodeStringID(EncodedIDItem, contentID)
|
||||
req := httptest.NewRequest("GET", "/Items/"+encodedID+"/Download", nil)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", encodedID)
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
|
||||
ctx = context.WithValue(ctx, compatSessionKey, &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleDownload(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestItemDetail_AdvertisesCanDownload guards against regressing the
|
||||
// CanDownload flag: Infuse refuses Direct Play (Static=true streaming) of
|
||||
// items it believes it cannot download, so playable items must advertise it.
|
||||
|
||||
@@ -3,13 +3,18 @@ package jellycompat
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type authenticateByNameRequest struct {
|
||||
@@ -118,14 +123,17 @@ type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
loginResolver loginResolver
|
||||
authenticator *Authenticator
|
||||
users auth.UserLoader
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler.
|
||||
func NewAuthHandler(cfg *config.Config, loginResolver loginResolver, authenticator *Authenticator) *AuthHandler {
|
||||
// NewAuthHandler creates a new auth handler. users supplies the freshly
|
||||
// loaded effective policy for user DTOs; user routes fail when it is absent.
|
||||
func NewAuthHandler(cfg *config.Config, loginResolver loginResolver, authenticator *Authenticator, users auth.UserLoader) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
cfg: cfg,
|
||||
loginResolver: loginResolver,
|
||||
authenticator: authenticator,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +166,17 @@ func (h *AuthHandler) HandleAuthenticateByName(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
dto, err := h.userDTO(r.Context(), session)
|
||||
if err != nil {
|
||||
slog.Error("jellycompat login user dto failed", "user_id", session.StreamAppUserID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, authenticateByNameResponse{
|
||||
AccessToken: session.Token,
|
||||
ServerID: h.cfg.JellyfinCompat.ServerID,
|
||||
User: h.userDTO(session),
|
||||
User: dto,
|
||||
SessionInfo: h.sessionInfo(session),
|
||||
})
|
||||
}
|
||||
@@ -173,7 +188,13 @@ func (h *AuthHandler) HandleCurrentUser(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.userDTO(session))
|
||||
dto, err := h.userDTO(r.Context(), session)
|
||||
if err != nil {
|
||||
slog.Error("jellycompat current user dto failed", "user_id", session.StreamAppUserID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// HandleUsers serves GET /Users.
|
||||
@@ -190,7 +211,13 @@ func (h *AuthHandler) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []userDTOResponse{h.userDTO(session)})
|
||||
dto, err := h.userDTO(r.Context(), session)
|
||||
if err != nil {
|
||||
slog.Error("jellycompat users list dto failed", "user_id", session.StreamAppUserID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []userDTOResponse{dto})
|
||||
}
|
||||
|
||||
// HandleUserByID serves GET /Users/{id}.
|
||||
@@ -205,7 +232,13 @@ func (h *AuthHandler) HandleUserByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "NotFound", "User not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.userDTO(session))
|
||||
dto, err := h.userDTO(r.Context(), session)
|
||||
if err != nil {
|
||||
slog.Error("jellycompat user by id dto failed", "user_id", session.StreamAppUserID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load user")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// HandleLogout serves POST /Sessions/Logout.
|
||||
@@ -219,50 +252,33 @@ func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
|
||||
name := session.Username
|
||||
// userDTO builds the Jellyfin user DTO for a compat session. The user is
|
||||
// loaded fresh on every call so the policy always reflects the current
|
||||
// group-derived effective policy: compat sessions are long-lived (and
|
||||
// persisted without policy), so anything cached at login would go stale the
|
||||
// moment group membership changes. Callers must fail the request on error
|
||||
// rather than fall back to permissive defaults. These are cold paths (login,
|
||||
// /Users/Me, /Users listings), so the extra query is fine.
|
||||
func (h *AuthHandler) userDTO(ctx context.Context, session *Session) (userDTOResponse, error) {
|
||||
if h.users == nil {
|
||||
return userDTOResponse{}, errors.New("user loader not configured")
|
||||
}
|
||||
user, err := h.users.GetByID(ctx, session.StreamAppUserID)
|
||||
if err != nil {
|
||||
return userDTOResponse{}, fmt.Errorf("load user %d: %w", session.StreamAppUserID, err)
|
||||
}
|
||||
if user == nil {
|
||||
return userDTOResponse{}, fmt.Errorf("user %d not found", session.StreamAppUserID)
|
||||
}
|
||||
|
||||
return userDTOResponse{
|
||||
ID: session.PseudoUserID.String(),
|
||||
Name: name,
|
||||
Name: session.Username,
|
||||
ServerID: h.cfg.JellyfinCompat.ServerID,
|
||||
HasPassword: true,
|
||||
HasConfiguredPassword: true,
|
||||
HasConfiguredEasyPassword: false,
|
||||
Policy: userPolicyResponse{
|
||||
IsAdministrator: false,
|
||||
IsHidden: false,
|
||||
EnableCollectionManagement: false,
|
||||
EnableSubtitleManagement: false,
|
||||
EnableLyricManagement: false,
|
||||
IsDisabled: false,
|
||||
EnableUserPreferenceAccess: true,
|
||||
EnableRemoteControlOfOtherUsers: false,
|
||||
EnableSharedDeviceControl: false,
|
||||
EnableRemoteAccess: true,
|
||||
EnableLiveTVManagement: false,
|
||||
EnableLiveTVAccess: false,
|
||||
EnableMediaPlayback: true,
|
||||
EnableAudioPlaybackTranscoding: true,
|
||||
EnableVideoPlaybackTranscoding: true,
|
||||
EnablePlaybackRemuxing: true,
|
||||
ForceRemoteSourceTranscoding: false,
|
||||
EnableContentDeletion: false,
|
||||
EnableContentDownloading: true,
|
||||
EnableSyncTranscoding: false,
|
||||
EnableMediaConversion: false,
|
||||
EnableAllDevices: true,
|
||||
EnableAllChannels: false,
|
||||
EnableAllFolders: true,
|
||||
InvalidLoginAttemptCount: 0,
|
||||
LoginAttemptsBeforeLockout: 0,
|
||||
MaxActiveSessions: 0,
|
||||
EnablePublicSharing: false,
|
||||
RemoteClientBitrateLimit: 0,
|
||||
AuthenticationProviderID: "silo-local",
|
||||
PasswordResetProviderID: "silo-local",
|
||||
SyncPlayAccess: "None",
|
||||
},
|
||||
Policy: buildUserPolicy(user),
|
||||
Configuration: userConfigurationResponse{
|
||||
PlayDefaultAudioTrack: true,
|
||||
DisplayMissingEpisodes: false,
|
||||
@@ -278,7 +294,54 @@ func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
|
||||
RememberSubtitleSelections: true,
|
||||
EnableNextEpisodeAutoPlay: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildUserPolicy maps Silo's group-derived effective policy onto the
|
||||
// Jellyfin UserPolicy shape.
|
||||
func buildUserPolicy(u *models.User) userPolicyResponse {
|
||||
policy := userPolicyResponse{
|
||||
IsAdministrator: u.IsAdmin,
|
||||
IsHidden: false,
|
||||
EnableCollectionManagement: false,
|
||||
EnableSubtitleManagement: false,
|
||||
EnableLyricManagement: false,
|
||||
IsDisabled: false,
|
||||
EnableUserPreferenceAccess: true,
|
||||
EnableRemoteControlOfOtherUsers: false,
|
||||
EnableSharedDeviceControl: false,
|
||||
EnableRemoteAccess: true,
|
||||
EnableLiveTVManagement: false,
|
||||
EnableLiveTVAccess: false,
|
||||
EnableMediaPlayback: true,
|
||||
EnableAudioPlaybackTranscoding: true,
|
||||
EnableVideoPlaybackTranscoding: true,
|
||||
EnablePlaybackRemuxing: true,
|
||||
ForceRemoteSourceTranscoding: false,
|
||||
EnableContentDeletion: false,
|
||||
EnableContentDownloading: u.DownloadAllowed,
|
||||
EnableSyncTranscoding: false,
|
||||
EnableMediaConversion: false,
|
||||
EnableAllDevices: true,
|
||||
EnableAllChannels: false,
|
||||
EnableAllFolders: u.LibraryIDs == nil,
|
||||
InvalidLoginAttemptCount: 0,
|
||||
LoginAttemptsBeforeLockout: 0,
|
||||
MaxActiveSessions: 0,
|
||||
EnablePublicSharing: false,
|
||||
RemoteClientBitrateLimit: 0,
|
||||
AuthenticationProviderID: "silo-local",
|
||||
PasswordResetProviderID: "silo-local",
|
||||
SyncPlayAccess: "None",
|
||||
}
|
||||
if u.LibraryIDs != nil {
|
||||
folders := make([]string, len(u.LibraryIDs))
|
||||
for i, id := range u.LibraryIDs {
|
||||
folders[i] = EncodeNumericID(EncodedIDLibrary, uint64(id)).String()
|
||||
}
|
||||
policy.EnabledFolders = folders
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (h *AuthHandler) sessionInfo(session *Session) *sessionInfoResponse {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -32,6 +33,10 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// audioSpatialFormatNone is the Jellyfin AudioSpatialFormat value for streams
|
||||
// without spatial audio metadata.
|
||||
const audioSpatialFormatNone = "None"
|
||||
|
||||
type playbackInfoRequest struct {
|
||||
UserID string `json:"UserId"`
|
||||
MediaSourceID string `json:"MediaSourceId"`
|
||||
@@ -119,6 +124,7 @@ type PlaybackHandler struct {
|
||||
sessionMgr SessionManagerInterface
|
||||
fileResolver FilePathResolver
|
||||
storeProvider userstore.UserStoreProvider
|
||||
users auth.UserLoader // fresh effective-policy loads for download enforcement
|
||||
NodePlanner nodepool.SessionPlanner
|
||||
JWTSecret string
|
||||
profileStaler profileStaler
|
||||
@@ -664,7 +670,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
|
||||
ColorSpace: track.ColorSpace,
|
||||
ColorTransfer: track.ColorTransfer,
|
||||
PixelFormat: track.PixelFormat,
|
||||
AudioSpatialFormat: "None",
|
||||
AudioSpatialFormat: audioSpatialFormatNone,
|
||||
AverageFrameRate: parseCompatFrameRate(track.FrameRate),
|
||||
RealFrameRate: parseCompatFrameRate(track.FrameRate),
|
||||
ReferenceFrameRate: parseCompatFrameRate(track.FrameRate),
|
||||
@@ -694,7 +700,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
|
||||
IsHearingImpaired: false,
|
||||
IsTextSubtitleStream: false,
|
||||
SupportsExternalStream: false,
|
||||
AudioSpatialFormat: "None",
|
||||
AudioSpatialFormat: audioSpatialFormatNone,
|
||||
Channels: track.Channels,
|
||||
BitRate: track.Bitrate,
|
||||
})
|
||||
@@ -721,7 +727,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
|
||||
IsHearingImpaired: track.HearingImpaired,
|
||||
IsTextSubtitleStream: true,
|
||||
SupportsExternalStream: track.External,
|
||||
AudioSpatialFormat: "None",
|
||||
AudioSpatialFormat: audioSpatialFormatNone,
|
||||
DeliveryURL: subtitleDeliveryURL(routeItemID, mediaSourceID, streamIndex, format, compatToken, playSessionID),
|
||||
DeliveryMethod: subtitleDeliveryMethod(track.External),
|
||||
Path: subtitlePath(track, routeItemID, mediaSourceID, streamIndex, format),
|
||||
|
||||
@@ -66,7 +66,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if err != nil {
|
||||
slog.Error("jellycompat web bundle unavailable", "error", err)
|
||||
}
|
||||
authHandler := NewAuthHandler(deps.Config, deps.LoginResolver, deps.Authenticator)
|
||||
authHandler := NewAuthHandler(deps.Config, deps.LoginResolver, deps.Authenticator, deps.UserLoader)
|
||||
nextUpRepo := catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
|
||||
var subtitleRepo subtitles.Repository
|
||||
if deps.SubtitleRepo != nil {
|
||||
@@ -82,7 +82,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
itemsHandler.posterPresigner = deps.PosterPresigner
|
||||
itemsHandler.presignTTL = deps.PresignTTL
|
||||
autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler)
|
||||
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader, deps.UserStoreProvider, deps.Now)
|
||||
adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.UserLoader, deps.UserStoreProvider, deps.Now)
|
||||
autoscanVirtualFoldersRegistered := false
|
||||
if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil {
|
||||
r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)).
|
||||
@@ -93,6 +93,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
|
||||
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
|
||||
playbackHandler.users = deps.UserLoader
|
||||
if deps.DB != nil {
|
||||
playbackHandler.profileStaler = recommendations.NewRepo(deps.DB)
|
||||
}
|
||||
@@ -216,7 +217,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Stream routes: use playback-session auth fallback for media players
|
||||
// (e.g. libmpv) that don't forward auth headers or query parameters.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth))
|
||||
r.Use(PlaybackSessionAuth(deps.SessionStore, deps.PlaybackStore, adminAPIKeyAuth, deps.UserLoader))
|
||||
r.Method(http.MethodHead, "/Items/{id}/Download", http.HandlerFunc(playbackHandler.HandleDownload))
|
||||
r.Get("/Items/{id}/Download", playbackHandler.HandleDownload)
|
||||
r.Method(http.MethodHead, "/Videos/{id}/stream", http.HandlerFunc(playbackHandler.HandleVideoStream))
|
||||
@@ -334,7 +335,7 @@ func withDefaults(deps Dependencies) Dependencies {
|
||||
}
|
||||
|
||||
if deps.Authenticator == nil && deps.SessionStore != nil {
|
||||
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService)
|
||||
deps.Authenticator = NewAuthenticator(deps.SessionStore, deps.AuthService, deps.UserLoader)
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
@@ -45,9 +45,11 @@ type Dependencies struct {
|
||||
AuthService *auth.Service
|
||||
|
||||
// Autoscan / admin compatibility support.
|
||||
APIKeyValidator apiKeyValidator
|
||||
APIKeyUserLoader apiKeyUserLoader
|
||||
ScanQueue scantrigger.Queuer
|
||||
APIKeyValidator apiKeyValidator
|
||||
// UserLoader loads users with their effective policy hydrated. Required
|
||||
// for API-key auth, user DTO policy, and download permission enforcement.
|
||||
UserLoader auth.UserLoader
|
||||
ScanQueue scantrigger.Queuer
|
||||
|
||||
// Catalog repos (for ContentService construction)
|
||||
BrowseRepo *catalog.BrowseRepository
|
||||
|
||||
@@ -122,6 +122,10 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque
|
||||
// load-bearing for Infuse: it refuses Direct Play (Static=true streaming)
|
||||
// for items it believes it cannot download, so the flag must stay true and
|
||||
// this route must exist.
|
||||
//
|
||||
// The requesting user's group-derived DownloadAllowed policy is enforced
|
||||
// here from a fresh user load (never a session-cached value), failing closed
|
||||
// on any load error. For allowed users the route behavior is unchanged.
|
||||
func (h *PlaybackHandler) HandleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
session := SessionFromContext(r.Context())
|
||||
if session == nil {
|
||||
@@ -129,6 +133,21 @@ func (h *PlaybackHandler) HandleDownload(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if h.users == nil {
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "User loader not available")
|
||||
return
|
||||
}
|
||||
user, err := h.users.GetByID(r.Context(), session.StreamAppUserID)
|
||||
if err != nil || user == nil {
|
||||
slog.Error("jellycompat download user load failed", "user_id", session.StreamAppUserID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to resolve user")
|
||||
return
|
||||
}
|
||||
if !user.DownloadAllowed {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "Downloads are not permitted for this user")
|
||||
return
|
||||
}
|
||||
|
||||
contentID, err := decodeContentID(h.codec, chiURLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Built-in group slugs. Built-in groups cannot be deleted; the
|
||||
// administrators group cannot lose the admin permission or its last
|
||||
// enabled member.
|
||||
const (
|
||||
GroupSlugAdministrators = "administrators"
|
||||
GroupSlugUsers = "users"
|
||||
)
|
||||
|
||||
// Group represents a row in the groups table. Groups are the only source of
|
||||
// authorization policy: a user's effective policy is the most-permissive
|
||||
// union of their groups.
|
||||
type Group struct {
|
||||
ID int
|
||||
Slug string
|
||||
Name string
|
||||
Description string
|
||||
BuiltIn bool
|
||||
Permissions []string
|
||||
LibraryIDs []int // nil = all libraries
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
MaxProfiles int
|
||||
MaxPlaybackQuality string // "" = unrestricted
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGroupInput contains the fields to create a new group. The slug is
|
||||
// derived from Name. Nil pointer fields use the DB defaults.
|
||||
type CreateGroupInput struct {
|
||||
Name string // required
|
||||
Description string
|
||||
Permissions []string
|
||||
LibraryIDs []int // nil = all libraries
|
||||
MaxStreams *int
|
||||
MaxTranscodes *int
|
||||
MaxProfiles *int
|
||||
MaxPlaybackQuality *string
|
||||
DownloadAllowed *bool
|
||||
DownloadTranscodeAllowed *bool
|
||||
}
|
||||
|
||||
// UpdateGroupInput contains optional fields for updating a group.
|
||||
// Nil means "don't update". LibraryIDs follows the same convention
|
||||
// models.UpdateUserInput uses: a non-nil pointer to a nil slice means
|
||||
// "all libraries"; a pointer to an empty slice means "none".
|
||||
type UpdateGroupInput struct {
|
||||
Name *string
|
||||
Description *string
|
||||
Permissions *[]string
|
||||
LibraryIDs *[]int // nil = don't update; *nil = all; *[] = none
|
||||
MaxStreams *int
|
||||
MaxTranscodes *int
|
||||
MaxProfiles *int
|
||||
MaxPlaybackQuality *string
|
||||
DownloadAllowed *bool
|
||||
DownloadTranscodeAllowed *bool
|
||||
}
|
||||
+26
-32
@@ -2,43 +2,44 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// User represents a row in the users table.
|
||||
// User represents a row in the users table plus the effective access policy
|
||||
// derived from the user's group memberships at load time.
|
||||
type User struct {
|
||||
ID int
|
||||
Email string
|
||||
Username string
|
||||
PasswordHash string
|
||||
LocalPasswordLoginEnabled bool
|
||||
Role string
|
||||
Permissions []string
|
||||
Enabled bool
|
||||
LibraryIDs []int // nullable in PG (nil = all libraries)
|
||||
MaxPlaybackQuality string
|
||||
AccessPolicyRevision int64
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
MaxProfiles int
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// IsAdmin, GroupIDs, and the policy fields below are derived from group
|
||||
// membership at load time (see auth.ApplyEffectivePolicy).
|
||||
IsAdmin bool
|
||||
GroupIDs []int
|
||||
Permissions []string
|
||||
Enabled bool
|
||||
LibraryIDs []int // effective; nil = all libraries
|
||||
MaxPlaybackQuality string
|
||||
AccessPolicyRevision int64
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
MaxProfiles int
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
// Groups carries the loaded group memberships (set at load time, not a column).
|
||||
Groups []Group
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateUserInput contains the fields required to create a new user.
|
||||
// Access policy comes from group memberships, not per-user columns.
|
||||
type CreateUserInput struct {
|
||||
Email string // required
|
||||
Username string // required
|
||||
Password string // plaintext, will be bcrypt-hashed
|
||||
LocalPasswordLoginEnabled *bool
|
||||
Role string // e.g. "admin", "user"
|
||||
Permissions []string
|
||||
LibraryIDs []int
|
||||
MaxPlaybackQuality string
|
||||
MaxStreams *int // nil = use DB default (6)
|
||||
MaxTranscodes *int // nil = use DB default (2)
|
||||
MaxProfiles *int // nil = use DB default (5); minimum 1
|
||||
DownloadAllowed *bool // nil = use DB default (true)
|
||||
DownloadTranscodeAllowed *bool // nil = use DB default (false)
|
||||
// GroupIDs are the group memberships to create. nil means the caller
|
||||
// resolves defaults via the AccountProvisioner.
|
||||
GroupIDs []int
|
||||
}
|
||||
|
||||
// UpdateUserInput contains optional fields for updating a user.
|
||||
@@ -48,14 +49,7 @@ type UpdateUserInput struct {
|
||||
Username *string
|
||||
Password *string // plaintext, will be bcrypt-hashed if provided
|
||||
LocalPasswordLoginEnabled *bool
|
||||
Role *string
|
||||
Permissions *[]string
|
||||
Enabled *bool
|
||||
LibraryIDs *[]int
|
||||
MaxPlaybackQuality *string
|
||||
MaxStreams *int
|
||||
MaxTranscodes *int
|
||||
MaxProfiles *int
|
||||
DownloadAllowed *bool
|
||||
DownloadTranscodeAllowed *bool
|
||||
// GroupIDs replaces the user's group memberships. nil = don't update.
|
||||
GroupIDs *[]int
|
||||
}
|
||||
|
||||
@@ -388,6 +388,9 @@ type CommandIssuedBy struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// IssuedByKindAdmin marks commands issued by an administrator action.
|
||||
const IssuedByKindAdmin = "admin"
|
||||
|
||||
// NewCommandEnvelope creates a validated command envelope.
|
||||
func NewCommandEnvelope(sessionID, commandID string, name CommandName, payload json.RawMessage) (CommandEnvelope, error) {
|
||||
normalizedPayload, err := normalizeJSONPayload(payload)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE groups (
|
||||
id serial PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
built_in boolean NOT NULL DEFAULT false,
|
||||
permissions text[] NOT NULL DEFAULT '{}',
|
||||
library_ids integer[],
|
||||
max_streams integer NOT NULL DEFAULT 6,
|
||||
max_transcodes integer NOT NULL DEFAULT 2,
|
||||
max_profiles integer NOT NULL DEFAULT 5,
|
||||
max_playback_quality text NOT NULL DEFAULT '',
|
||||
download_allowed boolean NOT NULL DEFAULT true,
|
||||
download_transcode_allowed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE user_groups (
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
|
||||
|
||||
INSERT INTO groups (slug, name, description, built_in, permissions)
|
||||
VALUES
|
||||
('administrators', 'Administrators',
|
||||
'Full access to everything, including server administration.', true,
|
||||
ARRAY['admin']),
|
||||
('users', 'Users',
|
||||
'Default group for new accounts.', true,
|
||||
ARRAY['marker_edit']);
|
||||
|
||||
-- Admins -> administrators (admin implies unrestricted policy).
|
||||
INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT u.id, g.id
|
||||
FROM users u
|
||||
JOIN groups g ON g.slug = 'administrators'
|
||||
WHERE u.role = 'admin';
|
||||
|
||||
-- Non-admin users whose policy deviates from the defaults: bucket by
|
||||
-- distinct policy tuple, one group per tuple, members join ONLY that group
|
||||
-- (joining 'users' too would erase restrictions via the permissive union).
|
||||
WITH outliers AS (
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
WHERE u.role IS DISTINCT FROM 'admin'
|
||||
AND (
|
||||
COALESCE(u.permissions, '{}') IS DISTINCT FROM ARRAY['marker_edit']::text[]
|
||||
OR u.library_ids IS NOT NULL
|
||||
OR u.max_streams IS DISTINCT FROM 6
|
||||
OR u.max_transcodes IS DISTINCT FROM 2
|
||||
OR u.max_profiles IS DISTINCT FROM 5
|
||||
OR COALESCE(u.max_playback_quality, '') IS DISTINCT FROM ''
|
||||
OR u.download_allowed IS DISTINCT FROM true
|
||||
OR u.download_transcode_allowed IS DISTINCT FROM false
|
||||
)
|
||||
),
|
||||
buckets AS (
|
||||
SELECT o.id AS user_id,
|
||||
dense_rank() OVER (
|
||||
ORDER BY o.permissions, o.library_ids, o.max_streams,
|
||||
o.max_transcodes, o.max_profiles,
|
||||
o.max_playback_quality, o.download_allowed,
|
||||
o.download_transcode_allowed
|
||||
) AS n,
|
||||
o.permissions, o.library_ids, o.max_streams, o.max_transcodes,
|
||||
o.max_profiles, o.max_playback_quality, o.download_allowed,
|
||||
o.download_transcode_allowed
|
||||
FROM outliers o
|
||||
),
|
||||
distinct_buckets AS (
|
||||
SELECT DISTINCT ON (n) n, permissions, library_ids, max_streams,
|
||||
max_transcodes, max_profiles, max_playback_quality,
|
||||
download_allowed, download_transcode_allowed
|
||||
FROM buckets
|
||||
),
|
||||
created AS (
|
||||
INSERT INTO groups (slug, name, description, built_in, permissions,
|
||||
library_ids, max_streams, max_transcodes,
|
||||
max_profiles, max_playback_quality, download_allowed,
|
||||
download_transcode_allowed)
|
||||
SELECT 'migrated-policy-' || n,
|
||||
'Migrated policy ' || n,
|
||||
'Auto-created during the group migration to preserve a pre-existing per-user policy.',
|
||||
false,
|
||||
array_remove(COALESCE(permissions, '{}'), 'admin'),
|
||||
library_ids,
|
||||
COALESCE(max_streams, 6),
|
||||
COALESCE(max_transcodes, 2),
|
||||
COALESCE(max_profiles, 5),
|
||||
COALESCE(max_playback_quality, ''),
|
||||
COALESCE(download_allowed, true),
|
||||
COALESCE(download_transcode_allowed, false)
|
||||
FROM distinct_buckets
|
||||
RETURNING id, slug
|
||||
)
|
||||
INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT b.user_id, c.id
|
||||
FROM buckets b
|
||||
JOIN created c ON c.slug = 'migrated-policy-' || b.n;
|
||||
|
||||
-- Everyone not yet in any group (non-admin, default policy) -> users.
|
||||
INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT u.id, g.id
|
||||
FROM users u
|
||||
JOIN groups g ON g.slug = 'users'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM user_groups ug WHERE ug.user_id = u.id);
|
||||
|
||||
ALTER TABLE users
|
||||
DROP COLUMN role,
|
||||
DROP COLUMN permissions,
|
||||
DROP COLUMN library_ids,
|
||||
DROP COLUMN max_streams,
|
||||
DROP COLUMN max_transcodes,
|
||||
DROP COLUMN max_profiles,
|
||||
DROP COLUMN max_playback_quality,
|
||||
DROP COLUMN download_allowed,
|
||||
DROP COLUMN download_transcode_allowed;
|
||||
|
||||
UPDATE users SET access_policy_revision = access_policy_revision + 1;
|
||||
|
||||
INSERT INTO server_settings (key, value)
|
||||
VALUES ('users.default_group_slugs', '["users"]')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE users
|
||||
ADD COLUMN role text,
|
||||
ADD COLUMN permissions text[] NOT NULL DEFAULT ARRAY['marker_edit']::text[],
|
||||
ADD COLUMN library_ids integer[],
|
||||
ADD COLUMN max_streams integer NOT NULL DEFAULT 6,
|
||||
ADD COLUMN max_transcodes integer NOT NULL DEFAULT 2,
|
||||
ADD COLUMN max_profiles integer NOT NULL DEFAULT 5,
|
||||
ADD COLUMN max_playback_quality text NOT NULL DEFAULT '',
|
||||
ADD COLUMN download_allowed boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN download_transcode_allowed boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Best-effort flattening of effective policy back onto users.
|
||||
UPDATE users u SET
|
||||
role = CASE WHEN EXISTS (
|
||||
SELECT 1 FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = u.id AND 'admin' = ANY(g.permissions)
|
||||
) THEN 'admin' ELSE 'user' END,
|
||||
permissions = COALESCE((
|
||||
SELECT array_agg(DISTINCT p ORDER BY p)
|
||||
FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id, unnest(g.permissions) AS p
|
||||
WHERE ug.user_id = u.id AND p <> 'admin'
|
||||
), '{}'),
|
||||
library_ids = CASE WHEN EXISTS (
|
||||
SELECT 1 FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = u.id
|
||||
AND (g.library_ids IS NULL OR 'admin' = ANY(g.permissions))
|
||||
) THEN NULL ELSE (
|
||||
SELECT array_agg(DISTINCT lid ORDER BY lid)
|
||||
FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id, unnest(g.library_ids) AS lid
|
||||
WHERE ug.user_id = u.id
|
||||
) END,
|
||||
max_streams = COALESCE((
|
||||
SELECT MAX(g.max_streams) FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id), 6),
|
||||
max_transcodes = COALESCE((
|
||||
SELECT MAX(g.max_transcodes) FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id), 2),
|
||||
max_profiles = COALESCE((
|
||||
SELECT MAX(g.max_profiles) FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id), 5),
|
||||
download_allowed = EXISTS (
|
||||
SELECT 1 FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = u.id AND g.download_allowed),
|
||||
download_transcode_allowed = EXISTS (
|
||||
SELECT 1 FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = u.id AND g.download_transcode_allowed),
|
||||
-- Flatten max_playback_quality: rank-order mirrors internal/access/quality.go
|
||||
-- ('' = unrestricted is most permissive; most permissive group wins).
|
||||
max_playback_quality = COALESCE((
|
||||
SELECT CASE WHEN bool_or(g.max_playback_quality = '') THEN ''
|
||||
ELSE split_part(MAX(CASE g.max_playback_quality
|
||||
WHEN '4320p' THEN '5:4320p'
|
||||
WHEN '2160p' THEN '4:2160p'
|
||||
WHEN '1080p' THEN '3:1080p'
|
||||
WHEN '720p' THEN '2:720p'
|
||||
WHEN '480p' THEN '1:480p'
|
||||
ELSE '0:' END), ':', 2)
|
||||
END
|
||||
FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = u.id), '');
|
||||
|
||||
DELETE FROM server_settings WHERE key = 'users.default_group_slugs';
|
||||
DROP TABLE user_groups;
|
||||
DROP TABLE groups;
|
||||
|
||||
UPDATE users SET access_policy_revision = access_policy_revision + 1;
|
||||
-- +goose StatementEnd
|
||||
@@ -42,6 +42,7 @@ import AdminDashboard from "@/pages/AdminDashboard";
|
||||
import AdminActivity from "@/pages/AdminActivity";
|
||||
import AdminLogs from "@/pages/AdminLogs";
|
||||
import AdminUsers from "@/pages/AdminUsers";
|
||||
import AdminGroups from "@/pages/AdminGroups";
|
||||
import AdminRequests from "@/pages/AdminRequests";
|
||||
import AdminAutoscan from "@/pages/AdminAutoscan";
|
||||
import AdminDevices from "@/pages/AdminDevices";
|
||||
@@ -392,6 +393,7 @@ function AppRoutes() {
|
||||
<Route path="history-import" element={<AdminHistoryImport />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="users/:id" element={<AdminUserDetail />} />
|
||||
<Route path="groups" element={<AdminGroups />} />
|
||||
<Route path="devices" element={<AdminDevices />} />
|
||||
<Route path="devices/:userId/:deviceId" element={<AdminDevices />} />
|
||||
<Route path="nodes" element={<AdminNodes />} />
|
||||
|
||||
+63
-18
@@ -2019,6 +2019,64 @@ export interface RequestListParams {
|
||||
}
|
||||
|
||||
// Admin
|
||||
export interface AdminGroupRef {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AdminGroup {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
built_in: boolean;
|
||||
permissions: string[];
|
||||
/** null = access to all libraries */
|
||||
library_ids: number[] | null;
|
||||
max_streams: number;
|
||||
max_transcodes: number;
|
||||
max_profiles: number;
|
||||
max_playback_quality: string;
|
||||
download_allowed: boolean;
|
||||
download_transcode_allowed: boolean;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
/** null/omitted = access to all libraries */
|
||||
library_ids?: number[] | null;
|
||||
max_streams?: number;
|
||||
max_transcodes?: number;
|
||||
max_profiles?: number;
|
||||
max_playback_quality?: string;
|
||||
download_allowed?: boolean;
|
||||
download_transcode_allowed?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateGroupRequest = Partial<CreateGroupRequest>;
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface GroupMembersPage {
|
||||
members: GroupMember[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// AdminUser policy fields (role, permissions, library_ids, max_*, download_*)
|
||||
// are READ-ONLY effective values derived from the user's groups.
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -2026,6 +2084,7 @@ export interface AdminUser {
|
||||
role: string;
|
||||
permissions: string[];
|
||||
enabled: boolean;
|
||||
groups: AdminGroupRef[];
|
||||
library_ids: number[] | null;
|
||||
max_playback_quality: string;
|
||||
max_streams: number;
|
||||
@@ -2042,33 +2101,19 @@ export interface CreateUserRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
permissions?: string[];
|
||||
create_default_profile?: boolean;
|
||||
default_profile_name?: string;
|
||||
library_ids?: number[] | null;
|
||||
max_playback_quality?: string;
|
||||
max_streams?: number;
|
||||
max_transcodes?: number;
|
||||
max_profiles?: number;
|
||||
download_allowed?: boolean;
|
||||
download_transcode_allowed?: boolean;
|
||||
/** Group memberships to assign. Omitted = configured default groups. */
|
||||
group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
enabled?: boolean;
|
||||
library_ids?: number[] | null;
|
||||
max_playback_quality?: string;
|
||||
max_streams?: number;
|
||||
max_transcodes?: number;
|
||||
max_profiles?: number;
|
||||
download_allowed?: boolean;
|
||||
download_transcode_allowed?: boolean;
|
||||
/** Replaces the user's group memberships. Omitted = unchanged. */
|
||||
group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LayoutPanelTop,
|
||||
PanelsTopLeft,
|
||||
Users,
|
||||
Shield,
|
||||
MonitorSmartphone,
|
||||
History,
|
||||
Captions,
|
||||
@@ -116,6 +117,7 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
|
||||
label: "Users",
|
||||
items: [
|
||||
{ label: "Users", icon: Users, href: "/admin/users" },
|
||||
{ label: "Groups", icon: Shield, href: "/admin/groups" },
|
||||
{ label: "Devices", icon: MonitorSmartphone, href: "/admin/devices" },
|
||||
{ label: "Playback History", icon: History, href: "/admin/history" },
|
||||
{ label: "History Import", icon: Download, href: "/admin/history-import" },
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { AdminGroup, AdminUser, CreateUserRequest, UpdateUserRequest } from "@/api/types";
|
||||
import { useAdminGroups } from "@/hooks/queries/admin/groups";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCreateUser, useUpdateUser } from "@/hooks/queries/admin/users";
|
||||
import { EffectiveAccessSummary } from "@/components/admin/EffectiveAccessSummary";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatLibraryAccess, formatLimit } from "@/lib/group-policy";
|
||||
import { formatPlaybackQualityPreset } from "@/lib/playback-quality";
|
||||
import { permissionLabel } from "@/lib/permissions";
|
||||
|
||||
/** Slug of the built-in group preselected for new users. */
|
||||
const USERS_GROUP_SLUG = "users";
|
||||
|
||||
const GROUP_UNION_HINT =
|
||||
"Users in multiple groups get the most permissive combination of their groups.";
|
||||
|
||||
function groupPolicySummary(group: AdminGroup): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
group.permissions.length === 0
|
||||
? "No permissions"
|
||||
: group.permissions.map(permissionLabel).join(", "),
|
||||
);
|
||||
parts.push(formatLibraryAccess(group.library_ids));
|
||||
parts.push(`${formatLimit(group.max_streams)} streams`);
|
||||
parts.push(`${formatPlaybackQualityPreset(group.max_playback_quality)} quality`);
|
||||
parts.push(group.download_allowed ? "Downloads" : "No downloads");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function GroupMembershipSelector({
|
||||
groups,
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: {
|
||||
groups: AdminGroup[];
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}) {
|
||||
if (groups.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">No groups available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="border-border flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{group.name}</span>
|
||||
{group.built_in && <Badge variant="secondary">Built-in</Badge>}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{groupPolicySummary(group)}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={selectedIds.includes(group.id)}
|
||||
aria-label={`Member of ${group.name}`}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(
|
||||
checked ? [...selectedIds, group.id] : selectedIds.filter((id) => id !== group.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/edit form for admin users. Identity fields are edited directly;
|
||||
* permissions, library access, and limits are managed via group membership.
|
||||
*/
|
||||
export function AdminUserForm({ user, onClose }: { user: AdminUser | null; onClose: () => void }) {
|
||||
const { data: groups, isLoading: groupsLoading, isError: groupsError } = useAdminGroups();
|
||||
const groupsLoaded = !groupsLoading && !groupsError && groups !== undefined;
|
||||
const [username, setUsername] = useState(user?.username ?? "");
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [enabled, setEnabled] = useState(user?.enabled ?? true);
|
||||
// null = untouched; new users fall back to the built-in default group once
|
||||
// the groups list loads.
|
||||
const [groupIds, setGroupIds] = useState<number[] | null>(
|
||||
user ? user.groups.map((g) => g.id) : null,
|
||||
);
|
||||
const defaultGroupIds = useMemo(() => {
|
||||
const usersGroup = groups?.find((g) => g.slug === USERS_GROUP_SLUG);
|
||||
return usersGroup ? [usersGroup.id] : [];
|
||||
}, [groups]);
|
||||
const selectedGroupIds = groupIds ?? defaultGroupIds;
|
||||
const usernameId = useId();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const enabledId = useId();
|
||||
const createMutation = useCreateUser();
|
||||
const updateMutation = useUpdateUser();
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (user) {
|
||||
const body: UpdateUserRequest = {
|
||||
username,
|
||||
email,
|
||||
enabled,
|
||||
group_ids: selectedGroupIds,
|
||||
};
|
||||
if (password) body.password = password;
|
||||
updateMutation.mutate({ id: user.id, body }, { onSuccess: onClose });
|
||||
} else {
|
||||
// Only include group_ids when the groups list has successfully loaded OR
|
||||
// the admin explicitly touched the selector. If groups are still loading
|
||||
// or errored and the selector was never touched, omit the field entirely
|
||||
// so the backend applies its own default-group provisioning.
|
||||
const includeGroupIds = groupsLoaded || groupIds !== null;
|
||||
const body: CreateUserRequest = {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
create_default_profile: true,
|
||||
...(includeGroupIds && { group_ids: selectedGroupIds }),
|
||||
};
|
||||
createMutation.mutate(body, { onSuccess: onClose });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
|
||||
<Tabs defaultValue="account" className="min-h-0 flex-1">
|
||||
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
|
||||
<TabsTrigger value="account" className="flex-none px-1">
|
||||
Account
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="flex-none px-1">
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<TabsContent value="account" className="mt-0 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={usernameId}>Username</Label>
|
||||
<Input
|
||||
id={usernameId}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={emailId}>Email</Label>
|
||||
<Input
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor={passwordId}>
|
||||
Password {user && "(leave blank to keep current)"}
|
||||
</Label>
|
||||
<Input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={!user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{user && (
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Account status</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Disable access without deleting the user.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={enabledId} className="text-xs">
|
||||
Enabled
|
||||
</Label>
|
||||
<Switch id={enabledId} checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="groups" className="mt-0 space-y-4">
|
||||
{groupsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[52px] w-full rounded-md" />
|
||||
<Skeleton className="h-[52px] w-full rounded-md" />
|
||||
<Skeleton className="h-[52px] w-full rounded-md" />
|
||||
</div>
|
||||
) : groupsError ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{"Couldn't load groups — the user will be created in the default group."}
|
||||
</p>
|
||||
) : (
|
||||
<GroupMembershipSelector
|
||||
groups={groups ?? []}
|
||||
selectedIds={selectedGroupIds}
|
||||
onChange={setGroupIds}
|
||||
/>
|
||||
)}
|
||||
{!groupsError && <p className="text-muted-foreground text-xs">{GROUP_UNION_HINT}</p>}
|
||||
{user && (
|
||||
<div className="space-y-2">
|
||||
<Label>Effective access</Label>
|
||||
<div className="border-border rounded-md border">
|
||||
<EffectiveAccessSummary user={user} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<div className="border-border mt-4 border-t pt-4">
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { AdminUser } from "@/api/types";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatPlaybackQualityPreset } from "@/lib/playback-quality";
|
||||
import { PERMISSION_ADMIN, permissionLabel } from "@/lib/permissions";
|
||||
|
||||
function formatUnlimited(value: number) {
|
||||
return value === 0 ? "Unlimited" : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only summary of a user's effective access (permissions, library
|
||||
* access, and limits) derived from group membership.
|
||||
*/
|
||||
export function EffectiveAccessSummary({ user }: { user: AdminUser }) {
|
||||
const { data: libraries = [] } = useAdminLibraries();
|
||||
|
||||
const libraryNames =
|
||||
user.library_ids === null
|
||||
? "All libraries"
|
||||
: user.library_ids.length === 0
|
||||
? "None"
|
||||
: user.library_ids
|
||||
.map((id) => libraries.find((l) => l.id === id)?.name ?? `#${id}`)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="divide-border divide-y">
|
||||
<SummaryRow label="Permissions">
|
||||
{user.permissions.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{user.permissions.map((permission) => (
|
||||
<Badge
|
||||
key={permission}
|
||||
variant={permission === PERMISSION_ADMIN ? "default" : "outline"}
|
||||
>
|
||||
{permissionLabel(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Library Access">{libraryNames}</SummaryRow>
|
||||
<SummaryRow label="Max Playback Quality">
|
||||
{formatPlaybackQualityPreset(user.max_playback_quality)}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Max Streams">{formatUnlimited(user.max_streams)}</SummaryRow>
|
||||
<SummaryRow label="Max Transcodes">{formatUnlimited(user.max_transcodes)}</SummaryRow>
|
||||
<SummaryRow label="Max Profiles">{formatUnlimited(user.max_profiles)}</SummaryRow>
|
||||
<SummaryRow label="Downloads">
|
||||
{user.download_allowed ? "Allowed" : "Not allowed"}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Download Transcode">
|
||||
{user.download_transcode_allowed ? "Allowed" : "Not allowed"}
|
||||
</SummaryRow>
|
||||
</div>
|
||||
<p className="text-muted-foreground px-4 py-2.5 text-xs">Derived from group membership.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
|
||||
<span className="text-muted-foreground shrink-0 text-sm">{label}</span>
|
||||
{typeof children === "string" ? (
|
||||
<span className="text-right text-sm font-medium">{children}</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import type {
|
||||
AdminGroup,
|
||||
CreateGroupRequest,
|
||||
GroupMembersPage,
|
||||
UpdateGroupRequest,
|
||||
} from "@/api/types";
|
||||
import { adminKeys } from "../keys";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const ADMIN_STALE_TIME = 30_000;
|
||||
|
||||
interface AdminGroupsResponse {
|
||||
groups: AdminGroup[];
|
||||
}
|
||||
|
||||
// Group mutations also invalidate the admin users caches: membership and
|
||||
// policy changes alter the effective fields on admin user rows.
|
||||
function invalidateGroupCaches(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
||||
}
|
||||
|
||||
export function useAdminGroups() {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.groups(),
|
||||
queryFn: () => api<AdminGroupsResponse>("/admin/groups").then((d) => d?.groups ?? []),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminGroup(id: number) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.groupDetail(id),
|
||||
queryFn: () => api<AdminGroup>(`/admin/groups/${id}`),
|
||||
enabled: id > 0,
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembers(id: number, offset = 0, limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.groupMembers(id, offset, limit),
|
||||
queryFn: () =>
|
||||
api<GroupMembersPage>(`/admin/groups/${id}/members?offset=${offset}&limit=${limit}`),
|
||||
enabled: id > 0,
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateGroupRequest) =>
|
||||
api<AdminGroup>("/admin/groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Group created");
|
||||
invalidateGroupCaches(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdateGroupRequest }) =>
|
||||
api<AdminGroup>(`/admin/groups/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Group updated");
|
||||
invalidateGroupCaches(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api(`/admin/groups/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
toast.success("Group deleted");
|
||||
invalidateGroupCaches(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddGroupMember() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, userId }: { groupId: number; userId: number }) =>
|
||||
api(`/admin/groups/${groupId}/members/${userId}`, { method: "PUT" }),
|
||||
onSuccess: () => {
|
||||
toast.success("Member added");
|
||||
invalidateGroupCaches(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to add member");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveGroupMember() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, userId }: { groupId: number; userId: number }) =>
|
||||
api(`/admin/groups/${groupId}/members/${userId}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
toast.success("Member removed");
|
||||
invalidateGroupCaches(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to remove member");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -46,11 +46,12 @@ function invalidateAdminDeviceCaches(
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.devices() });
|
||||
}
|
||||
|
||||
export function useAdminUsers() {
|
||||
export function useAdminUsers(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.users(),
|
||||
queryFn: () => api<AdminUser[]>("/admin/users").then((d) => d ?? []),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,6 +74,8 @@ export function useCreateUser() {
|
||||
onSuccess: () => {
|
||||
toast.success("User created");
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
||||
// Membership changes alter group member counts.
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
@@ -93,6 +96,8 @@ export function useUpdateUser() {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.userDetail(variables.id) });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.userProfiles(variables.id) });
|
||||
// group_ids changes alter group member counts.
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
@@ -107,6 +112,8 @@ export function useDeleteUser() {
|
||||
onSuccess: () => {
|
||||
toast.success("User deleted");
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
||||
// Deleting a user removes their group memberships.
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.groups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete");
|
||||
|
||||
@@ -325,6 +325,10 @@ export const adminKeys = {
|
||||
["admin", "users", userId, "deviceSettings", key] as const,
|
||||
userSubtitleDeviceSettings: (userId: number) =>
|
||||
["admin", "users", userId, "deviceSettings", "subtitle_appearance"] as const,
|
||||
groups: () => ["admin", "groups"] as const,
|
||||
groupDetail: (groupId: number) => ["admin", "groups", groupId] as const,
|
||||
groupMembers: (groupId: number, offset: number, limit: number) =>
|
||||
["admin", "groups", groupId, "members", offset, limit] as const,
|
||||
devices: () => ["admin", "devices"] as const,
|
||||
deviceDetail: (userId: number, deviceId: string) =>
|
||||
["admin", "devices", userId, deviceId] as const,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Shared formatters for group access-policy values in admin views.
|
||||
*/
|
||||
|
||||
/** Formats a numeric limit where 0 means unlimited. */
|
||||
export function formatLimit(value: number): string {
|
||||
return value === 0 ? "∞" : String(value);
|
||||
}
|
||||
|
||||
/** Formats library access where null means unrestricted. */
|
||||
export function formatLibraryAccess(ids: number[] | null): string {
|
||||
if (ids === null) return "All libraries";
|
||||
return `${ids.length} ${ids.length === 1 ? "library" : "libraries"}`;
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { User } from "@/api/types";
|
||||
|
||||
export const PERMISSION_ADMIN = "admin";
|
||||
export const PERMISSION_METADATA_CURATION = "metadata_curation";
|
||||
export const PERMISSION_MARKER_EDIT = "marker_edit";
|
||||
|
||||
const PERMISSION_LABELS: Record<string, string> = {
|
||||
[PERMISSION_ADMIN]: "Administrator",
|
||||
[PERMISSION_MARKER_EDIT]: "Marker Editing",
|
||||
[PERMISSION_METADATA_CURATION]: "Metadata Curation",
|
||||
};
|
||||
|
||||
export function permissionLabel(permission: string) {
|
||||
return PERMISSION_LABELS[permission] ?? permission;
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
user: Pick<User, "role" | "permissions"> | null | undefined,
|
||||
permission: string,
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import { useMemo, useState, useId } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { AdminGroup, CreateGroupRequest, UpdateGroupRequest } from "@/api/types";
|
||||
import {
|
||||
useAdminGroups,
|
||||
useGroupMembers,
|
||||
useCreateGroup,
|
||||
useUpdateGroup,
|
||||
useDeleteGroup,
|
||||
useAddGroupMember,
|
||||
useRemoveGroupMember,
|
||||
} from "@/hooks/queries/admin/groups";
|
||||
import { useAdminUsers } from "@/hooks/queries/admin/users";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Check, Minus, Pencil, Plus, Search, Trash2, UserPlus, X } from "lucide-react";
|
||||
import {
|
||||
PLAYBACK_QUALITY_OPTIONS,
|
||||
formatPlaybackQualityPreset,
|
||||
playbackQualityPresetFromValue,
|
||||
playbackQualityValueFromPreset,
|
||||
type PlaybackQualityPreset,
|
||||
} from "@/lib/playback-quality";
|
||||
import { formatLibraryAccess, formatLimit } from "@/lib/group-policy";
|
||||
import {
|
||||
PERMISSION_ADMIN,
|
||||
PERMISSION_MARKER_EDIT,
|
||||
PERMISSION_METADATA_CURATION,
|
||||
hasAssignedPermission,
|
||||
permissionLabel,
|
||||
setAssignedPermission,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
const ADMINISTRATORS_SLUG = "administrators";
|
||||
const MEMBERS_PAGE_SIZE = 50;
|
||||
|
||||
const PERMISSION_OPTIONS: Array<{ id: string; label: string; description: string }> = [
|
||||
{
|
||||
id: PERMISSION_ADMIN,
|
||||
label: permissionLabel(PERMISSION_ADMIN),
|
||||
description: "Full access to all features and settings.",
|
||||
},
|
||||
{
|
||||
id: PERMISSION_MARKER_EDIT,
|
||||
label: permissionLabel(PERMISSION_MARKER_EDIT),
|
||||
description: "Edit intro, recap, credits, and preview markers within assigned libraries.",
|
||||
},
|
||||
{
|
||||
id: PERMISSION_METADATA_CURATION,
|
||||
label: permissionLabel(PERMISSION_METADATA_CURATION),
|
||||
description: "Edit, refresh, and rematch metadata within assigned libraries.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminGroups() {
|
||||
const { data: groups = [], isLoading } = useAdminGroups();
|
||||
const deleteMutation = useDeleteGroup();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<AdminGroup | null>(null);
|
||||
const [confirmDeleteGroup, setConfirmDeleteGroup] = useState<AdminGroup | null>(null);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteGroup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDeleteGroup(null);
|
||||
}}
|
||||
title="Delete group"
|
||||
description={`Delete group "${confirmDeleteGroup?.name}"? Members lose this group's permissions and limits. This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
if (confirmDeleteGroup) deleteMutation.mutate(confirmDeleteGroup.id);
|
||||
setConfirmDeleteGroup(null);
|
||||
}}
|
||||
/>
|
||||
<div className="page-header">
|
||||
<div className="space-y-3">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Groups</h1>
|
||||
<p className="page-subtitle text-sm sm:text-base">
|
||||
Define permissions, library access, and playback limits shared by their members.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setEditingGroup(null);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" /> Add Group
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? "Edit Group" : "Create Group"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<GroupForm
|
||||
group={editingGroup}
|
||||
onClose={() => {
|
||||
setDialogOpen(false);
|
||||
setEditingGroup(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Members</TableHead>
|
||||
<TableHead>Permissions</TableHead>
|
||||
<TableHead>Libraries</TableHead>
|
||||
<TableHead>Streams / Transcodes</TableHead>
|
||||
<TableHead>Quality</TableHead>
|
||||
<TableHead>Downloads</TableHead>
|
||||
<TableHead className="w-20">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{group.name}</span>
|
||||
{group.built_in && <Badge variant="secondary">Built-in</Badge>}
|
||||
</div>
|
||||
{group.description && (
|
||||
<div className="text-muted-foreground text-xs">{group.description}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{group.member_count}</TableCell>
|
||||
<TableCell>
|
||||
{group.permissions.length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.permissions.map((permission) => (
|
||||
<Badge
|
||||
key={permission}
|
||||
variant={permission === PERMISSION_ADMIN ? "default" : "outline"}
|
||||
>
|
||||
{permissionLabel(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatLibraryAccess(group.library_ids)}</TableCell>
|
||||
<TableCell>
|
||||
{formatLimit(group.max_streams)} / {formatLimit(group.max_transcodes)}
|
||||
</TableCell>
|
||||
<TableCell>{formatPlaybackQualityPreset(group.max_playback_quality)}</TableCell>
|
||||
<TableCell>
|
||||
{group.download_allowed ? (
|
||||
<Check className="h-4 w-4 text-green-500" aria-label="Downloads allowed" />
|
||||
) : (
|
||||
<Minus
|
||||
className="text-muted-foreground h-4 w-4"
|
||||
aria-label="Downloads not allowed"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label={`Edit ${group.name}`}
|
||||
onClick={() => {
|
||||
setEditingGroup(group);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
{group.built_in ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label={`Delete ${group.name}`}
|
||||
disabled
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Built-in groups cannot be deleted.</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label={`Delete ${group.name}`}
|
||||
onClick={() => setConfirmDeleteGroup(group)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground py-8 text-center">
|
||||
No groups yet. Create one to assign permissions and limits.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupForm({ group, onClose }: { group: AdminGroup | null; onClose: () => void }) {
|
||||
const { data: libraries = [] } = useAdminLibraries();
|
||||
const isAdministrators = group?.slug === ADMINISTRATORS_SLUG;
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [description, setDescription] = useState(group?.description ?? "");
|
||||
const [permissions, setPermissions] = useState<string[]>(group?.permissions ?? []);
|
||||
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(group?.library_ids ?? null);
|
||||
const [maxStreams, setMaxStreams] = useState<number>(group?.max_streams ?? 6);
|
||||
const [maxTranscodes, setMaxTranscodes] = useState<number>(group?.max_transcodes ?? 2);
|
||||
const [maxProfiles, setMaxProfiles] = useState<number>(group?.max_profiles ?? 5);
|
||||
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
|
||||
playbackQualityPresetFromValue(group?.max_playback_quality),
|
||||
);
|
||||
const [downloadAllowed, setDownloadAllowed] = useState(group?.download_allowed ?? true);
|
||||
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
|
||||
group?.download_transcode_allowed ?? false,
|
||||
);
|
||||
const nameId = useId();
|
||||
const descriptionId = useId();
|
||||
const maxStreamsId = useId();
|
||||
const maxTranscodesId = useId();
|
||||
const maxProfilesId = useId();
|
||||
const maxPlaybackQualityId = useId();
|
||||
const downloadAllowedId = useId();
|
||||
const downloadTranscodeAllowedId = useId();
|
||||
const createMutation = useCreateGroup();
|
||||
const updateMutation = useUpdateGroup();
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const body: CreateGroupRequest & UpdateGroupRequest = {
|
||||
name,
|
||||
description,
|
||||
permissions,
|
||||
library_ids: libraryIDs,
|
||||
max_streams: maxStreams,
|
||||
max_transcodes: maxTranscodes,
|
||||
max_profiles: maxProfiles,
|
||||
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
|
||||
download_allowed: downloadAllowed,
|
||||
download_transcode_allowed: downloadTranscodeAllowed,
|
||||
};
|
||||
if (group) {
|
||||
updateMutation.mutate({ id: group.id, body }, { onSuccess: onClose });
|
||||
} else {
|
||||
createMutation.mutate(body, { onSuccess: onClose });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
|
||||
<Tabs defaultValue="general" className="min-h-0 flex-1">
|
||||
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
|
||||
<TabsTrigger value="general" className="flex-none px-1">
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="access" className="flex-none px-1">
|
||||
Access
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="limits" className="flex-none px-1">
|
||||
Limits
|
||||
</TabsTrigger>
|
||||
{group && (
|
||||
<TabsTrigger value="members" className="flex-none px-1">
|
||||
Members ({group.member_count})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<TabsContent value="general" className="mt-0 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={nameId}>Name</Label>
|
||||
<Input id={nameId} value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={descriptionId}>Description</Label>
|
||||
<Input
|
||||
id={descriptionId}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Permissions</Label>
|
||||
{PERMISSION_OPTIONS.map((option) => {
|
||||
const locked = option.id === PERMISSION_ADMIN && isAdministrators;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className="border-border flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<p className="text-muted-foreground text-xs">{option.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={locked || hasAssignedPermission(permissions, option.id)}
|
||||
disabled={locked}
|
||||
aria-label={option.label}
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((current) =>
|
||||
setAssignedPermission(current, option.id, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isAdministrators && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
The administrators group always keeps the Administrator permission.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Users in multiple groups get the most permissive combination of their groups.
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="access" className="mt-0 space-y-4">
|
||||
<LibraryAccessSelector
|
||||
libraries={libraries}
|
||||
value={libraryIDs}
|
||||
onChange={setLibraryIDs}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="limits" className="mt-0 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxStreamsId}>Max Streams</Label>
|
||||
<Input
|
||||
id={maxStreamsId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxStreams}
|
||||
onChange={(e) => setMaxStreams(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxTranscodesId}>Max Transcodes</Label>
|
||||
<Input
|
||||
id={maxTranscodesId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxTranscodes}
|
||||
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxProfilesId}>Max Profiles</Label>
|
||||
<Input
|
||||
id={maxProfilesId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxProfiles}
|
||||
onChange={(e) => setMaxProfiles(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<Label htmlFor={maxPlaybackQualityId}>Max Playback Quality</Label>
|
||||
<Select
|
||||
value={maxPlaybackQualityPreset}
|
||||
onValueChange={(value) =>
|
||||
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={maxPlaybackQualityId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
PLAYBACK_QUALITY_OPTIONS.find(
|
||||
(option) => option.value === maxPlaybackQualityPreset,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label htmlFor={downloadAllowedId}>Downloads Allowed</Label>
|
||||
<Switch
|
||||
id={downloadAllowedId}
|
||||
checked={downloadAllowed}
|
||||
onCheckedChange={setDownloadAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label htmlFor={downloadTranscodeAllowedId}>Download Transcode Allowed</Label>
|
||||
<Switch
|
||||
id={downloadTranscodeAllowedId}
|
||||
checked={downloadTranscodeAllowed}
|
||||
onCheckedChange={setDownloadTranscodeAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Users in multiple groups get the most permissive combination of their groups.
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
{group && (
|
||||
<TabsContent value="members" className="mt-0 space-y-4">
|
||||
<GroupMembersSection group={group} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<div className="border-border mt-4 border-t pt-4">
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupMembersSection({ group }: { group: AdminGroup }) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, isLoading } = useGroupMembers(group.id, offset, MEMBERS_PAGE_SIZE);
|
||||
// Only fetch the user list once the admin starts typing a search.
|
||||
const { data: users = [] } = useAdminUsers({ enabled: search.trim().length > 0 });
|
||||
const addMutation = useAddGroupMember();
|
||||
const removeMutation = useRemoveGroupMember();
|
||||
|
||||
const members = data?.members ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return users
|
||||
.filter(
|
||||
(u) =>
|
||||
!u.groups.some((g) => g.id === group.id) &&
|
||||
(u.username?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)),
|
||||
)
|
||||
.slice(0, 8);
|
||||
}, [users, search, group.id]);
|
||||
|
||||
function handleRemove(userId: number) {
|
||||
removeMutation.mutate(
|
||||
{ groupId: group.id, userId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Step back a page when removing the last member on a later page.
|
||||
if (members.length === 1 && offset > 0) {
|
||||
setOffset(offset - MEMBERS_PAGE_SIZE);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search users to add..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pr-9 pl-9"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setSearch("")}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{search.trim() && (
|
||||
<div className="grid gap-1.5">
|
||||
{candidates.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">No matching users to add.</p>
|
||||
) : (
|
||||
candidates.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="border-border flex items-center justify-between gap-3 rounded-md border px-3 py-1.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{u.username}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">{u.email}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={addMutation.isPending}
|
||||
onClick={() =>
|
||||
addMutation.mutate(
|
||||
{ groupId: group.id, userId: u.id },
|
||||
{ onSuccess: () => setSearch("") },
|
||||
)
|
||||
}
|
||||
>
|
||||
<UserPlus className="mr-1 h-3 w-3" aria-hidden="true" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">This group has no members.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-12">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
<TableRow key={member.user_id}>
|
||||
<TableCell className="font-medium">{member.username}</TableCell>
|
||||
<TableCell>{member.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={member.enabled ? "outline" : "destructive"}>
|
||||
{member.enabled ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label={`Remove ${member.username} from group`}
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() => handleRemove(member.user_id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{total > MEMBERS_PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Showing {offset + 1}-{Math.min(offset + MEMBERS_PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOffset((o) => Math.max(0, o - MEMBERS_PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOffset((o) => o + MEMBERS_PAGE_SIZE)}
|
||||
disabled={offset + MEMBERS_PAGE_SIZE >= total}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams, Link } from "react-router";
|
||||
import {
|
||||
type AdminDeviceSetting,
|
||||
useAdminUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
useImpersonateUser,
|
||||
useAdminUserDeviceSettings,
|
||||
@@ -17,21 +15,13 @@ import {
|
||||
} from "@/hooks/queries/admin/users";
|
||||
import { useAdminUserProfiles } from "@/hooks/queries/admin/history";
|
||||
import { useAdminPlaybackHistory } from "@/hooks/queries/admin/history";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import { useUserIPs } from "@/hooks/queries/admin/ips";
|
||||
import type {
|
||||
AdminSettingEntry,
|
||||
AdminUser,
|
||||
AdminUserProfile,
|
||||
UpdateUserRequest,
|
||||
UserIPEntry,
|
||||
} from "@/api/types";
|
||||
import type { AdminSettingEntry, AdminUser, AdminUserProfile, UserIPEntry } from "@/api/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
|
||||
import { AdminUserForm } from "@/components/admin/AdminUserForm";
|
||||
import { EffectiveAccessSummary } from "@/components/admin/EffectiveAccessSummary";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
@@ -48,30 +38,10 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ArrowUpRight, ChevronRight, Pencil, RotateCcw, Settings2, UserCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
PLAYBACK_QUALITY_OPTIONS,
|
||||
formatPlaybackQualityPreset,
|
||||
playbackQualityPresetFromValue,
|
||||
playbackQualityValueFromPreset,
|
||||
type PlaybackQualityPreset,
|
||||
} from "@/lib/playback-quality";
|
||||
import {
|
||||
PERMISSION_MARKER_EDIT,
|
||||
PERMISSION_METADATA_CURATION,
|
||||
hasAssignedPermission,
|
||||
setAssignedPermission,
|
||||
} from "@/lib/permissions";
|
||||
import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl";
|
||||
import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest";
|
||||
import {
|
||||
@@ -156,7 +126,7 @@ export default function AdminUserDetail() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EditUserForm user={user} onClose={() => setEditOpen(false)} />
|
||||
<AdminUserForm user={user} onClose={() => setEditOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
@@ -245,20 +215,6 @@ export default function AdminUserDetail() {
|
||||
}
|
||||
|
||||
function OverviewTab({ user }: { user: AdminUser }) {
|
||||
const { data: libraries = [] } = useAdminLibraries();
|
||||
|
||||
const libraryNames =
|
||||
user.library_ids === null
|
||||
? "All libraries"
|
||||
: user.library_ids.length === 0
|
||||
? "None"
|
||||
: user.library_ids
|
||||
.map((id) => {
|
||||
const lib = libraries.find((l) => l.id === id);
|
||||
return lib ? lib.name : `#${id}`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="surface-panel overflow-hidden rounded-2xl border-0">
|
||||
@@ -270,6 +226,20 @@ function OverviewTab({ user }: { user: AdminUser }) {
|
||||
<DetailRow label="Email" value={user.email} />
|
||||
<DetailRow label="Role" value={user.role} />
|
||||
<DetailRow label="Status" value={user.enabled ? "Active" : "Disabled"} />
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
|
||||
<span className="text-muted-foreground text-sm">Groups</span>
|
||||
{user.groups.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{user.groups.map((group) => (
|
||||
<Badge key={group.id} variant="outline">
|
||||
{group.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DetailRow label="Created" value={formatDate(user.created_at)} />
|
||||
<DetailRow label="Updated" value={formatDate(user.updated_at)} />
|
||||
</div>
|
||||
@@ -277,45 +247,9 @@ function OverviewTab({ user }: { user: AdminUser }) {
|
||||
|
||||
<div className="surface-panel overflow-hidden rounded-2xl border-0">
|
||||
<div className="border-border border-b px-4 py-3">
|
||||
<h3 className="text-sm font-medium">Permissions & Limits</h3>
|
||||
</div>
|
||||
<div className="divide-border divide-y">
|
||||
<DetailRow label="Library Access" value={libraryNames} />
|
||||
<DetailRow
|
||||
label="Marker Editing"
|
||||
value={
|
||||
hasAssignedPermission(user.permissions, PERMISSION_MARKER_EDIT)
|
||||
? "Allowed"
|
||||
: "Not allowed"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Metadata Curation"
|
||||
value={
|
||||
hasAssignedPermission(user.permissions, PERMISSION_METADATA_CURATION)
|
||||
? "Allowed"
|
||||
: "Not allowed"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Max Playback Quality"
|
||||
value={formatPlaybackQualityPreset(user.max_playback_quality)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Max Streams"
|
||||
value={user.max_streams === 0 ? "Unlimited" : String(user.max_streams)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Max Transcodes"
|
||||
value={user.max_transcodes === 0 ? "Unlimited" : String(user.max_transcodes)}
|
||||
/>
|
||||
<DetailRow label="Max Profiles" value={String(user.max_profiles)} />
|
||||
<DetailRow label="Downloads" value={user.download_allowed ? "Allowed" : "Not allowed"} />
|
||||
<DetailRow
|
||||
label="Download Transcode"
|
||||
value={user.download_transcode_allowed ? "Allowed" : "Not allowed"}
|
||||
/>
|
||||
<h3 className="text-sm font-medium">Effective Access</h3>
|
||||
</div>
|
||||
<EffectiveAccessSummary user={user} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -893,242 +827,6 @@ function DeviceOverridesTab({ userId }: { userId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void }) {
|
||||
const { data: libraries = [] } = useAdminLibraries();
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const [email, setEmail] = useState(user.email);
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [enabled, setEnabled] = useState(user.enabled);
|
||||
const [permissions, setPermissions] = useState<string[]>(user.permissions ?? []);
|
||||
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user.library_ids);
|
||||
const [maxStreams, setMaxStreams] = useState(user.max_streams);
|
||||
const [maxTranscodes, setMaxTranscodes] = useState(user.max_transcodes);
|
||||
const [maxProfiles, setMaxProfiles] = useState(user.max_profiles);
|
||||
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
|
||||
playbackQualityPresetFromValue(user.max_playback_quality),
|
||||
);
|
||||
const [downloadAllowed, setDownloadAllowed] = useState(user.download_allowed);
|
||||
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
|
||||
user.download_transcode_allowed,
|
||||
);
|
||||
const markerEditId = useId();
|
||||
const metadataCurationId = useId();
|
||||
const updateMutation = useUpdateUser();
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const body: UpdateUserRequest = {
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
permissions,
|
||||
enabled,
|
||||
library_ids: libraryIDs,
|
||||
max_streams: maxStreams,
|
||||
max_transcodes: maxTranscodes,
|
||||
max_profiles: maxProfiles,
|
||||
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
|
||||
download_allowed: downloadAllowed,
|
||||
download_transcode_allowed: downloadTranscodeAllowed,
|
||||
};
|
||||
if (password) body.password = password;
|
||||
updateMutation.mutate({ id: user.id, body }, { onSuccess: onClose });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
|
||||
<Tabs defaultValue="account" className="min-h-0 flex-1">
|
||||
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
|
||||
<TabsTrigger value="account" className="flex-none px-1">
|
||||
Account
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="access" className="flex-none px-1">
|
||||
Access
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="limits" className="flex-none px-1">
|
||||
Limits
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<TabsContent value="account" className="mt-0 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password (leave blank to keep current)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Account status</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Disable access without deleting the user.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs">Enabled</Label>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="access" className="mt-0 space-y-4">
|
||||
<LibraryAccessSelector
|
||||
libraries={libraries}
|
||||
value={libraryIDs}
|
||||
onChange={setLibraryIDs}
|
||||
/>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor={markerEditId}>Marker Editing</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Edit intro, recap, credits, and preview markers within assigned libraries.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={markerEditId}
|
||||
checked={hasAssignedPermission(permissions, PERMISSION_MARKER_EDIT)}
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((current) =>
|
||||
setAssignedPermission(current, PERMISSION_MARKER_EDIT, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Edit, refresh, and rematch metadata within assigned libraries.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={metadataCurationId}
|
||||
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((current) =>
|
||||
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label>Downloads Allowed</Label>
|
||||
<Switch checked={downloadAllowed} onCheckedChange={setDownloadAllowed} />
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label>Download Transcode Allowed</Label>
|
||||
<Switch
|
||||
checked={downloadTranscodeAllowed}
|
||||
onCheckedChange={setDownloadTranscodeAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="limits" className="mt-0 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Max Streams</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxStreams}
|
||||
onChange={(e) => setMaxStreams(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Max Transcodes</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxTranscodes}
|
||||
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Max Profiles</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxProfiles}
|
||||
onChange={(e) => setMaxProfiles(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<Label>Max Playback Quality</Label>
|
||||
<Select
|
||||
value={maxPlaybackQualityPreset}
|
||||
onValueChange={(value) =>
|
||||
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
PLAYBACK_QUALITY_OPTIONS.find(
|
||||
(option) => option.value === maxPlaybackQualityPreset,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<div className="border-border mt-4 border-t pt-4">
|
||||
<Button type="submit" className="w-full" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null) {
|
||||
if (!seconds || Number.isNaN(seconds)) return "0m";
|
||||
const rounded = Math.max(0, Math.floor(seconds));
|
||||
|
||||
+21
-488
@@ -1,19 +1,11 @@
|
||||
import { useState, useId, useMemo } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { AdminUser, CreateUserRequest, UpdateUserRequest } from "@/api/types";
|
||||
import {
|
||||
useAdminUsers,
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
} from "@/hooks/queries/admin/users";
|
||||
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
||||
import { useAdminServerSettings, useUpdateServerSetting } from "@/hooks/queries/admin/settings";
|
||||
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
|
||||
import type { AdminUser } from "@/api/types";
|
||||
import { useAdminUsers, useDeleteUser } from "@/hooks/queries/admin/users";
|
||||
import { AdminUserForm } from "@/components/admin/AdminUserForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
@@ -37,34 +29,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
History,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Settings2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, History, Plus, Pencil, Trash2, Search, X } from "lucide-react";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import InviteCodesTab from "./admin-settings/InviteCodesTab";
|
||||
import {
|
||||
PLAYBACK_QUALITY_OPTIONS,
|
||||
playbackQualityPresetFromValue,
|
||||
playbackQualityValueFromPreset,
|
||||
type PlaybackQualityPreset,
|
||||
} from "@/lib/playback-quality";
|
||||
import {
|
||||
PERMISSION_MARKER_EDIT,
|
||||
PERMISSION_METADATA_CURATION,
|
||||
hasAssignedPermission,
|
||||
setAssignedPermission,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
|
||||
type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at";
|
||||
@@ -74,7 +43,6 @@ export default function AdminUsers() {
|
||||
const { data: users = [], isLoading } = useAdminUsers();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [defaultsOpen, setDefaultsOpen] = useState(false);
|
||||
const [confirmDeleteUser, setConfirmDeleteUser] = useState<AdminUser | null>(null);
|
||||
const deleteMutation = useDeleteUser();
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -147,19 +115,6 @@ export default function AdminUsers() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Dialog open={defaultsOpen} onOpenChange={setDefaultsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings2 className="mr-1 h-4 w-4" /> User Defaults
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Default New User Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UserDefaultsForm onClose={() => setDefaultsOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -176,7 +131,7 @@ export default function AdminUsers() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingUser ? "Edit User" : "Create User"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UserForm
|
||||
<AdminUserForm
|
||||
user={editingUser}
|
||||
onClose={() => {
|
||||
setDialogOpen(false);
|
||||
@@ -247,6 +202,7 @@ export default function AdminUsers() {
|
||||
>
|
||||
Role
|
||||
</SortableUserHead>
|
||||
<TableHead>Groups</TableHead>
|
||||
<SortableUserHead
|
||||
field="enabled"
|
||||
activeField={sortField}
|
||||
@@ -286,6 +242,19 @@ export default function AdminUsers() {
|
||||
<TableCell>
|
||||
<Badge variant={u.role === "admin" ? "default" : "secondary"}>{u.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.groups.length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.groups.map((group) => (
|
||||
<Badge key={group.id} variant="outline">
|
||||
{group.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.enabled ? "outline" : "destructive"}>
|
||||
{u.enabled ? "Active" : "Disabled"}
|
||||
@@ -521,439 +490,3 @@ function formatRelativeTime(value?: string | null, fallback = "-") {
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => void }) {
|
||||
const { data: settings } = useAdminServerSettings();
|
||||
const { data: libraries = [] } = useAdminLibraries();
|
||||
const [username, setUsername] = useState(user?.username ?? "");
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState(user?.role ?? "user");
|
||||
const [enabled, setEnabled] = useState(user?.enabled ?? true);
|
||||
const [permissions, setPermissions] = useState<string[]>(
|
||||
user?.permissions ?? [PERMISSION_MARKER_EDIT],
|
||||
);
|
||||
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user?.library_ids ?? null);
|
||||
const [maxStreams, setMaxStreams] = useState<number>(
|
||||
user?.max_streams ?? Number(settings?.["defaults.max_streams"] ?? "6"),
|
||||
);
|
||||
const [maxTranscodes, setMaxTranscodes] = useState<number>(
|
||||
user?.max_transcodes ?? Number(settings?.["defaults.max_transcodes"] ?? "2"),
|
||||
);
|
||||
const [maxProfiles, setMaxProfiles] = useState<number>(
|
||||
user?.max_profiles ?? Number(settings?.["defaults.max_profiles"] ?? "5"),
|
||||
);
|
||||
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
|
||||
playbackQualityPresetFromValue(
|
||||
user?.max_playback_quality ?? settings?.["defaults.max_playback_quality"],
|
||||
),
|
||||
);
|
||||
const [downloadAllowed, setDownloadAllowed] = useState(
|
||||
user?.download_allowed ?? settings?.["defaults.download_allowed"] !== "false",
|
||||
);
|
||||
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
|
||||
user?.download_transcode_allowed ??
|
||||
settings?.["defaults.download_transcode_allowed"] === "true",
|
||||
);
|
||||
const usernameId = useId();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const roleId = useId();
|
||||
const enabledId = useId();
|
||||
const markerEditId = useId();
|
||||
const metadataCurationId = useId();
|
||||
const downloadAllowedId = useId();
|
||||
const downloadTranscodeAllowedId = useId();
|
||||
const maxStreamsId = useId();
|
||||
const maxTranscodesId = useId();
|
||||
const maxProfilesId = useId();
|
||||
const maxPlaybackQualityId = useId();
|
||||
const createMutation = useCreateUser();
|
||||
const updateMutation = useUpdateUser();
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (user) {
|
||||
const body: UpdateUserRequest = {
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
permissions,
|
||||
enabled,
|
||||
library_ids: libraryIDs,
|
||||
max_streams: maxStreams,
|
||||
max_transcodes: maxTranscodes,
|
||||
max_profiles: maxProfiles,
|
||||
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
|
||||
download_allowed: downloadAllowed,
|
||||
download_transcode_allowed: downloadTranscodeAllowed,
|
||||
};
|
||||
if (password) body.password = password;
|
||||
updateMutation.mutate({ id: user.id, body }, { onSuccess: onClose });
|
||||
} else {
|
||||
const body: CreateUserRequest = {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
permissions,
|
||||
create_default_profile: true,
|
||||
max_streams: maxStreams,
|
||||
max_transcodes: maxTranscodes,
|
||||
max_profiles: maxProfiles,
|
||||
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset) || undefined,
|
||||
download_allowed: downloadAllowed,
|
||||
download_transcode_allowed: downloadTranscodeAllowed,
|
||||
};
|
||||
if (libraryIDs !== null) body.library_ids = libraryIDs;
|
||||
createMutation.mutate(body, { onSuccess: onClose });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
|
||||
<Tabs defaultValue="account" className="min-h-0 flex-1">
|
||||
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
|
||||
<TabsTrigger value="account" className="flex-none px-1">
|
||||
Account
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="access" className="flex-none px-1">
|
||||
Access
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="limits" className="flex-none px-1">
|
||||
Limits
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<TabsContent value="account" className="mt-0 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={usernameId}>Username</Label>
|
||||
<Input
|
||||
id={usernameId}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={emailId}>Email</Label>
|
||||
<Input
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={passwordId}>
|
||||
Password {user && "(leave blank to keep current)"}
|
||||
</Label>
|
||||
<Input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={!user}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={roleId}>Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger id={roleId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{user && (
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Account status</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Disable access without deleting the user.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={enabledId} className="text-xs">
|
||||
Enabled
|
||||
</Label>
|
||||
<Switch id={enabledId} checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="access" className="mt-0 space-y-4">
|
||||
<LibraryAccessSelector
|
||||
libraries={libraries}
|
||||
value={libraryIDs}
|
||||
onChange={setLibraryIDs}
|
||||
/>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor={markerEditId}>Marker Editing</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Edit intro, recap, credits, and preview markers within assigned libraries.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={markerEditId}
|
||||
checked={hasAssignedPermission(permissions, PERMISSION_MARKER_EDIT)}
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((current) =>
|
||||
setAssignedPermission(current, PERMISSION_MARKER_EDIT, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Edit, refresh, and rematch metadata within assigned libraries.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={metadataCurationId}
|
||||
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((current) =>
|
||||
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label htmlFor={downloadAllowedId}>Downloads Allowed</Label>
|
||||
<Switch
|
||||
id={downloadAllowedId}
|
||||
checked={downloadAllowed}
|
||||
onCheckedChange={setDownloadAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label htmlFor={downloadTranscodeAllowedId}>Download Transcode Allowed</Label>
|
||||
<Switch
|
||||
id={downloadTranscodeAllowedId}
|
||||
checked={downloadTranscodeAllowed}
|
||||
onCheckedChange={setDownloadTranscodeAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="limits" className="mt-0 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxStreamsId}>Max Streams</Label>
|
||||
<Input
|
||||
id={maxStreamsId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxStreams}
|
||||
onChange={(e) => setMaxStreams(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxTranscodesId}>Max Transcodes</Label>
|
||||
<Input
|
||||
id={maxTranscodesId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxTranscodes}
|
||||
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={maxProfilesId}>Max Profiles</Label>
|
||||
<Input
|
||||
id={maxProfilesId}
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxProfiles}
|
||||
onChange={(e) => setMaxProfiles(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<Label htmlFor={maxPlaybackQualityId}>Max Playback Quality</Label>
|
||||
<Select
|
||||
value={maxPlaybackQualityPreset}
|
||||
onValueChange={(value) =>
|
||||
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={maxPlaybackQualityId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
PLAYBACK_QUALITY_OPTIONS.find(
|
||||
(option) => option.value === maxPlaybackQualityPreset,
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<div className="border-border mt-4 border-t pt-4">
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDefaultsForm({ onClose }: { onClose: () => void }) {
|
||||
const { data: settings } = useAdminServerSettings();
|
||||
const updateSetting = useUpdateServerSetting();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [maxStreams, setMaxStreams] = useState(Number(settings?.["defaults.max_streams"] ?? "6"));
|
||||
const [maxTranscodes, setMaxTranscodes] = useState(
|
||||
Number(settings?.["defaults.max_transcodes"] ?? "2"),
|
||||
);
|
||||
const [maxProfiles, setMaxProfiles] = useState(
|
||||
Number(settings?.["defaults.max_profiles"] ?? "5"),
|
||||
);
|
||||
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
|
||||
playbackQualityPresetFromValue(settings?.["defaults.max_playback_quality"]),
|
||||
);
|
||||
const [downloadAllowed, setDownloadAllowed] = useState(
|
||||
settings?.["defaults.download_allowed"] !== "false",
|
||||
);
|
||||
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
|
||||
settings?.["defaults.download_transcode_allowed"] === "true",
|
||||
);
|
||||
const defMaxStreamsId = useId();
|
||||
const defMaxTranscodesId = useId();
|
||||
const defMaxProfilesId = useId();
|
||||
const defMaxPlaybackQualityId = useId();
|
||||
const defDownloadAllowedId = useId();
|
||||
const defDownloadTranscodeAllowedId = useId();
|
||||
|
||||
function handleSave() {
|
||||
setIsSaving(true);
|
||||
const updates = [
|
||||
{ key: "defaults.max_streams", value: String(maxStreams) },
|
||||
{ key: "defaults.max_transcodes", value: String(maxTranscodes) },
|
||||
{ key: "defaults.max_profiles", value: String(maxProfiles) },
|
||||
{
|
||||
key: "defaults.max_playback_quality",
|
||||
value: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
|
||||
},
|
||||
{ key: "defaults.download_allowed", value: String(downloadAllowed) },
|
||||
{ key: "defaults.download_transcode_allowed", value: String(downloadTranscodeAllowed) },
|
||||
];
|
||||
Promise.all(updates.map((u) => updateSetting.mutateAsync(u)))
|
||||
.then(() => onClose())
|
||||
.finally(() => setIsSaving(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
These defaults will pre-fill the form when creating new users.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={defMaxStreamsId}>Max Streams</Label>
|
||||
<Input
|
||||
id={defMaxStreamsId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxStreams}
|
||||
onChange={(e) => setMaxStreams(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={defMaxTranscodesId}>Max Transcodes</Label>
|
||||
<Input
|
||||
id={defMaxTranscodesId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxTranscodes}
|
||||
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">0 = unlimited</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={defMaxProfilesId}>Max Profiles</Label>
|
||||
<Input
|
||||
id={defMaxProfilesId}
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxProfiles}
|
||||
onChange={(e) => setMaxProfiles(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={defMaxPlaybackQualityId}>Max Playback Quality</Label>
|
||||
<Select
|
||||
value={maxPlaybackQualityPreset}
|
||||
onValueChange={(value) => setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)}
|
||||
>
|
||||
<SelectTrigger id={defMaxPlaybackQualityId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
PLAYBACK_QUALITY_OPTIONS.find((option) => option.value === maxPlaybackQualityPreset)
|
||||
?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={defDownloadAllowedId}>Downloads Allowed</Label>
|
||||
<Switch
|
||||
id={defDownloadAllowedId}
|
||||
checked={downloadAllowed}
|
||||
onCheckedChange={setDownloadAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={defDownloadTranscodeAllowedId}>Download Transcode Allowed</Label>
|
||||
<Switch
|
||||
id={defDownloadTranscodeAllowedId}
|
||||
checked={downloadTranscodeAllowed}
|
||||
onCheckedChange={setDownloadTranscodeAllowed}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSave} className="w-full" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Defaults"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user