diff --git a/.build_number b/.build_number new file mode 100644 index 0000000..2d8bd50 --- /dev/null +++ b/.build_number @@ -0,0 +1 @@ +251260445 \ No newline at end of file diff --git a/backend/app/db.py b/backend/app/db.py index d883d28..80dc6bf 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -61,7 +61,9 @@ def init_db() -> None: auth_provider TEXT NOT NULL DEFAULT 'local', created_at TEXT NOT NULL, last_login_at TEXT, - is_blocked INTEGER NOT NULL DEFAULT 0 + is_blocked INTEGER NOT NULL DEFAULT 0, + jellyfin_password_hash TEXT, + last_jellyfin_auth_at TEXT ) """ ) @@ -141,6 +143,14 @@ def init_db() -> None: conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'") except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT") + except sqlite3.OperationalError: + pass _backfill_auth_providers() ensure_admin_user() @@ -277,7 +287,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: with _connect() as conn: row = conn.execute( """ - SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at, is_blocked + SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at, + is_blocked, jellyfin_password_hash, last_jellyfin_auth_at FROM users WHERE username = ? """, @@ -294,6 +305,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: "created_at": row[5], "last_login_at": row[6], "is_blocked": bool(row[7]), + "jellyfin_password_hash": row[8], + "last_jellyfin_auth_at": row[9], } @@ -373,6 +386,22 @@ def set_user_password(username: str, password: str) -> None: ) +def set_jellyfin_auth_cache(username: str, password: str) -> None: + if not username or not password: + return + password_hash = hash_password(password) + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + conn.execute( + """ + UPDATE users + SET jellyfin_password_hash = ?, last_jellyfin_auth_at = ? + WHERE username = ? + """, + (password_hash, timestamp, username), + ) + + def _backfill_auth_providers() -> None: with _connect() as conn: rows = conn.execute( diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index ad4f9bb..f3eb232 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta, timezone + from fastapi import APIRouter, HTTPException, status, Depends from fastapi.security import OAuth2PasswordRequestForm @@ -7,6 +9,7 @@ from ..db import ( set_last_login, get_user_by_username, set_user_password, + set_jellyfin_auth_cache, get_user_activity, get_user_activity_summary, get_user_request_stats, @@ -16,7 +19,7 @@ from ..db import ( from ..runtime import get_runtime_settings from ..clients.jellyfin import JellyfinClient from ..clients.jellyseerr import JellyseerrClient -from ..security import create_access_token +from ..security import create_access_token, verify_password from ..auth import get_current_user router = APIRouter(prefix="/auth", tags=["auth"]) @@ -26,6 +29,31 @@ def _normalize_username(value: str) -> str: return value.strip().lower() +def _is_recent_jellyfin_auth(last_auth_at: str) -> bool: + if not last_auth_at: + return False + try: + parsed = datetime.fromisoformat(last_auth_at) + except ValueError: + return False + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + age = datetime.now(timezone.utc) - parsed + return age <= timedelta(days=7) + + +def _has_valid_jellyfin_cache(user: dict, password: str) -> bool: + if not user or not password: + return False + cached_hash = user.get("jellyfin_password_hash") + last_auth_at = user.get("last_jellyfin_auth_at") + if not cached_hash or not last_auth_at: + return False + if not verify_password(password, cached_hash): + return False + return _is_recent_jellyfin_auth(last_auth_at) + + @router.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) @@ -48,14 +76,23 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured") + 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") + if user and _has_valid_jellyfin_cache(user, password): + token = create_access_token(username, "user") + set_last_login(username) + return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} try: - response = await client.authenticate_by_name(form_data.username, form_data.password) + response = await client.authenticate_by_name(username, password) except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if not isinstance(response, dict) or not response.get("User"): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") - create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin") - user = get_user_by_username(form_data.username) + 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") try: @@ -69,9 +106,10 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") except Exception: pass - token = create_access_token(form_data.username, "user") - set_last_login(form_data.username) - return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} + set_jellyfin_auth_cache(username, password) + token = create_access_token(username, "user") + set_last_login(username) + return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} @router.post("/jellyseerr/login") @@ -107,18 +145,19 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict: username_norm = _normalize_username(username) if username else "" stats = get_user_request_stats(username_norm) global_total = get_global_request_total() - leader = get_global_request_leader() share = (stats.get("total", 0) / global_total) if global_total else 0 activity_summary = get_user_activity_summary(username) if username else {} activity_recent = get_user_activity(username, limit=5) if username else [] + stats_payload = { + **stats, + "share": share, + "global_total": global_total, + } + if current_user.get("role") == "admin": + stats_payload["most_active_user"] = get_global_request_leader() return { "user": current_user, - "stats": { - **stats, - "share": share, - "global_total": global_total, - "most_active_user": leader, - }, + "stats": stats_payload, "activity": { **activity_summary, "recent": activity_recent, diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 5898229..e71758d 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -188,14 +188,16 @@ export default function ProfilePage() { : '0%'} -
-
Most active user
-
- {stats?.most_active_user - ? `${stats.most_active_user.username} (${stats.most_active_user.total})` - : 'N/A'} + {profile?.role === 'admin' ? ( +
+
Most active user
+
+ {stats?.most_active_user + ? `${stats.most_active_user.username} (${stats.most_active_user.total})` + : 'N/A'} +
-
+ ) : null}