Add user feature permissions and unified account management
This commit is contained in:
@@ -161,6 +161,8 @@ def _load_current_user_from_token(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||
|
||||
user = normalize_user_auth_provider(user)
|
||||
from .feature_access import permissions
|
||||
features = permissions(user)
|
||||
|
||||
if request is not None:
|
||||
ip = _extract_client_ip(request)
|
||||
@@ -168,6 +170,7 @@ def _load_current_user_from_token(
|
||||
upsert_user_activity(user["username"], ip, user_agent)
|
||||
|
||||
return {
|
||||
"features": features,
|
||||
"username": user["username"],
|
||||
"email": user.get("email"),
|
||||
"role": user["role"],
|
||||
|
||||
+17
-7
@@ -187,6 +187,9 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
|
||||
|
||||
def init_db() -> None:
|
||||
with _connect() as conn:
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS user_feature_permissions (
|
||||
user_id INTEGER NOT NULL, feature TEXT NOT NULL, enabled INTEGER NOT NULL,
|
||||
PRIMARY KEY(user_id, feature))""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jellyfin_user_links (
|
||||
source TEXT NOT NULL, local_user_id INTEGER NOT NULL, jellyfin_user_id TEXT NOT NULL,
|
||||
@@ -747,11 +750,16 @@ def init_db() -> None:
|
||||
init_recap_schema(conn)
|
||||
from .services.newsletter_store import init_schema as init_newsletter_schema
|
||||
init_newsletter_schema(conn)
|
||||
conn.execute("""CREATE TRIGGER IF NOT EXISTS delete_user_feature_permissions
|
||||
AFTER DELETE ON users BEGIN
|
||||
DELETE FROM user_feature_permissions WHERE user_id = OLD.id;
|
||||
END""")
|
||||
_backfill_auth_providers()
|
||||
ensure_admin_user()
|
||||
_backfill_request_repairs()
|
||||
|
||||
|
||||
|
||||
def start_request_repair(tracking: Dict[str, Any]) -> None:
|
||||
"""Persist the new collection cycle before a managed file is removed."""
|
||||
with _connect() as conn:
|
||||
@@ -3823,21 +3831,23 @@ def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[st
|
||||
]
|
||||
|
||||
|
||||
def get_portal_overview() -> Dict[str, Any]:
|
||||
def get_portal_overview(kind: Optional[str] = None) -> Dict[str, Any]:
|
||||
with _connect() as conn:
|
||||
kind_rows = conn.execute(
|
||||
"""
|
||||
SELECT kind, COUNT(*)
|
||||
FROM portal_items
|
||||
WHERE (? IS NULL OR kind = ?)
|
||||
GROUP BY kind
|
||||
"""
|
||||
""", (kind, kind)
|
||||
).fetchall()
|
||||
status_rows = conn.execute(
|
||||
"""
|
||||
SELECT status, COUNT(*)
|
||||
FROM portal_items
|
||||
WHERE (? IS NULL OR kind = ?)
|
||||
GROUP BY status
|
||||
"""
|
||||
""", (kind, kind)
|
||||
).fetchall()
|
||||
request_workflow_rows = conn.execute(
|
||||
"""
|
||||
@@ -3846,12 +3856,12 @@ def get_portal_overview() -> Dict[str, Any]:
|
||||
COALESCE(workflow_media_status, ''),
|
||||
COUNT(*)
|
||||
FROM portal_items
|
||||
WHERE kind = 'request'
|
||||
WHERE kind = 'request' AND (? IS NULL OR kind = ?)
|
||||
GROUP BY workflow_request_status, workflow_media_status
|
||||
"""
|
||||
""", (kind, kind)
|
||||
).fetchall()
|
||||
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
|
||||
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
|
||||
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items WHERE (? IS NULL OR kind = ?)", (kind, kind)).fetchone()
|
||||
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments c JOIN portal_items i ON i.id = c.item_id WHERE (? IS NULL OR i.kind = ?)", (kind, kind)).fetchone()
|
||||
request_workflow: Dict[str, Dict[str, int]] = {}
|
||||
for row in request_workflow_rows:
|
||||
request_status = str(row[0] or "")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Live account permissions. Invite access uses the existing users column."""
|
||||
from .db import _connect
|
||||
|
||||
FEATURES = ("stats", "requests", "new_requests", "issues", "invites")
|
||||
|
||||
|
||||
def permissions(user: dict) -> dict[str, bool]:
|
||||
if user.get("role") == "admin":
|
||||
return dict.fromkeys(FEATURES, True)
|
||||
values = dict.fromkeys(FEATURES, True)
|
||||
values["invites"] = bool(user.get("invite_management_enabled", False))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
|
||||
JOIN users u ON u.id = p.user_id WHERE u.username = ? COLLATE NOCASE""",
|
||||
(user.get("username", ""),)).fetchall()
|
||||
values.update({key: bool(enabled) for key, enabled in rows if key in FEATURES and key != "invites"})
|
||||
return values
|
||||
|
||||
|
||||
def update_permissions(changes: dict[str, bool], username: str | None = None) -> int:
|
||||
if not changes or any(key not in FEATURES or type(value) is not bool for key, value in changes.items()):
|
||||
raise ValueError("Choose valid features with true or false values")
|
||||
with _connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
users = conn.execute("SELECT id FROM users WHERE role != 'admin'" +
|
||||
(" AND username = ? COLLATE NOCASE" if username is not None else ""),
|
||||
(username,) if username is not None else ()).fetchall()
|
||||
for (user_id,) in users:
|
||||
for feature, enabled in changes.items():
|
||||
if feature == "invites":
|
||||
conn.execute("UPDATE users SET invite_management_enabled = ? WHERE id = ?", (int(enabled), user_id))
|
||||
else:
|
||||
conn.execute("""INSERT INTO user_feature_permissions(user_id, feature, enabled) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, feature) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(user_id, feature, int(enabled)))
|
||||
return len(users)
|
||||
@@ -0,0 +1,69 @@
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from .auth import get_current_user, get_current_user_event_stream
|
||||
from .db import get_portal_item
|
||||
|
||||
|
||||
def check(user: dict, *features: str) -> None:
|
||||
access = user.get("features") or {}
|
||||
if user.get("role") == "admin":
|
||||
return
|
||||
if not any(access.get(feature, False) for feature in features):
|
||||
raise HTTPException(status_code=403, detail="This feature is disabled for your account")
|
||||
|
||||
|
||||
def require_stats(user: dict = Depends(get_current_user)) -> dict:
|
||||
check(user, "stats")
|
||||
return user
|
||||
|
||||
|
||||
def require_invites(user: dict = Depends(get_current_user)) -> dict:
|
||||
check(user, "invites")
|
||||
return user
|
||||
|
||||
|
||||
def require_request_access(request: Request, user: dict = Depends(get_current_user)) -> None:
|
||||
path = request.url.path.rstrip("/")
|
||||
if path.endswith("/search") and "/actions/" not in path:
|
||||
# The issue picker uses the same media search; creation is checked separately.
|
||||
check(user, "new_requests", "issues")
|
||||
elif path.endswith(("/create", "/request-options")):
|
||||
check(user, "new_requests")
|
||||
elif path.endswith(("/issue-options", "/replacement-options", "/actions/replace", "/actions/search-missing", "/actions/repair-subtitles")):
|
||||
check(user, "issues")
|
||||
else:
|
||||
check(user, "requests")
|
||||
|
||||
|
||||
async def require_portal_access(request: Request, user: dict = Depends(get_current_user)) -> None:
|
||||
if user.get("role") == "admin":
|
||||
return
|
||||
path = request.url.path.rstrip("/")
|
||||
access = user.get("features", {})
|
||||
if access.get("requests") and access.get("issues") and access.get("new_requests"):
|
||||
return
|
||||
if "/issues" in path:
|
||||
check(user, "issues")
|
||||
elif path.endswith("/requests") or path.endswith("/pipeline"):
|
||||
check(user, "requests")
|
||||
elif "item_id" in request.path_params:
|
||||
try:
|
||||
item = get_portal_item(int(request.path_params["item_id"]))
|
||||
except (ValueError, TypeError):
|
||||
item = None
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
check(user, "requests" if item.get("kind") == "request" else "issues")
|
||||
elif path.endswith("/items") and request.method == "POST":
|
||||
payload = await request.json()
|
||||
check(user, "new_requests" if isinstance(payload, dict) and payload.get("kind", "request") == "request" else "issues")
|
||||
elif path.endswith(("/items", "/overview")) and request.query_params.get("kind"):
|
||||
check(user, "requests" if request.query_params["kind"] == "request" else "issues")
|
||||
else:
|
||||
# Unfiltered lists/overview can include both kinds.
|
||||
check(user, "requests")
|
||||
check(user, "issues")
|
||||
|
||||
|
||||
def require_request_stream(user: dict = Depends(get_current_user_event_stream)) -> dict:
|
||||
check(user, "requests")
|
||||
return user
|
||||
@@ -1,3 +1,4 @@
|
||||
from ..feature_access import permissions, update_permissions
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
@@ -1199,7 +1200,7 @@ async def list_users_summary() -> Dict[str, Any]:
|
||||
username = user.get("username") or ""
|
||||
username_norm = _normalize_username(username) if username else ""
|
||||
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
|
||||
results.append({**user, "stats": stats})
|
||||
results.append({**user, "features": permissions(user), "stats": stats})
|
||||
return {"users": results}
|
||||
|
||||
@router.get("/users/{username}")
|
||||
@@ -1209,7 +1210,7 @@ async def get_user_summary(username: str) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
username_norm = _normalize_username(user.get("username") or "")
|
||||
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
|
||||
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
|
||||
return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
|
||||
|
||||
|
||||
@router.get("/users/id/{user_id}")
|
||||
@@ -1219,7 +1220,7 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
username_norm = _normalize_username(user.get("username") or "")
|
||||
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
|
||||
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
|
||||
return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
|
||||
|
||||
|
||||
@router.post("/users/{username}/block")
|
||||
@@ -2122,3 +2123,26 @@ async def remove_invite(invite_id: int) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
logger.warning("Admin deleted invite: invite_id=%s", invite_id)
|
||||
return {"status": "ok", "deleted": True, "invite_id": invite_id}
|
||||
|
||||
|
||||
@router.put("/users/features/bulk")
|
||||
async def bulk_feature_permissions(payload: Dict[str, Any]) -> dict:
|
||||
try:
|
||||
updated = update_permissions(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"updated": updated, "scope": "non-admin-users"}
|
||||
|
||||
|
||||
@router.put("/users/{username}/features")
|
||||
async def user_feature_permissions(username: str, payload: Dict[str, Any]) -> dict:
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.get("role") == "admin":
|
||||
raise HTTPException(status_code=400, detail="Administrators always have all features")
|
||||
try:
|
||||
update_permissions(payload, username)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"features": permissions(get_user_by_username(username))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ..feature_guards import require_invites
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections import defaultdict, deque
|
||||
import logging
|
||||
@@ -1233,7 +1234,7 @@ async def update_profile_email(payload: dict, current_user: dict = Depends(get_c
|
||||
return {"status": "ok", "email": email}
|
||||
|
||||
|
||||
@router.get("/profile/invites")
|
||||
@router.get("/profile/invites", dependencies=[Depends(require_invites)])
|
||||
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
if not username:
|
||||
|
||||
@@ -9,7 +9,9 @@ from typing import Any, Dict, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..auth import get_current_user_event_stream
|
||||
from ..feature_guards import require_request_stream, check
|
||||
from ..feature_access import permissions
|
||||
from ..db import get_user_by_username
|
||||
from . import requests as requests_router
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
@@ -76,7 +78,7 @@ async def events_stream(
|
||||
request: Request,
|
||||
recent_days: int = 90,
|
||||
recent_stage: str = "all",
|
||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||
user: Dict[str, Any] = Depends(require_request_stream),
|
||||
) -> StreamingResponse:
|
||||
recent_days = max(0, min(int(recent_days or 90), 3650))
|
||||
recent_take = 50 if user.get("role") == "admin" else 6
|
||||
@@ -91,6 +93,13 @@ async def events_stream(
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
try:
|
||||
account = get_user_by_username(user.get("username", ""))
|
||||
if not account or account.get("is_blocked") or account.get("is_expired"):
|
||||
break
|
||||
check({**account, "features": permissions(account)}, "requests")
|
||||
except HTTPException:
|
||||
break
|
||||
now = time.monotonic()
|
||||
sent_any = False
|
||||
|
||||
@@ -148,7 +157,7 @@ async def events_stream(
|
||||
async def request_events_stream(
|
||||
request_id: str,
|
||||
request: Request,
|
||||
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||
user: Dict[str, Any] = Depends(require_request_stream),
|
||||
) -> StreamingResponse:
|
||||
request_id = str(request_id).strip()
|
||||
if not request_id:
|
||||
@@ -164,6 +173,13 @@ async def request_events_stream(
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
try:
|
||||
account = get_user_by_username(user.get("username", ""))
|
||||
if not account or account.get("is_blocked") or account.get("is_expired"):
|
||||
break
|
||||
check({**account, "features": permissions(account)}, "requests")
|
||||
except HTTPException:
|
||||
break
|
||||
now = time.monotonic()
|
||||
sent_any = False
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ..feature_guards import require_stats
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
@@ -10,7 +11,7 @@ from ..services.insights_artwork import get_artwork
|
||||
from ..services.monthly_reports import get_monthly_report, report_csv
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/insights", tags=["insights"])
|
||||
router = APIRouter(prefix="/insights", tags=["insights"], dependencies=[Depends(require_stats)])
|
||||
|
||||
|
||||
class MonthlyReportQuery(BaseModel):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from ..feature_guards import require_portal_access
|
||||
|
||||
import logging
|
||||
import re
|
||||
@@ -33,7 +34,7 @@ from ..services.issue_resolution import (
|
||||
from ..services.notifications import send_portal_notification
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
|
||||
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PORTAL_KINDS = {"request", "issue", "feature"}
|
||||
@@ -654,10 +655,11 @@ async def _notify(
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
mine = count_portal_items(mine_username=str(current_user.get("username") or ""))
|
||||
async def portal_overview(kind: Optional[str] = None, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
kind = _normalize_choice(kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True)
|
||||
mine = count_portal_items(kind=kind, mine_username=str(current_user.get("username") or ""))
|
||||
return {
|
||||
"overview": get_portal_overview(),
|
||||
"overview": get_portal_overview(kind) if kind else get_portal_overview(),
|
||||
"my_items": mine,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..auth import get_current_user, require_admin
|
||||
from ..feature_guards import require_stats
|
||||
from ..services import email_recaps as recaps, recap_store as store
|
||||
|
||||
|
||||
@@ -65,7 +66,7 @@ def error(exc: recaps.RecapError):
|
||||
|
||||
|
||||
@router.get("/profile/email-recaps")
|
||||
def preferences(user: dict = Depends(get_current_user)) -> dict:
|
||||
def preferences(user: dict = Depends(require_stats)) -> dict:
|
||||
try:
|
||||
return recaps.preferences(user)
|
||||
except recaps.RecapError as exc:
|
||||
@@ -73,7 +74,7 @@ def preferences(user: dict = Depends(get_current_user)) -> dict:
|
||||
|
||||
|
||||
@router.put("/profile/email-recaps")
|
||||
async def preference(payload: Preference, user: dict = Depends(get_current_user)) -> dict:
|
||||
async def preference(payload: Preference, user: dict = Depends(require_stats)) -> dict:
|
||||
try:
|
||||
if payload.enabled:
|
||||
return await recaps.subscribe(user, payload.automatic_monthly)
|
||||
@@ -135,7 +136,7 @@ def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
|
||||
|
||||
|
||||
@router.post('/profile/email-recaps/send', status_code=202)
|
||||
def email_personal_report(payload: TestEmail, user: dict = Depends(get_current_user)) -> dict:
|
||||
def email_personal_report(payload: TestEmail, user: dict = Depends(require_stats)) -> dict:
|
||||
try:
|
||||
return recaps.queue_personal(user, payload.month, str(payload.request_id))
|
||||
except recaps.RecapError as exc:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ..feature_guards import require_request_access
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import asyncio
|
||||
import httpx
|
||||
@@ -65,7 +66,7 @@ from ..services.snapshot import (
|
||||
jellyfin_item_matches_request,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
|
||||
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)])
|
||||
|
||||
CACHE_TTL_SECONDS = 600
|
||||
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
|
||||
@@ -178,6 +178,9 @@ def queue_test(user: dict, month: str | None, request_id: str) -> dict:
|
||||
|
||||
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
|
||||
account = db.get_user_by_id(delivery["user_id"])
|
||||
from ..feature_access import permissions
|
||||
if not account or not permissions(account)["stats"]:
|
||||
raise mail.DeliveryCancelled()
|
||||
sub = active_subscription(account) if account else None
|
||||
config = store.settings()
|
||||
ready, _ = delivery_ready()
|
||||
|
||||
Reference in New Issue
Block a user