Build 2602260214: invites profiles and expiry admin controls

This commit is contained in:
2026-02-26 02:15:21 +13:00
parent 9be0ec75ec
commit f78382c019
14 changed files with 2795 additions and 31 deletions

View File

@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from typing import Dict, Any
from fastapi import Depends, HTTPException, status, Request
@@ -8,6 +9,21 @@ from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _is_expired(expires_at: str | None) -> bool:
if not isinstance(expires_at, str) or not expires_at.strip():
return False
candidate = expires_at.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
@@ -37,6 +53,8 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
if request is not None:
ip = _extract_client_ip(request)
@@ -49,6 +67,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
"profile_id": user.get("profile_id"),
"expires_at": user.get("expires_at"),
"is_expired": bool(user.get("is_expired", False)),
}

View File

@@ -1,2 +1,2 @@
BUILD_NUMBER = "2602260022"
BUILD_NUMBER = "2602260214"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -24,6 +24,28 @@ def _connect() -> sqlite3.Connection:
return sqlite3.connect(_db_path())
def _parse_datetime_value(value: Optional[str]) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
candidate = value.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _is_datetime_in_past(value: Optional[str]) -> bool:
parsed = _parse_datetime_value(value)
if parsed is None:
return False
return parsed <= datetime.now(timezone.utc)
def _normalize_title_value(title: Optional[str]) -> Optional[str]:
if not isinstance(title, str):
return None
@@ -150,11 +172,61 @@ def init_db() -> None:
last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0,
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
profile_id INTEGER,
expires_at TEXT,
invited_by_code TEXT,
invited_at TEXT,
jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
role TEXT NOT NULL DEFAULT 'user',
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
account_expires_days INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS signup_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
label TEXT,
description TEXT,
profile_id INTEGER,
role TEXT,
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
created_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_signup_invites_enabled
ON signup_invites (enabled)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_signup_invites_expires_at
ON signup_invites (expires_at)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -269,6 +341,40 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_profile_id
ON users (profile_id)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_expires_at
ON users (expires_at)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
@@ -391,16 +497,44 @@ def create_user(
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
profile_id: Optional[int] = None,
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
with _connect() as conn:
conn.execute(
"""
INSERT INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO users (
username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
auto_search_enabled,
profile_id,
expires_at,
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
(
username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
1 if auto_search_enabled else 0,
profile_id,
expires_at,
invited_by_code,
created_at if invited_by_code else None,
),
)
@@ -410,16 +544,44 @@ def create_user_if_missing(
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
profile_id: Optional[int] = None,
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> bool:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
with _connect() as conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT OR IGNORE INTO users (
username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
auto_search_enabled,
profile_id,
expires_at,
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
(
username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
1 if auto_search_enabled else 0,
profile_id,
expires_at,
invited_by_code,
created_at if invited_by_code else None,
),
)
return cursor.rowcount > 0
@@ -429,7 +591,9 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at
created_at, last_login_at, is_blocked, auto_search_enabled,
profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ? COLLATE NOCASE
""",
@@ -448,8 +612,13 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
"profile_id": row[10],
"expires_at": row[11],
"invited_by_code": row[12],
"invited_at": row[13],
"is_expired": _is_datetime_in_past(row[11]),
"jellyfin_password_hash": row[14],
"last_jellyfin_auth_at": row[15],
}
@@ -458,7 +627,9 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at
created_at, last_login_at, is_blocked, auto_search_enabled,
profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE id = ?
""",
@@ -477,15 +648,22 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
"profile_id": row[10],
"expires_at": row[11],
"invited_by_code": row[12],
"invited_at": row[13],
"is_expired": _is_datetime_in_past(row[11]),
"jellyfin_password_hash": row[14],
"last_jellyfin_auth_at": row[15],
}
def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
last_login_at, is_blocked, auto_search_enabled, profile_id, expires_at,
invited_by_code, invited_at
FROM users
ORDER BY username COLLATE NOCASE
"""
@@ -503,6 +681,11 @@ def get_all_users() -> list[Dict[str, Any]]:
"last_login_at": row[6],
"is_blocked": bool(row[7]),
"auto_search_enabled": bool(row[8]),
"profile_id": row[9],
"expires_at": row[10],
"invited_by_code": row[11],
"invited_at": row[12],
"is_expired": _is_datetime_in_past(row[10]),
}
)
return results
@@ -580,6 +763,333 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
return cursor.rowcount
def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET profile_id = ? WHERE username = ? COLLATE NOCASE
""",
(profile_id, username),
)
def set_user_expires_at(username: str, expires_at: Optional[str]) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET expires_at = ? WHERE username = ? COLLATE NOCASE
""",
(expires_at, username),
)
def _row_to_user_profile(row: Any) -> Dict[str, Any]:
return {
"id": row[0],
"name": row[1],
"description": row[2],
"role": row[3],
"auto_search_enabled": bool(row[4]),
"account_expires_days": row[5],
"is_active": bool(row[6]),
"created_at": row[7],
"updated_at": row[8],
}
def list_user_profiles() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
FROM user_profiles
ORDER BY name COLLATE NOCASE
"""
).fetchall()
return [_row_to_user_profile(row) for row in rows]
def get_user_profile(profile_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
FROM user_profiles
WHERE id = ?
""",
(profile_id,),
).fetchone()
if not row:
return None
return _row_to_user_profile(row)
def create_user_profile(
name: str,
description: Optional[str] = None,
role: str = "user",
auto_search_enabled: bool = True,
account_expires_days: Optional[int] = None,
is_active: bool = True,
) -> Dict[str, Any]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO user_profiles (
name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
description,
role,
1 if auto_search_enabled else 0,
account_expires_days,
1 if is_active else 0,
timestamp,
timestamp,
),
)
profile_id = int(cursor.lastrowid)
profile = get_user_profile(profile_id)
if not profile:
raise RuntimeError("Profile creation failed")
return profile
def update_user_profile(
profile_id: int,
*,
name: str,
description: Optional[str],
role: str,
auto_search_enabled: bool,
account_expires_days: Optional[int],
is_active: bool,
) -> Optional[Dict[str, Any]]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE user_profiles
SET name = ?, description = ?, role = ?, auto_search_enabled = ?,
account_expires_days = ?, is_active = ?, updated_at = ?
WHERE id = ?
""",
(
name,
description,
role,
1 if auto_search_enabled else 0,
account_expires_days,
1 if is_active else 0,
timestamp,
profile_id,
),
)
if cursor.rowcount <= 0:
return None
return get_user_profile(profile_id)
def delete_user_profile(profile_id: int) -> bool:
with _connect() as conn:
users_count = conn.execute(
"SELECT COUNT(*) FROM users WHERE profile_id = ?",
(profile_id,),
).fetchone()
invites_count = conn.execute(
"SELECT COUNT(*) FROM signup_invites WHERE profile_id = ?",
(profile_id,),
).fetchone()
if int((users_count or [0])[0] or 0) > 0:
raise ValueError("Profile is assigned to existing users.")
if int((invites_count or [0])[0] or 0) > 0:
raise ValueError("Profile is assigned to existing invites.")
cursor = conn.execute(
"DELETE FROM user_profiles WHERE id = ?",
(profile_id,),
)
return cursor.rowcount > 0
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = row[6]
use_count = int(row[7] or 0)
expires_at = row[9]
is_expired = _is_datetime_in_past(expires_at)
remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
return {
"id": row[0],
"code": row[1],
"label": row[2],
"description": row[3],
"profile_id": row[4],
"role": row[5],
"max_uses": max_uses,
"use_count": use_count,
"enabled": bool(row[8]),
"expires_at": expires_at,
"created_by": row[10],
"created_at": row[11],
"updated_at": row[12],
"is_expired": is_expired,
"remaining_uses": remaining_uses,
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
}
def list_signup_invites() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
ORDER BY created_at DESC, id DESC
"""
).fetchall()
return [_row_to_signup_invite(row) for row in rows]
def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
WHERE id = ?
""",
(invite_id,),
).fetchone()
if not row:
return None
return _row_to_signup_invite(row)
def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
WHERE code = ? COLLATE NOCASE
""",
(code,),
).fetchone()
if not row:
return None
return _row_to_signup_invite(row)
def create_signup_invite(
*,
code: str,
label: Optional[str] = None,
description: Optional[str] = None,
profile_id: Optional[int] = None,
role: Optional[str] = None,
max_uses: Optional[int] = None,
enabled: bool = True,
expires_at: Optional[str] = None,
created_by: Optional[str] = None,
) -> Dict[str, Any]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO signup_invites (
code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
""",
(
code,
label,
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
created_by,
timestamp,
timestamp,
),
)
invite_id = int(cursor.lastrowid)
invite = get_signup_invite_by_id(invite_id)
if not invite:
raise RuntimeError("Invite creation failed")
return invite
def update_signup_invite(
invite_id: int,
*,
code: str,
label: Optional[str],
description: Optional[str],
profile_id: Optional[int],
role: Optional[str],
max_uses: Optional[int],
enabled: bool,
expires_at: Optional[str],
) -> Optional[Dict[str, Any]]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE signup_invites
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, updated_at = ?
WHERE id = ?
""",
(
code,
label,
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
timestamp,
invite_id,
),
)
if cursor.rowcount <= 0:
return None
return get_signup_invite_by_id(invite_id)
def delete_signup_invite(invite_id: int) -> bool:
with _connect() as conn:
cursor = conn.execute(
"DELETE FROM signup_invites WHERE id = ?",
(invite_id,),
)
return cursor.rowcount > 0
def increment_signup_invite_use(invite_id: int) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE signup_invites
SET use_count = use_count + 1, updated_at = ?
WHERE id = ?
""",
(timestamp, invite_id),
)
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username)
if not user:

View File

@@ -2,6 +2,9 @@ from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import ipaddress
import os
import secrets
import sqlite3
import string
from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -26,6 +29,8 @@ from ..db import (
set_user_blocked,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_profile_id,
set_user_expires_at,
set_user_password,
set_user_role,
run_integrity_check,
@@ -36,6 +41,16 @@ from ..db import (
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
list_user_profiles,
get_user_profile,
create_user_profile,
update_user_profile,
delete_user_profile,
list_signup_invites,
get_signup_invite_by_id,
create_signup_invite,
update_signup_invite,
delete_signup_invite,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -226,6 +241,105 @@ def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
return results
def _normalize_optional_text(value: Any) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
trimmed = value.strip()
return trimmed if trimmed else None
def _parse_optional_positive_int(value: Any, field_name: str) -> Optional[int]:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"{field_name} must be a number") from exc
if parsed <= 0:
raise HTTPException(status_code=400, detail=f"{field_name} must be greater than 0")
return parsed
def _parse_optional_profile_id(value: Any) -> Optional[int]:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
if parsed <= 0:
raise HTTPException(status_code=400, detail="profile_id must be greater than 0")
profile = get_user_profile(parsed)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return parsed
def _parse_optional_expires_at(value: Any) -> Optional[str]:
if value is None or value == "":
return None
if not isinstance(value, str):
raise HTTPException(status_code=400, detail="expires_at must be an ISO datetime string")
candidate = value.strip()
if not candidate:
return None
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="expires_at must be a valid ISO datetime") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.isoformat()
def _normalize_invite_code(value: Optional[str]) -> str:
raw = (value or "").strip().upper()
filtered = "".join(ch for ch in raw if ch.isalnum())
if len(filtered) < 6:
raise HTTPException(status_code=400, detail="Invite code must be at least 6 letters/numbers.")
return filtered
def _generate_invite_code(length: int = 12) -> str:
alphabet = string.ascii_uppercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _normalize_role_or_none(value: Any) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
role = value.strip().lower()
if not role:
return None
if role not in {"user", "admin"}:
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
return role
def _calculate_profile_expiry(profile: Dict[str, Any]) -> Optional[str]:
expires_days = profile.get("account_expires_days")
if isinstance(expires_days, int) and expires_days > 0:
return (datetime.now(timezone.utc) + timedelta(days=expires_days)).isoformat()
return None
def _apply_profile_defaults_to_user(username: str, profile: Dict[str, Any]) -> Dict[str, Any]:
set_user_profile_id(username, int(profile["id"]))
role = profile.get("role") or "user"
if role in {"user", "admin"}:
set_user_role(username, role)
set_user_auto_search_enabled(username, bool(profile.get("auto_search_enabled", True)))
set_user_expires_at(username, _calculate_profile_expiry(profile))
refreshed = get_user_by_username(username)
if not refreshed:
raise HTTPException(status_code=404, detail="User not found")
return refreshed
@router.get("/settings")
async def list_settings() -> Dict[str, Any]:
overrides = get_settings_overrides()
@@ -607,12 +721,12 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users")
async def list_users() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
users = get_all_users()
return {"users": users}
@router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
users = get_all_users()
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
@@ -674,6 +788,57 @@ async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dic
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
@router.post("/users/{username}/profile")
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
profile_id = payload.get("profile_id")
if profile_id in (None, ""):
set_user_profile_id(username, None)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
try:
parsed_profile_id = int(profile_id)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
profile = get_user_profile(parsed_profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=400, detail="Profile is disabled")
refreshed = _apply_profile_defaults_to_user(username, profile)
return {"status": "ok", "user": refreshed, "applied_profile_id": parsed_profile_id}
@router.post("/users/{username}/expiry")
async def update_user_expiry(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
clear = payload.get("clear")
if clear is True:
set_user_expires_at(username, None)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
if "days" in payload and payload.get("days") not in (None, ""):
days = _parse_optional_positive_int(payload.get("days"), "days")
expires_at = None
if days is not None:
expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
set_user_expires_at(username, expires_at)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
set_user_expires_at(username, expires_at)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
@router.post("/users/auto-search/bulk")
async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
@@ -688,6 +853,68 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An
}
@router.post("/users/profile/bulk")
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
if scope not in {"non-admin-users", "all-users"}:
raise HTTPException(status_code=400, detail="Invalid scope")
profile_id_value = payload.get("profile_id")
if profile_id_value in (None, ""):
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
set_user_profile_id(user["username"], None)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": None}
try:
profile_id = int(profile_id_value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
profile = get_user_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=400, detail="Profile is disabled")
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
_apply_profile_defaults_to_user(user["username"], profile)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": profile_id}
@router.post("/users/expiry/bulk")
async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
if scope not in {"non-admin-users", "all-users"}:
raise HTTPException(status_code=400, detail="Invalid scope")
clear = payload.get("clear")
expires_at: Optional[str] = None
if clear is True:
expires_at = None
elif "days" in payload and payload.get("days") not in (None, ""):
days = _parse_optional_positive_int(payload.get("days"), "days")
expires_at = (datetime.now(timezone.utc) + timedelta(days=int(days or 0))).isoformat() if days else None
else:
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
set_user_expires_at(user["username"], expires_at)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "expires_at": expires_at}
@router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
new_password = payload.get("password") if isinstance(payload, dict) else None
@@ -702,3 +929,211 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
)
set_user_password(username, new_password.strip())
return {"status": "ok", "username": username}
@router.get("/profiles")
async def get_profiles() -> Dict[str, Any]:
profiles = list_user_profiles()
users = get_all_users()
invites = list_signup_invites()
user_counts: Dict[int, int] = {}
invite_counts: Dict[int, int] = {}
for user in users:
profile_id = user.get("profile_id")
if isinstance(profile_id, int):
user_counts[profile_id] = user_counts.get(profile_id, 0) + 1
for invite in invites:
profile_id = invite.get("profile_id")
if isinstance(profile_id, int):
invite_counts[profile_id] = invite_counts.get(profile_id, 0) + 1
enriched = []
for profile in profiles:
pid = int(profile["id"])
enriched.append(
{
**profile,
"assigned_users": user_counts.get(pid, 0),
"assigned_invites": invite_counts.get(pid, 0),
}
)
return {"profiles": enriched}
@router.post("/profiles")
async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
name = _normalize_optional_text(payload.get("name"))
if not name:
raise HTTPException(status_code=400, detail="Profile name is required")
role = _normalize_role_or_none(payload.get("role")) or "user"
auto_search_enabled = payload.get("auto_search_enabled")
if auto_search_enabled is None:
auto_search_enabled = True
if not isinstance(auto_search_enabled, bool):
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
is_active = payload.get("is_active")
if is_active is None:
is_active = True
if not isinstance(is_active, bool):
raise HTTPException(status_code=400, detail="is_active must be true or false")
account_expires_days = _parse_optional_positive_int(
payload.get("account_expires_days"), "account_expires_days"
)
try:
profile = create_user_profile(
name=name,
description=_normalize_optional_text(payload.get("description")),
role=role,
auto_search_enabled=auto_search_enabled,
account_expires_days=account_expires_days,
is_active=is_active,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
return {"status": "ok", "profile": profile}
@router.put("/profiles/{profile_id}")
async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
existing = get_user_profile(profile_id)
if not existing:
raise HTTPException(status_code=404, detail="Profile not found")
name = _normalize_optional_text(payload.get("name"))
if not name:
raise HTTPException(status_code=400, detail="Profile name is required")
role = _normalize_role_or_none(payload.get("role")) or "user"
auto_search_enabled = payload.get("auto_search_enabled")
if not isinstance(auto_search_enabled, bool):
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
is_active = payload.get("is_active")
if not isinstance(is_active, bool):
raise HTTPException(status_code=400, detail="is_active must be true or false")
account_expires_days = _parse_optional_positive_int(
payload.get("account_expires_days"), "account_expires_days"
)
try:
profile = update_user_profile(
profile_id,
name=name,
description=_normalize_optional_text(payload.get("description")),
role=role,
auto_search_enabled=auto_search_enabled,
account_expires_days=account_expires_days,
is_active=is_active,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return {"status": "ok", "profile": profile}
@router.delete("/profiles/{profile_id}")
async def remove_profile(profile_id: int) -> Dict[str, Any]:
try:
deleted = delete_user_profile(profile_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Profile not found")
return {"status": "ok", "deleted": True, "profile_id": profile_id}
@router.get("/invites")
async def get_invites() -> Dict[str, Any]:
invites = list_signup_invites()
profiles = {profile["id"]: profile for profile in list_user_profiles()}
results = []
for invite in invites:
profile = profiles.get(invite.get("profile_id"))
results.append(
{
**invite,
"profile": (
{
"id": profile.get("id"),
"name": profile.get("name"),
}
if profile
else None
),
}
)
return {"invites": results}
@router.post("/invites")
async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
raw_code = _normalize_optional_text(payload.get("code"))
code = _normalize_invite_code(raw_code) if raw_code else _generate_invite_code()
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if enabled is None:
enabled = True
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
try:
invite = create_signup_invite(
code=code,
label=_normalize_optional_text(payload.get("label")),
description=_normalize_optional_text(payload.get("description")),
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
created_by=current_user.get("username"),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
return {"status": "ok", "invite": invite}
@router.put("/invites/{invite_id}")
async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
existing = get_signup_invite_by_id(invite_id)
if not existing:
raise HTTPException(status_code=404, detail="Invite not found")
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
try:
invite = update_signup_invite(
invite_id,
code=code,
label=_normalize_optional_text(payload.get("label")),
description=_normalize_optional_text(payload.get("description")),
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
if not invite:
raise HTTPException(status_code=404, detail="Invite not found")
return {"status": "ok", "invite": invite}
@router.delete("/invites/{invite_id}")
async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id)
if not deleted:
raise HTTPException(status_code=404, detail="Invite not found")
return {"status": "ok", "deleted": True, "invite_id": invite_id}

View File

@@ -5,12 +5,16 @@ from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
verify_user_password,
create_user,
create_user_if_missing,
set_last_login,
get_user_by_username,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
get_signup_invite_by_code,
increment_signup_invite_use,
get_user_profile,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
@@ -80,13 +84,60 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return None
def _is_user_expired(user: dict | None) -> bool:
if not user:
return False
expires_at = user.get("expires_at")
if not expires_at:
return False
try:
parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _assert_user_can_login(user: dict | None) -> None:
if not user:
return
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_user_expired(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"label": invite.get("label"),
"description": invite.get("description"),
"enabled": bool(invite.get("enabled")),
"expires_at": invite.get("expires_at"),
"max_uses": invite.get("max_uses"),
"use_count": invite.get("use_count", 0),
"remaining_uses": invite.get("remaining_uses"),
"is_expired": bool(invite.get("is_expired")),
"is_usable": bool(invite.get("is_usable")),
"profile": (
{
"id": profile.get("id"),
"name": profile.get("name"),
"description": profile.get("description"),
}
if profile
else None
),
}
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
set_last_login(user["username"])
return {
@@ -107,8 +158,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
username = form_data.username
password = form_data.password
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user")
set_last_login(username)
@@ -121,8 +171,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
try:
users = await client.get_users()
if isinstance(users, list):
@@ -167,8 +216,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
jellyseerr_user_id=jellyseerr_user_id,
)
user = get_user_by_username(form_data.username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
token = create_access_token(form_data.username, "user")
@@ -181,6 +229,107 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@router.get("/invites/{code}")
async def invite_details(code: str) -> dict:
invite = get_signup_invite_by_code(code.strip())
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if profile and not profile.get("is_active", True):
invite = {**invite, "is_usable": False}
return {"invite": _public_invite_payload(invite, profile)}
@router.post("/signup")
async def signup(payload: dict) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "")
if not invite_code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
if len(password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
invite = get_signup_invite_by_code(invite_code)
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if not invite.get("enabled"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled")
if invite.get("is_expired"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired")
remaining_uses = invite.get("remaining_uses")
if remaining_uses is not None and int(remaining_uses) <= 0:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if not profile:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled")
invite_role = invite.get("role")
profile_role = profile.get("role") if profile else None
role = invite_role if invite_role in {"user", "admin"} else profile_role
if role not in {"user", "admin"}:
role = "user"
auto_search_enabled = (
bool(profile.get("auto_search_enabled", True))
if profile is not None
else True
)
expires_at = None
account_expires_days = profile.get("account_expires_days") if profile else None
if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
try:
create_user(
username,
password.strip(),
role=role,
auth_provider="local",
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
return {
"access_token": token,
"token_type": "bearer",
"user": {
"username": username,
"role": role,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
}
@router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or ""