Compare commits
3 Commits
251261718
...
57a4883931
| Author | SHA1 | Date | |
|---|---|---|---|
| 57a4883931 | |||
| 6ba41b854b | |||
| 580b335268 |
1
.build_number
Normal file
1
.build_number
Normal file
@@ -0,0 +1 @@
|
||||
251260501
|
||||
BIN
backend/app/assets/branding/favicon.ico
Normal file
BIN
backend/app/assets/branding/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
backend/app/assets/branding/logo.png
Normal file
BIN
backend/app/assets/branding/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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(
|
||||
|
||||
@@ -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 []
|
||||
return {
|
||||
"user": current_user,
|
||||
"stats": {
|
||||
stats_payload = {
|
||||
**stats,
|
||||
"share": share,
|
||||
"global_total": global_total,
|
||||
"most_active_user": leader,
|
||||
},
|
||||
}
|
||||
if current_user.get("role") == "admin":
|
||||
stats_payload["most_active_user"] = get_global_request_leader()
|
||||
return {
|
||||
"user": current_user,
|
||||
"stats": stats_payload,
|
||||
"activity": {
|
||||
**activity_summary,
|
||||
"recent": activity_recent,
|
||||
|
||||
@@ -11,6 +11,9 @@ router = APIRouter(prefix="/branding", tags=["branding"])
|
||||
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
|
||||
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
|
||||
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
|
||||
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
|
||||
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
|
||||
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
||||
|
||||
|
||||
def _ensure_branding_dir() -> None:
|
||||
@@ -41,6 +44,18 @@ def _ensure_default_branding() -> None:
|
||||
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
|
||||
return
|
||||
_ensure_branding_dir()
|
||||
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
|
||||
try:
|
||||
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
|
||||
target.write(source.read())
|
||||
except OSError:
|
||||
pass
|
||||
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
|
||||
try:
|
||||
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
|
||||
target.write(source.read())
|
||||
except OSError:
|
||||
pass
|
||||
if not os.path.exists(_LOGO_PATH):
|
||||
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
@@ -714,7 +714,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
.map((sectionGroup) => (
|
||||
<section key={sectionGroup.key} className="admin-section">
|
||||
<div className="section-header">
|
||||
<h2>{sectionGroup.title}</h2>
|
||||
<h2>{sectionGroup.key === 'requests' ? 'Sync controls' : sectionGroup.title}</h2>
|
||||
{sectionGroup.key === 'sonarr' && (
|
||||
<button type="button" onClick={() => loadOptions('sonarr')}>
|
||||
Refresh Sonarr options
|
||||
@@ -737,17 +737,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</button>
|
||||
) : null}
|
||||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
||||
<div className="sync-actions-block">
|
||||
<div className="sync-actions">
|
||||
<button type="button" onClick={syncRequests}>
|
||||
Full refresh
|
||||
Full refresh (all requests)
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
|
||||
Quick refresh (new changes)
|
||||
Quick refresh (delta changes)
|
||||
</button>
|
||||
</div>
|
||||
<div className="meta sync-note">
|
||||
Full refresh reloads the entire list. Quick refresh only checks recent changes.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{SECTION_DESCRIPTIONS[sectionGroup.key] && (
|
||||
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
|
||||
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
|
||||
)}
|
||||
{sectionGroup.key === 'sonarr' && sonarrError && (
|
||||
|
||||
@@ -1047,6 +1047,17 @@ button span {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sync-actions-block {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sync-note {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.section-header button {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ink);
|
||||
|
||||
@@ -188,6 +188,7 @@ export default function ProfilePage() {
|
||||
: '0%'}
|
||||
</div>
|
||||
</div>
|
||||
{profile?.role === 'admin' ? (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Most active user</div>
|
||||
<div className="stat-value stat-value--small">
|
||||
@@ -196,6 +197,7 @@ export default function ProfilePage() {
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="profile-section">
|
||||
|
||||
Reference in New Issue
Block a user