Files
Magent/backend/app/db.py

1534 lines
47 KiB
Python

import json
import os
import sqlite3
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, Optional
from .config import settings
from .models import Snapshot
from .security import hash_password, verify_password
logger = logging.getLogger(__name__)
def _db_path() -> str:
path = settings.sqlite_path or "data/magent.db"
if not os.path.isabs(path):
path = os.path.join(os.getcwd(), path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
def _connect() -> sqlite3.Connection:
return sqlite3.connect(_db_path())
def init_db() -> None:
with _connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
state TEXT NOT NULL,
state_reason TEXT,
created_at TEXT NOT NULL,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
action_id TEXT NOT NULL,
label TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
auth_provider TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL,
last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_contacts (
user_id INTEGER PRIMARY KEY,
email TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
discord TEXT,
discord_verified INTEGER NOT NULL DEFAULT 0,
telegram TEXT,
telegram_verified INTEGER NOT NULL DEFAULT 0,
matrix TEXT,
matrix_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS invite_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
max_uses INTEGER,
expires_in_days INTEGER,
require_captcha INTEGER NOT NULL DEFAULT 0,
password_rules_json TEXT,
allow_referrals INTEGER NOT NULL DEFAULT 0,
referral_uses INTEGER,
user_expiry_days INTEGER,
user_expiry_action TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
profile_id INTEGER,
created_by TEXT,
created_at TEXT NOT NULL,
expires_at TEXT,
max_uses INTEGER,
uses_count INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
require_captcha INTEGER NOT NULL DEFAULT 0,
password_rules_json TEXT,
allow_referrals INTEGER NOT NULL DEFAULT 0,
referral_uses INTEGER,
user_expiry_days INTEGER,
user_expiry_action TEXT,
is_referral INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_expiry (
user_id INTEGER PRIMARY KEY,
expires_at TEXT NOT NULL,
action TEXT NOT NULL,
warning_sent_at TEXT,
disabled_at TEXT,
deleted_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_by TEXT,
subject TEXT NOT NULL,
body_md TEXT NOT NULL,
channels_csv TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
recipient TEXT,
status TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS requests_cache (
request_id INTEGER PRIMARY KEY,
media_id INTEGER,
media_type TEXT,
status INTEGER,
title TEXT,
year INTEGER,
requested_by TEXT,
requested_by_norm TEXT,
created_at TEXT,
updated_at TEXT,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
ON requests_cache (created_at)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm
ON requests_cache (requested_by_norm)
"""
)
try:
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError:
pass
_backfill_auth_providers()
ensure_admin_user()
def save_snapshot(snapshot: Snapshot) -> None:
payload = json.dumps(snapshot.model_dump(), ensure_ascii=True)
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO snapshots (request_id, state, state_reason, created_at, payload_json)
VALUES (?, ?, ?, ?, ?)
""",
(
snapshot.request_id,
snapshot.state.value,
snapshot.state_reason,
created_at,
payload,
),
)
def save_action(
request_id: str,
action_id: str,
label: str,
status: str,
message: Optional[str] = None,
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO actions (request_id, action_id, label, status, message, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(request_id, action_id, label, status, message, created_at),
)
def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, state, state_reason, created_at, payload_json
FROM snapshots
WHERE request_id = ?
ORDER BY id DESC
LIMIT ?
""",
(request_id, limit),
).fetchall()
results = []
for row in rows:
results.append(
{
"request_id": row[0],
"state": row[1],
"state_reason": row[2],
"created_at": row[3],
"payload": json.loads(row[4]),
}
)
return results
def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, action_id, label, status, message, created_at
FROM actions
WHERE request_id = ?
ORDER BY id DESC
LIMIT ?
""",
(request_id, limit),
).fetchall()
results = []
for row in rows:
results.append(
{
"request_id": row[0],
"action_id": row[1],
"label": row[2],
"status": row[3],
"message": row[4],
"created_at": row[5],
}
)
return results
def ensure_admin_user() -> None:
if not settings.admin_username or not settings.admin_password:
return
existing = get_user_by_username(settings.admin_username)
if existing:
return
create_user(settings.admin_username, settings.admin_password, role="admin")
def create_user(username: str, password: str, role: str = "user", auth_provider: str = "local") -> 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, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(username, password_hash, role, auth_provider, created_at),
)
def create_user_if_missing(
username: str, password: str, role: str = "user", auth_provider: str = "local"
) -> 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, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(username, password_hash, role, auth_provider, created_at),
)
return cursor.rowcount > 0
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
FROM users
WHERE username = ?
""",
(username,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"created_at": row[5],
"last_login_at": row[6],
"is_blocked": bool(row[7]),
}
def get_user_by_email(email: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT users.id, users.username, users.password_hash, users.role, users.auth_provider,
users.created_at, users.last_login_at, users.is_blocked
FROM users
JOIN user_contacts ON user_contacts.user_id = users.id
WHERE lower(user_contacts.email) = lower(?)
""",
(email,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"created_at": row[5],
"last_login_at": row[6],
"is_blocked": bool(row[7]),
}
def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, role, auth_provider, created_at, last_login_at, is_blocked
FROM users
ORDER BY username COLLATE NOCASE
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"username": row[1],
"role": row[2],
"auth_provider": row[3],
"created_at": row[4],
"last_login_at": row[5],
"is_blocked": bool(row[6]),
}
)
return results
def set_last_login(username: str) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE users SET last_login_at = ? WHERE username = ?
""",
(timestamp, username),
)
def set_user_blocked(username: str, blocked: bool) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET is_blocked = ? WHERE username = ?
""",
(1 if blocked else 0, username),
)
def set_user_role(username: str, role: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET role = ? WHERE username = ?
""",
(role, username),
)
def delete_user(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
with _connect() as conn:
conn.execute("DELETE FROM user_contacts WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM user_expiry WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM password_resets WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username)
if not user:
return None
if not verify_password(password, user["password_hash"]):
return None
return user
def set_user_password(username: str, password: str) -> None:
password_hash = hash_password(password)
with _connect() as conn:
conn.execute(
"""
UPDATE users SET password_hash = ? WHERE username = ?
""",
(password_hash, username),
)
def get_user_id(username: str) -> Optional[int]:
with _connect() as conn:
row = conn.execute(
"SELECT id FROM users WHERE username = ?",
(username,),
).fetchone()
if not row:
return None
return int(row[0])
def upsert_user_contact(
username: str,
email: Optional[str] = None,
discord: Optional[str] = None,
telegram: Optional[str] = None,
matrix: Optional[str] = None,
verified: Optional[Dict[str, bool]] = None,
) -> None:
user_id = get_user_id(username)
if user_id is None:
return
now = datetime.now(timezone.utc).isoformat()
verified = verified or {}
with _connect() as conn:
conn.execute(
"""
INSERT INTO user_contacts (
user_id,
email,
email_verified,
discord,
discord_verified,
telegram,
telegram_verified,
matrix,
matrix_verified,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
email = COALESCE(excluded.email, user_contacts.email),
email_verified = COALESCE(excluded.email_verified, user_contacts.email_verified),
discord = COALESCE(excluded.discord, user_contacts.discord),
discord_verified = COALESCE(excluded.discord_verified, user_contacts.discord_verified),
telegram = COALESCE(excluded.telegram, user_contacts.telegram),
telegram_verified = COALESCE(excluded.telegram_verified, user_contacts.telegram_verified),
matrix = COALESCE(excluded.matrix, user_contacts.matrix),
matrix_verified = COALESCE(excluded.matrix_verified, user_contacts.matrix_verified),
updated_at = excluded.updated_at
""",
(
user_id,
email,
1 if verified.get("email") else 0,
discord,
1 if verified.get("discord") else 0,
telegram,
1 if verified.get("telegram") else 0,
matrix,
1 if verified.get("matrix") else 0,
now,
now,
),
)
def get_user_contact(username: str) -> Optional[Dict[str, Any]]:
user_id = get_user_id(username)
if user_id is None:
return None
with _connect() as conn:
row = conn.execute(
"""
SELECT email, email_verified, discord, discord_verified, telegram, telegram_verified, matrix, matrix_verified
FROM user_contacts
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if not row:
return None
return {
"email": row[0],
"email_verified": bool(row[1]),
"discord": row[2],
"discord_verified": bool(row[3]),
"telegram": row[4],
"telegram_verified": bool(row[5]),
"matrix": row[6],
"matrix_verified": bool(row[7]),
}
def get_all_contacts() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, user_contacts.email, user_contacts.discord,
user_contacts.telegram, user_contacts.matrix
FROM user_contacts
JOIN users ON users.id = user_contacts.user_id
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"email": row[1],
"discord": row[2],
"telegram": row[3],
"matrix": row[4],
}
)
return results
def set_user_expiry(username: str, expires_at: str, action: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
with _connect() as conn:
conn.execute(
"""
INSERT INTO user_expiry (user_id, expires_at, action)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
expires_at = excluded.expires_at,
action = excluded.action
""",
(user_id, expires_at, action),
)
def get_expired_users(now_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, users.is_blocked, user_expiry.expires_at, user_expiry.action, user_expiry.warning_sent_at
FROM user_expiry
JOIN users ON users.id = user_expiry.user_id
WHERE user_expiry.expires_at <= ?
AND user_expiry.deleted_at IS NULL
""",
(now_iso,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"is_blocked": bool(row[1]),
"expires_at": row[2],
"action": row[3],
"warning_sent_at": row[4],
}
)
return results
def get_users_expiring_by(cutoff_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, user_expiry.expires_at, user_expiry.action, user_expiry.warning_sent_at
FROM user_expiry
JOIN users ON users.id = user_expiry.user_id
WHERE user_expiry.expires_at <= ?
AND user_expiry.warning_sent_at IS NULL
""",
(cutoff_iso,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"expires_at": row[1],
"action": row[2],
"warning_sent_at": row[3],
}
)
return results
def mark_expiry_warning_sent(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET warning_sent_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def mark_expiry_disabled(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET disabled_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def mark_expiry_deleted(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET deleted_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def list_invite_profiles() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, max_uses, expires_in_days, require_captcha,
password_rules_json, allow_referrals, referral_uses, user_expiry_days,
user_expiry_action, created_at, updated_at
FROM invite_profiles
ORDER BY name COLLATE NOCASE
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"name": row[1],
"description": row[2],
"max_uses": row[3],
"expires_in_days": row[4],
"require_captcha": bool(row[5]),
"password_rules": json.loads(row[6]) if row[6] else None,
"allow_referrals": bool(row[7]),
"referral_uses": row[8],
"user_expiry_days": row[9],
"user_expiry_action": row[10],
"created_at": row[11],
"updated_at": row[12],
}
)
return results
def get_invite_profile(profile_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, name, description, max_uses, expires_in_days, require_captcha,
password_rules_json, allow_referrals, referral_uses, user_expiry_days,
user_expiry_action
FROM invite_profiles
WHERE id = ?
""",
(profile_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"name": row[1],
"description": row[2],
"max_uses": row[3],
"expires_in_days": row[4],
"require_captcha": bool(row[5]),
"password_rules": json.loads(row[6]) if row[6] else None,
"allow_referrals": bool(row[7]),
"referral_uses": row[8],
"user_expiry_days": row[9],
"user_expiry_action": row[10],
}
def create_invite_profile(
name: str,
description: Optional[str] = None,
max_uses: Optional[int] = None,
expires_in_days: Optional[int] = None,
require_captcha: bool = False,
password_rules: Optional[Dict[str, Any]] = None,
allow_referrals: bool = False,
referral_uses: Optional[int] = None,
user_expiry_days: Optional[int] = None,
user_expiry_action: Optional[str] = None,
) -> int:
now = datetime.now(timezone.utc).isoformat()
rules_json = json.dumps(password_rules, ensure_ascii=True) if password_rules else None
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO invite_profiles (
name,
description,
max_uses,
expires_in_days,
require_captcha,
password_rules_json,
allow_referrals,
referral_uses,
user_expiry_days,
user_expiry_action,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
description,
max_uses,
expires_in_days,
1 if require_captcha else 0,
rules_json,
1 if allow_referrals else 0,
referral_uses,
user_expiry_days,
user_expiry_action,
now,
now,
),
)
return int(cursor.lastrowid)
def create_invite(
code: str,
created_by: Optional[str],
profile_id: Optional[int],
expires_at: Optional[str],
max_uses: Optional[int],
require_captcha: bool,
password_rules: Optional[Dict[str, Any]],
allow_referrals: bool,
referral_uses: Optional[int],
user_expiry_days: Optional[int],
user_expiry_action: Optional[str],
is_referral: bool = False,
) -> None:
now = datetime.now(timezone.utc).isoformat()
rules_json = json.dumps(password_rules, ensure_ascii=True) if password_rules else None
with _connect() as conn:
conn.execute(
"""
INSERT INTO invites (
code,
profile_id,
created_by,
created_at,
expires_at,
max_uses,
require_captcha,
password_rules_json,
allow_referrals,
referral_uses,
user_expiry_days,
user_expiry_action,
is_referral
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
code,
profile_id,
created_by,
now,
expires_at,
max_uses,
1 if require_captcha else 0,
rules_json,
1 if allow_referrals else 0,
referral_uses,
user_expiry_days,
user_expiry_action,
1 if is_referral else 0,
),
)
def list_invites(limit: int = 200) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 500))
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, code, profile_id, created_by, created_at, expires_at, max_uses, uses_count,
disabled, require_captcha, password_rules_json, allow_referrals, referral_uses,
user_expiry_days, user_expiry_action, is_referral
FROM invites
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"code": row[1],
"profile_id": row[2],
"created_by": row[3],
"created_at": row[4],
"expires_at": row[5],
"max_uses": row[6],
"uses_count": row[7],
"disabled": bool(row[8]),
"require_captcha": bool(row[9]),
"password_rules": json.loads(row[10]) if row[10] else None,
"allow_referrals": bool(row[11]),
"referral_uses": row[12],
"user_expiry_days": row[13],
"user_expiry_action": row[14],
"is_referral": bool(row[15]),
}
)
return results
def list_invites_by_creator(username: str, is_referral: bool = False) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT code, created_at, expires_at, max_uses, uses_count, disabled, is_referral
FROM invites
WHERE created_by = ? AND is_referral = ?
ORDER BY created_at DESC
""",
(username, 1 if is_referral else 0),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"code": row[0],
"created_at": row[1],
"expires_at": row[2],
"max_uses": row[3],
"uses_count": row[4],
"disabled": bool(row[5]),
"is_referral": bool(row[6]),
}
)
return results
def get_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, profile_id, created_by, created_at, expires_at, max_uses, uses_count,
disabled, require_captcha, password_rules_json, allow_referrals, referral_uses,
user_expiry_days, user_expiry_action, is_referral
FROM invites
WHERE code = ?
""",
(code,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"code": row[1],
"profile_id": row[2],
"created_by": row[3],
"created_at": row[4],
"expires_at": row[5],
"max_uses": row[6],
"uses_count": row[7],
"disabled": bool(row[8]),
"require_captcha": bool(row[9]),
"password_rules": json.loads(row[10]) if row[10] else None,
"allow_referrals": bool(row[11]),
"referral_uses": row[12],
"user_expiry_days": row[13],
"user_expiry_action": row[14],
"is_referral": bool(row[15]),
}
def increment_invite_use(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE invites SET uses_count = uses_count + 1 WHERE code = ?
""",
(code,),
)
def disable_invite(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE invites SET disabled = 1 WHERE code = ?
""",
(code,),
)
def delete_invite(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
DELETE FROM invites WHERE code = ?
""",
(code,),
)
def create_password_reset(token: str, username: str, expires_at: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO password_resets (token, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
""",
(token, user_id, created_at, expires_at),
)
def get_password_reset(token: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT users.username, password_resets.expires_at, password_resets.used_at
FROM password_resets
JOIN users ON users.id = password_resets.user_id
WHERE password_resets.token = ?
""",
(token,),
).fetchone()
if not row:
return None
return {"username": row[0], "expires_at": row[1], "used_at": row[2]}
def mark_password_reset_used(token: str) -> None:
used_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE password_resets SET used_at = ? WHERE token = ?
""",
(used_at, token),
)
def save_announcement(
created_by: Optional[str],
subject: str,
body_md: str,
channels_csv: Optional[str],
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO announcements (created_by, subject, body_md, channels_csv, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(created_by, subject, body_md, channels_csv, created_at),
)
def log_notification(channel: str, recipient: Optional[str], status: str, detail: Optional[str]) -> None:
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO notification_log (channel, recipient, status, detail, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(channel, recipient, status, detail, created_at),
)
def _backfill_auth_providers() -> None:
with _connect() as conn:
rows = conn.execute(
"""
SELECT username, password_hash, auth_provider
FROM users
"""
).fetchall()
updates: list[tuple[str, str]] = []
for row in rows:
username, password_hash, auth_provider = row
provider = auth_provider or "local"
if provider == "local":
if verify_password("jellyfin-user", password_hash):
provider = "jellyfin"
elif verify_password("jellyseerr-user", password_hash):
provider = "jellyseerr"
if provider != auth_provider:
updates.append((provider, username))
if not updates:
return
with _connect() as conn:
conn.executemany(
"""
UPDATE users SET auth_provider = ? WHERE username = ?
""",
updates,
)
def upsert_request_cache(
request_id: int,
media_id: Optional[int],
media_type: Optional[str],
status: Optional[int],
title: Optional[str],
year: Optional[int],
requested_by: Optional[str],
requested_by_norm: Optional[str],
created_at: Optional[str],
updated_at: Optional[str],
payload_json: str,
) -> None:
with _connect() as conn:
conn.execute(
"""
INSERT INTO requests_cache (
request_id,
media_id,
media_type,
status,
title,
year,
requested_by,
requested_by_norm,
created_at,
updated_at,
payload_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO UPDATE SET
media_id = excluded.media_id,
media_type = excluded.media_type,
status = excluded.status,
title = excluded.title,
year = excluded.year,
requested_by = excluded.requested_by,
requested_by_norm = excluded.requested_by_norm,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
payload_json = excluded.payload_json
""",
(
request_id,
media_id,
media_type,
status,
title,
year,
requested_by,
requested_by_norm,
created_at,
updated_at,
payload_json,
),
)
logger.debug(
"requests_cache upsert: request_id=%s media_id=%s status=%s updated_at=%s",
request_id,
media_id,
status,
updated_at,
)
def get_request_cache_last_updated() -> Optional[str]:
with _connect() as conn:
row = conn.execute(
"""
SELECT MAX(updated_at) FROM requests_cache
"""
).fetchone()
if not row:
return None
return row[0]
def get_request_cache_by_id(request_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT request_id, updated_at, title
FROM requests_cache
WHERE request_id = ?
""",
(request_id,),
).fetchone()
if not row:
logger.debug("requests_cache miss: request_id=%s", request_id)
return None
logger.debug("requests_cache hit: request_id=%s updated_at=%s", row[0], row[1])
return {"request_id": row[0], "updated_at": row[1], "title": row[2]}
def get_request_cache_payload(request_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT payload_json
FROM requests_cache
WHERE request_id = ?
""",
(request_id,),
).fetchone()
if not row or not row[0]:
logger.debug("requests_cache payload miss: request_id=%s", request_id)
return None
try:
payload = json.loads(row[0])
logger.debug("requests_cache payload hit: request_id=%s", request_id)
return payload
except json.JSONDecodeError:
logger.warning("requests_cache payload invalid json: request_id=%s", request_id)
return None
def get_cached_requests(
limit: int,
offset: int,
requested_by_norm: Optional[str] = None,
since_iso: Optional[str] = None,
) -> list[Dict[str, Any]]:
query = """
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, payload_json
FROM requests_cache
"""
params: list[Any] = []
conditions = []
if requested_by_norm:
conditions.append("requested_by_norm = ?")
params.append(requested_by_norm)
if since_iso:
conditions.append("created_at >= ?")
params.append(since_iso)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY created_at DESC, request_id DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
with _connect() as conn:
rows = conn.execute(query, tuple(params)).fetchall()
logger.debug(
"requests_cache list: count=%s requested_by_norm=%s since_iso=%s",
len(rows),
requested_by_norm,
since_iso,
)
results: list[Dict[str, Any]] = []
for row in rows:
title = row[4]
year = row[5]
if (not title or not year) and row[8]:
try:
payload = json.loads(row[8])
if isinstance(payload, dict):
media = payload.get("media") or {}
if not title:
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
if not year:
year = media.get("year") if isinstance(media, dict) else None
year = year or payload.get("year")
except json.JSONDecodeError:
pass
results.append(
{
"request_id": row[0],
"media_id": row[1],
"media_type": row[2],
"status": row[3],
"title": title,
"year": year,
"requested_by": row[6],
"created_at": row[7],
}
)
return results
def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 200))
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, updated_at, payload_json
FROM requests_cache
ORDER BY updated_at DESC, request_id DESC
LIMIT ?
""",
(limit,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
title = row[4]
if not title and row[9]:
try:
payload = json.loads(row[9])
if isinstance(payload, dict):
media = payload.get("media") or {}
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
except json.JSONDecodeError:
title = row[4]
results.append(
{
"request_id": row[0],
"media_id": row[1],
"media_type": row[2],
"status": row[3],
"title": title,
"year": row[5],
"requested_by": row[6],
"created_at": row[7],
"updated_at": row[8],
}
)
return results
def get_request_cache_count() -> int:
with _connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone()
return int(row[0] or 0)
def prune_duplicate_requests_cache() -> int:
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM requests_cache
WHERE media_id IS NOT NULL
AND request_id NOT IN (
SELECT MAX(request_id)
FROM requests_cache
WHERE media_id IS NOT NULL
GROUP BY media_id, COALESCE(requested_by_norm, '')
)
"""
)
return cursor.rowcount
def get_request_cache_payloads(limit: int = 200, offset: int = 0) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 1000))
offset = max(0, offset)
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, payload_json
FROM requests_cache
ORDER BY request_id ASC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
payload = None
if row[1]:
try:
payload = json.loads(row[1])
except json.JSONDecodeError:
payload = None
results.append({"request_id": row[0], "payload": payload})
return results
def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, media_id, media_type, status, title, year, requested_by, requested_by_norm, created_at
FROM requests_cache
WHERE created_at >= ?
ORDER BY created_at DESC, request_id DESC
""",
(since_iso,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"request_id": row[0],
"media_id": row[1],
"media_type": row[2],
"status": row[3],
"title": row[4],
"year": row[5],
"requested_by": row[6],
"requested_by_norm": row[7],
"created_at": row[8],
}
)
return results
def get_cached_request_by_media_id(
media_id: int, requested_by_norm: Optional[str] = None
) -> Optional[Dict[str, Any]]:
query = """
SELECT request_id, status
FROM requests_cache
WHERE media_id = ?
"""
params: list[Any] = [media_id]
if requested_by_norm:
query += " AND requested_by_norm = ?"
params.append(requested_by_norm)
query += " ORDER BY created_at DESC, request_id DESC LIMIT 1"
with _connect() as conn:
row = conn.execute(query, tuple(params)).fetchone()
if not row:
return None
return {"request_id": row[0], "status": row[1]}
def get_setting(key: str) -> Optional[str]:
with _connect() as conn:
row = conn.execute(
"""
SELECT value FROM settings WHERE key = ?
""",
(key,),
).fetchone()
if not row:
return None
return row[0]
def set_setting(key: str, value: Optional[str]) -> None:
updated_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
""",
(key, value, updated_at),
)
def delete_setting(key: str) -> None:
with _connect() as conn:
conn.execute(
"""
DELETE FROM settings WHERE key = ?
""",
(key,),
)
def get_settings_overrides() -> Dict[str, str]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT key, value FROM settings
"""
).fetchall()
overrides: Dict[str, str] = {}
for row in rows:
key = row[0]
value = row[1]
if key:
overrides[key] = value
return overrides
def run_integrity_check() -> str:
with _connect() as conn:
row = conn.execute("PRAGMA integrity_check").fetchone()
if not row:
return "unknown"
return str(row[0])
def vacuum_db() -> None:
with _connect() as conn:
conn.execute("VACUUM")
def clear_requests_cache() -> int:
with _connect() as conn:
cursor = conn.execute("DELETE FROM requests_cache")
return cursor.rowcount
def clear_history() -> Dict[str, int]:
with _connect() as conn:
actions = conn.execute("DELETE FROM actions").rowcount
snapshots = conn.execute("DELETE FROM snapshots").rowcount
return {"actions": actions, "snapshots": snapshots}
def cleanup_history(days: int) -> Dict[str, int]:
if days <= 0:
return {"actions": 0, "snapshots": 0}
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
with _connect() as conn:
actions = conn.execute(
"DELETE FROM actions WHERE created_at < ?",
(cutoff,),
).rowcount
snapshots = conn.execute(
"DELETE FROM snapshots WHERE created_at < ?",
(cutoff,),
).rowcount
return {"actions": actions, "snapshots": snapshots}