diff --git a/.build_number b/.build_number index 49673e2..8dcb863 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0303261841 +0403261256 diff --git a/backend/app/auth.py b/backend/app/auth.py index 8b87a74..129d3f4 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -108,6 +108,7 @@ def _load_current_user_from_token( return { "username": user["username"], + "email": user.get("email"), "role": user["role"], "auth_provider": user.get("auth_provider", "local"), "jellyseerr_user_id": user.get("jellyseerr_user_id"), diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 1bb755f..24b13ed 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0303261841" -CHANGELOG = '2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0403261256" +CHANGELOG = '2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/backend/app/config.py b/backend/app/config.py index 0d623ab..65a42e6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,6 +9,9 @@ class Settings(BaseSettings): app_name: str = "Magent" cors_allow_origin: str = "http://localhost:3000" sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH")) + sqlite_journal_mode: str = Field( + default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE") + ) jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET")) jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES")) api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED")) @@ -21,6 +24,15 @@ class Settings(BaseSettings): auth_rate_limit_max_attempts_user: int = Field( default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER") ) + password_reset_rate_limit_window_seconds: int = Field( + default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS") + ) + password_reset_rate_limit_max_attempts_ip: int = Field( + default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP") + ) + password_reset_rate_limit_max_attempts_identifier: int = Field( + default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER") + ) admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) diff --git a/backend/app/db.py b/backend/app/db.py index 2aab0e9..b1c45d5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -32,8 +32,11 @@ def _db_path() -> str: def _apply_connection_pragmas(conn: sqlite3.Connection) -> None: + journal_mode = str(getattr(settings, "sqlite_journal_mode", "DELETE") or "DELETE").strip().upper() + if journal_mode not in {"DELETE", "WAL", "TRUNCATE", "PERSIST", "MEMORY", "OFF"}: + journal_mode = "DELETE" pragmas = ( - ("journal_mode", "WAL"), + ("journal_mode", journal_mode), ("synchronous", "NORMAL"), ("temp_store", "MEMORY"), ("cache_size", -SQLITE_CACHE_SIZE_KIB), @@ -165,6 +168,15 @@ def _extract_tmdb_from_payload(payload_json: Optional[str]) -> tuple[Optional[in return tmdb_id, media_type +def _normalize_stored_email(value: Optional[Any]) -> Optional[str]: + if not isinstance(value, str): + return None + candidate = value.strip() + if not candidate or "@" not in candidate: + return None + return candidate + + def init_db() -> None: with _connect() as conn: conn.execute( @@ -197,6 +209,7 @@ def init_db() -> None: CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, + email TEXT, password_hash TEXT NOT NULL, role TEXT NOT NULL, auth_provider TEXT NOT NULL DEFAULT 'local', @@ -422,6 +435,10 @@ def init_db() -> None: ON user_activity (last_seen_at) """ ) + try: + conn.execute("ALTER TABLE users ADD COLUMN email TEXT") + except sqlite3.OperationalError: + pass try: conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT") except sqlite3.OperationalError: @@ -501,6 +518,15 @@ def init_db() -> None: ) except sqlite3.OperationalError: pass + try: + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_users_email_nocase + ON users (email COLLATE NOCASE) + """ + ) + except sqlite3.OperationalError: + pass try: conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") except sqlite3.OperationalError: @@ -625,6 +651,7 @@ def create_user( username: str, password: str, role: str = "user", + email: Optional[str] = None, auth_provider: str = "local", jellyseerr_user_id: Optional[int] = None, auto_search_enabled: bool = True, @@ -635,11 +662,13 @@ def create_user( ) -> None: created_at = datetime.now(timezone.utc).isoformat() password_hash = hash_password(password) + normalized_email = _normalize_stored_email(email) with _connect() as conn: conn.execute( """ INSERT INTO users ( username, + email, password_hash, role, auth_provider, @@ -652,10 +681,11 @@ def create_user( invited_by_code, invited_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( username, + normalized_email, password_hash, role, auth_provider, @@ -675,6 +705,7 @@ def create_user_if_missing( username: str, password: str, role: str = "user", + email: Optional[str] = None, auth_provider: str = "local", jellyseerr_user_id: Optional[int] = None, auto_search_enabled: bool = True, @@ -685,11 +716,13 @@ def create_user_if_missing( ) -> bool: created_at = datetime.now(timezone.utc).isoformat() password_hash = hash_password(password) + normalized_email = _normalize_stored_email(email) with _connect() as conn: cursor = conn.execute( """ INSERT OR IGNORE INTO users ( username, + email, password_hash, role, auth_provider, @@ -702,10 +735,11 @@ def create_user_if_missing( invited_by_code, invited_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( username, + normalized_email, password_hash, role, auth_provider, @@ -739,7 +773,7 @@ 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, jellyseerr_user_id, + SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, jellyfin_password_hash, last_jellyfin_auth_at @@ -753,22 +787,23 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: return { "id": row[0], "username": row[1], - "password_hash": row[2], - "role": row[3], - "auth_provider": row[4], - "jellyseerr_user_id": row[5], - "created_at": row[6], - "last_login_at": row[7], - "is_blocked": bool(row[8]), - "auto_search_enabled": bool(row[9]), - "invite_management_enabled": bool(row[10]), - "profile_id": row[11], - "expires_at": row[12], - "invited_by_code": row[13], - "invited_at": row[14], - "is_expired": _is_datetime_in_past(row[12]), - "jellyfin_password_hash": row[15], - "last_jellyfin_auth_at": row[16], + "email": row[2], + "password_hash": row[3], + "role": row[4], + "auth_provider": row[5], + "jellyseerr_user_id": row[6], + "created_at": row[7], + "last_login_at": row[8], + "is_blocked": bool(row[9]), + "auto_search_enabled": bool(row[10]), + "invite_management_enabled": bool(row[11]), + "profile_id": row[12], + "expires_at": row[13], + "invited_by_code": row[14], + "invited_at": row[15], + "is_expired": _is_datetime_in_past(row[13]), + "jellyfin_password_hash": row[16], + "last_jellyfin_auth_at": row[17], } @@ -776,7 +811,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any with _connect() as conn: row = conn.execute( """ - SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, + SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, jellyfin_password_hash, last_jellyfin_auth_at @@ -792,22 +827,23 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any return { "id": row[0], "username": row[1], - "password_hash": row[2], - "role": row[3], - "auth_provider": row[4], - "jellyseerr_user_id": row[5], - "created_at": row[6], - "last_login_at": row[7], - "is_blocked": bool(row[8]), - "auto_search_enabled": bool(row[9]), - "invite_management_enabled": bool(row[10]), - "profile_id": row[11], - "expires_at": row[12], - "invited_by_code": row[13], - "invited_at": row[14], - "is_expired": _is_datetime_in_past(row[12]), - "jellyfin_password_hash": row[15], - "last_jellyfin_auth_at": row[16], + "email": row[2], + "password_hash": row[3], + "role": row[4], + "auth_provider": row[5], + "jellyseerr_user_id": row[6], + "created_at": row[7], + "last_login_at": row[8], + "is_blocked": bool(row[9]), + "auto_search_enabled": bool(row[10]), + "invite_management_enabled": bool(row[11]), + "profile_id": row[12], + "expires_at": row[13], + "invited_by_code": row[14], + "invited_at": row[15], + "is_expired": _is_datetime_in_past(row[13]), + "jellyfin_password_hash": row[16], + "last_jellyfin_auth_at": row[17], } @@ -815,7 +851,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: with _connect() as conn: row = conn.execute( """ - SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, + SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, jellyfin_password_hash, last_jellyfin_auth_at @@ -829,29 +865,30 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: return { "id": row[0], "username": row[1], - "password_hash": row[2], - "role": row[3], - "auth_provider": row[4], - "jellyseerr_user_id": row[5], - "created_at": row[6], - "last_login_at": row[7], - "is_blocked": bool(row[8]), - "auto_search_enabled": bool(row[9]), - "invite_management_enabled": bool(row[10]), - "profile_id": row[11], - "expires_at": row[12], - "invited_by_code": row[13], - "invited_at": row[14], - "is_expired": _is_datetime_in_past(row[12]), - "jellyfin_password_hash": row[15], - "last_jellyfin_auth_at": row[16], + "email": row[2], + "password_hash": row[3], + "role": row[4], + "auth_provider": row[5], + "jellyseerr_user_id": row[6], + "created_at": row[7], + "last_login_at": row[8], + "is_blocked": bool(row[9]), + "auto_search_enabled": bool(row[10]), + "invite_management_enabled": bool(row[11]), + "profile_id": row[12], + "expires_at": row[13], + "invited_by_code": row[14], + "invited_at": row[15], + "is_expired": _is_datetime_in_past(row[13]), + "jellyfin_password_hash": row[16], + "last_jellyfin_auth_at": row[17], } 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, + SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at FROM users @@ -864,19 +901,20 @@ def get_all_users() -> list[Dict[str, Any]]: { "id": row[0], "username": row[1], - "role": row[2], - "auth_provider": row[3], - "jellyseerr_user_id": row[4], - "created_at": row[5], - "last_login_at": row[6], - "is_blocked": bool(row[7]), - "auto_search_enabled": bool(row[8]), - "invite_management_enabled": bool(row[9]), - "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]), + "email": row[2], + "role": row[3], + "auth_provider": row[4], + "jellyseerr_user_id": row[5], + "created_at": row[6], + "last_login_at": row[7], + "is_blocked": bool(row[8]), + "auto_search_enabled": bool(row[9]), + "invite_management_enabled": bool(row[10]), + "profile_id": row[11], + "expires_at": row[12], + "invited_by_code": row[13], + "invited_at": row[14], + "is_expired": _is_datetime_in_past(row[12]), } ) # Admin user management uses Jellyfin as the source of truth for non-admin @@ -945,7 +983,7 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) -> with _connect() as conn: conn.execute( """ - UPDATE users SET jellyseerr_user_id = ? WHERE username = ? + UPDATE users SET jellyseerr_user_id = ? WHERE username = ? COLLATE NOCASE """, (jellyseerr_user_id, username), ) @@ -956,7 +994,7 @@ def set_user_auth_provider(username: str, auth_provider: str) -> None: with _connect() as conn: conn.execute( """ - UPDATE users SET auth_provider = ? WHERE username = ? + UPDATE users SET auth_provider = ? WHERE username = ? COLLATE NOCASE """, (provider, username), ) @@ -967,7 +1005,7 @@ def set_last_login(username: str) -> None: with _connect() as conn: conn.execute( """ - UPDATE users SET last_login_at = ? WHERE username = ? + UPDATE users SET last_login_at = ? WHERE username = ? COLLATE NOCASE """, (timestamp, username), ) @@ -1026,7 +1064,7 @@ def set_user_role(username: str, role: str) -> None: with _connect() as conn: conn.execute( """ - UPDATE users SET role = ? WHERE username = ? + UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE """, (role, username), ) @@ -1037,7 +1075,7 @@ def set_user_auto_search_enabled(username: str, enabled: bool) -> None: with _connect() as conn: conn.execute( """ - UPDATE users SET auto_search_enabled = ? WHERE username = ? + UPDATE users SET auto_search_enabled = ? WHERE username = ? COLLATE NOCASE """, (1 if enabled else 0, username), ) @@ -1480,7 +1518,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]: with _connect() as conn: rows = conn.execute( """ - SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, + SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, jellyfin_password_hash, last_jellyfin_auth_at @@ -1498,33 +1536,53 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]: { "id": row[0], "username": row[1], - "password_hash": row[2], - "role": row[3], - "auth_provider": row[4], - "jellyseerr_user_id": row[5], - "created_at": row[6], - "last_login_at": row[7], - "is_blocked": bool(row[8]), - "auto_search_enabled": bool(row[9]), - "invite_management_enabled": bool(row[10]), - "profile_id": row[11], - "expires_at": row[12], - "invited_by_code": row[13], - "invited_at": row[14], - "is_expired": _is_datetime_in_past(row[12]), - "jellyfin_password_hash": row[15], - "last_jellyfin_auth_at": row[16], + "email": row[2], + "password_hash": row[3], + "role": row[4], + "auth_provider": row[5], + "jellyseerr_user_id": row[6], + "created_at": row[7], + "last_login_at": row[8], + "is_blocked": bool(row[9]), + "auto_search_enabled": bool(row[10]), + "invite_management_enabled": bool(row[11]), + "profile_id": row[12], + "expires_at": row[13], + "invited_by_code": row[14], + "invited_at": row[15], + "is_expired": _is_datetime_in_past(row[13]), + "jellyfin_password_hash": row[16], + "last_jellyfin_auth_at": row[17], } ) return results +def set_user_email(username: str, email: Optional[str]) -> bool: + normalized_email = _normalize_stored_email(email) + with _connect() as conn: + cursor = conn.execute( + """ + UPDATE users + SET email = ? + WHERE username = ? COLLATE NOCASE + """, + (normalized_email, username), + ) + updated = cursor.rowcount > 0 + if updated: + logger.info("user email updated username=%s email=%s", username, normalized_email) + else: + logger.debug("user email update skipped username=%s", username) + return updated + + 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 = ? + UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE """, (password_hash, username), ) diff --git a/backend/app/main.py b/backend/app/main.py index 1c0138e..2736606 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -163,6 +163,21 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable _background_tasks.append(task) +def _log_security_configuration_warnings() -> None: + if str(settings.jwt_secret or "").strip() == "change-me": + logger.warning( + "security configuration warning: JWT_SECRET is still set to the default value" + ) + if str(settings.admin_password or "") == "adminadmin": + logger.warning( + "security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default" + ) + if bool(settings.api_docs_enabled): + logger.warning( + "security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments" + ) + + @app.on_event("startup") async def startup() -> None: configure_logging( @@ -174,6 +189,7 @@ async def startup() -> None: log_background_sync_level=settings.log_background_sync_level, ) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) + _log_security_configuration_warnings() init_db() runtime = get_runtime_settings() configure_logging( diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 83e4d36..3d1844a 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -41,6 +41,7 @@ from ..db import ( delete_user_activity_by_username, set_user_auto_search_enabled, set_auto_search_enabled_for_non_admin_users, + set_user_email, set_user_invite_management_enabled, set_invite_management_enabled_for_non_admin_users, set_user_profile_id, @@ -78,6 +79,8 @@ from ..clients.jellyseerr import JellyseerrClient from ..services.jellyfin_sync import sync_jellyfin_users from ..services.user_cache import ( build_jellyseerr_candidate_map, + extract_jellyseerr_user_email, + find_matching_jellyseerr_user, get_cached_jellyfin_users, get_cached_jellyseerr_users, match_jellyseerr_user_id, @@ -85,9 +88,11 @@ from ..services.user_cache import ( save_jellyseerr_users_cache, clear_user_import_caches, ) +from ..security import validate_password_policy from ..services.invite_email import ( TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS, get_invite_email_templates, + normalize_delivery_email, reset_invite_email_template, save_invite_email_template, send_test_email, @@ -106,6 +111,16 @@ events_router = APIRouter(prefix="/admin/events", tags=["admin"]) logger = logging.getLogger(__name__) SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" + +def _require_recipient_email(value: object) -> str: + normalized = normalize_delivery_email(value) + if normalized: + return normalized + raise HTTPException( + status_code=400, + detail="recipient_email is required and must be a valid email address", + ) + SENSITIVE_KEYS = { "magent_ssl_certificate_pem", "magent_ssl_private_key_pem", @@ -820,8 +835,12 @@ async def jellyseerr_users_sync() -> Dict[str, Any]: continue username = user.get("username") or "" matched_id = match_jellyseerr_user_id(username, candidate_to_id) + matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users) + matched_email = extract_jellyseerr_user_email(matched_seerr_user) if matched_id is not None: set_user_jellyseerr_id(username, matched_id) + if matched_email: + set_user_email(username, matched_email) updated += 1 else: skipped += 1 @@ -858,10 +877,12 @@ async def jellyseerr_users_resync() -> Dict[str, Any]: username = _pick_jellyseerr_username(user) if not username: continue + email = extract_jellyseerr_user_email(user) created = create_user_if_missing( username, "jellyseerr-user", role="user", + email=email, auth_provider="jellyseerr", jellyseerr_user_id=user_id, ) @@ -869,6 +890,8 @@ async def jellyseerr_users_resync() -> Dict[str, Any]: imported += 1 else: set_user_jellyseerr_id(username, user_id) + if email: + set_user_email(username, email) return {"status": "ok", "imported": imported, "cleared": cleared} @router.post("/requests/sync") @@ -1458,12 +1481,15 @@ async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]: @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 - if not isinstance(new_password, str) or len(new_password.strip()) < 8: - raise HTTPException(status_code=400, detail="Password must be at least 8 characters.") + if not isinstance(new_password, str): + raise HTTPException(status_code=400, detail="Invalid payload") + try: + new_password_clean = validate_password_policy(new_password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc user = get_user_by_username(username) if not user: raise HTTPException(status_code=404, detail="User not found") - new_password_clean = new_password.strip() user = normalize_user_auth_provider(user) auth_provider = resolve_user_auth_provider(user) if auth_provider == "local": @@ -1775,7 +1801,7 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]: if invite is None: invite = _resolve_user_invite(user) - recipient_email = _normalize_optional_text(payload.get("recipient_email")) + recipient_email = _require_recipient_email(payload.get("recipient_email")) message = _normalize_optional_text(payload.get("message")) reason = _normalize_optional_text(payload.get("reason")) @@ -1825,7 +1851,7 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = 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")) - recipient_email = _normalize_optional_text(payload.get("recipient_email")) + recipient_email = _require_recipient_email(payload.get("recipient_email")) send_email = bool(payload.get("send_email")) delivery_message = _normalize_optional_text(payload.get("message")) try: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0c32668..46d93bb 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -19,6 +19,7 @@ from ..db import ( get_users_by_username_ci, set_user_password, set_user_jellyseerr_id, + set_user_email, set_user_auth_provider, get_signup_invite_by_code, get_signup_invite_by_id, @@ -39,17 +40,28 @@ 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, verify_password +from ..security import ( + PASSWORD_POLICY_MESSAGE, + create_access_token, + validate_password_policy, + verify_password, +) from ..security import create_stream_token from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider from ..config import settings from ..services.user_cache import ( build_jellyseerr_candidate_map, + extract_jellyseerr_user_email, + find_matching_jellyseerr_user, get_cached_jellyseerr_users, match_jellyseerr_user_id, save_jellyfin_users_cache, ) -from ..services.invite_email import send_templated_email, smtp_email_config_ready +from ..services.invite_email import ( + normalize_delivery_email, + send_templated_email, + smtp_email_config_ready, +) from ..services.password_reset import ( PasswordResetUnavailableError, apply_password_reset, @@ -68,6 +80,19 @@ PASSWORD_RESET_GENERIC_MESSAGE = ( _LOGIN_RATE_LOCK = Lock() _LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque) _LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque) +_RESET_RATE_LOCK = Lock() +_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque) +_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque) + + +def _require_recipient_email(value: object) -> str: + normalized = normalize_delivery_email(value) + if normalized: + return normalized + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="recipient_email is required and must be a valid email address.", + ) def _auth_client_ip(request: Request) -> str: @@ -86,6 +111,10 @@ def _login_rate_key_user(username: str) -> str: return (username or "").strip().lower()[:256] or "" +def _password_reset_rate_key_identifier(identifier: str) -> str: + return (identifier or "").strip().lower()[:256] or "" + + def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None: cutoff = now - window_seconds while bucket and bucket[0] < cutoff: @@ -171,6 +200,57 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None: ) +def _record_password_reset_attempt(request: Request, identifier: str) -> None: + now = time.monotonic() + window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1) + ip_key = _auth_client_ip(request) + identifier_key = _password_reset_rate_key_identifier(identifier) + with _RESET_RATE_LOCK: + ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key] + identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key] + _prune_attempts(ip_bucket, now, window) + _prune_attempts(identifier_bucket, now, window) + ip_bucket.append(now) + identifier_bucket.append(now) + logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key) + + +def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None: + now = time.monotonic() + window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1) + max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1) + max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1) + ip_key = _auth_client_ip(request) + identifier_key = _password_reset_rate_key_identifier(identifier) + with _RESET_RATE_LOCK: + ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key] + identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key] + _prune_attempts(ip_bucket, now, window) + _prune_attempts(identifier_bucket, now, window) + exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier + retry_after = 1 + if exceeded: + retry_candidates = [] + if ip_bucket: + retry_candidates.append(max(1, int(window - (now - ip_bucket[0])))) + if identifier_bucket: + retry_candidates.append(max(1, int(window - (now - identifier_bucket[0])))) + if retry_candidates: + retry_after = max(retry_candidates) + if exceeded: + logger.warning( + "password reset rate limit exceeded identifier=%s client=%s retry_after=%s", + identifier_key, + ip_key, + retry_after, + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many password reset attempts. Try again shortly.", + headers={"Retry-After": str(retry_after)}, + ) + + def _normalize_username(value: str) -> str: normalized = value.strip().lower() if "@" in normalized: @@ -219,6 +299,13 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None: return None +def _extract_jellyseerr_response_email(response: dict) -> str | None: + if not isinstance(response, dict): + return None + user_payload = response.get("user") if isinstance(response.get("user"), dict) else response + return extract_jellyseerr_user_email(user_payload) + + def _extract_http_error_detail(exc: Exception) -> str: if isinstance(exc, httpx.HTTPStatusError): response = exc.response @@ -569,6 +656,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm preferred_match = _pick_preferred_ci_user_match(ci_matches, username) canonical_username = str(preferred_match.get("username") or username) if preferred_match else username user = preferred_match or get_user_by_username(username) + matched_seerr_user = find_matching_jellyseerr_user(canonical_username, jellyseerr_users or []) + matched_email = extract_jellyseerr_user_email(matched_seerr_user) _assert_user_can_login(user) if user and _has_valid_jellyfin_cache(user, password): token = create_access_token(canonical_username, "user") @@ -597,7 +686,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm _record_login_failure(request, username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") if not preferred_match: - create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin") + create_user_if_missing( + canonical_username, + "jellyfin-user", + role="user", + email=matched_email, + auth_provider="jellyfin", + ) elif ( user and str(user.get("role") or "user").strip().lower() != "admin" @@ -605,6 +700,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm ): set_user_auth_provider(canonical_username, "jellyfin") user = get_user_by_username(canonical_username) + if matched_email: + set_user_email(canonical_username, matched_email) user = get_user_by_username(canonical_username) _assert_user_can_login(user) try: @@ -660,6 +757,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor _record_login_failure(request, form_data.username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials") jellyseerr_user_id = _extract_jellyseerr_user_id(response) + jellyseerr_email = _extract_jellyseerr_response_email(response) ci_matches = get_users_by_username_ci(form_data.username) preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username) canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username @@ -668,13 +766,22 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor canonical_username, "jellyseerr-user", role="user", + email=jellyseerr_email, auth_provider="jellyseerr", jellyseerr_user_id=jellyseerr_user_id, ) + elif ( + preferred_match + and str(preferred_match.get("role") or "user").strip().lower() != "admin" + and str(preferred_match.get("auth_provider") or "local").strip().lower() not in {"jellyfin", "jellyseerr"} + ): + set_user_auth_provider(canonical_username, "jellyseerr") user = get_user_by_username(canonical_username) _assert_user_can_login(user) if jellyseerr_user_id is not None: set_user_jellyseerr_id(canonical_username, jellyseerr_user_id) + if jellyseerr_email: + set_user_email(canonical_username, jellyseerr_email) token = create_access_token(canonical_username, "user") _clear_login_failures(request, form_data.username) set_last_login(canonical_username) @@ -735,11 +842,10 @@ async def signup(payload: dict) -> dict: 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.", - ) + try: + password_value = validate_password_policy(password) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if get_user_by_username(username): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists") logger.info( @@ -786,7 +892,6 @@ async def signup(payload: dict) -> dict: expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() runtime = get_runtime_settings() - password_value = password.strip() auth_provider = "local" local_password_value = password_value matched_jellyseerr_user_id: int | None = None @@ -839,6 +944,7 @@ async def signup(payload: dict) -> dict: username, local_password_value, role=role, + email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None, auth_provider=auth_provider, jellyseerr_user_id=matched_jellyseerr_user_id, auto_search_enabled=auto_search_enabled, @@ -901,6 +1007,8 @@ async def forgot_password(payload: dict, request: Request) -> dict: identifier = payload.get("identifier") or payload.get("username") or payload.get("email") if not isinstance(identifier, str) or not identifier.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.") + _enforce_password_reset_rate_limit(request, identifier) + _record_password_reset_attempt(request, identifier) ready, detail = smtp_email_config_ready() if not ready: @@ -960,14 +1068,15 @@ async def password_reset(payload: dict) -> dict: new_password = payload.get("new_password") if not isinstance(token, str) or not token.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.") - if not isinstance(new_password, str) or len(new_password.strip()) < 8: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Password must be at least 8 characters.", - ) + if not isinstance(new_password, str): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=PASSWORD_POLICY_MESSAGE) + try: + new_password_clean = validate_password_policy(new_password) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc try: - result = await apply_password_reset(token.strip(), new_password.strip()) + result = await apply_password_reset(token.strip(), new_password_clean) except PasswordResetUnavailableError as exc: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc except ValueError as exc: @@ -1065,8 +1174,7 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_ label = str(label).strip() or None if description is not None: description = str(description).strip() or None - if recipient_email is not None: - recipient_email = str(recipient_email).strip() or None + recipient_email = _require_recipient_email(recipient_email) send_email = bool(payload.get("send_email")) delivery_message = str(payload.get("message") or "").strip() or None @@ -1156,8 +1264,7 @@ async def update_profile_invite( label = str(label).strip() or None if description is not None: description = str(description).strip() or None - if recipient_email is not None: - recipient_email = str(recipient_email).strip() or None + recipient_email = _require_recipient_email(recipient_email) send_email = bool(payload.get("send_email")) delivery_message = str(payload.get("message") or "").strip() or None @@ -1232,14 +1339,13 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren new_password = payload.get("new_password") if isinstance(payload, dict) else None if not isinstance(current_password, str) or not isinstance(new_password, str): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") - if len(new_password.strip()) < 8: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters." - ) + try: + new_password_clean = validate_password_policy(new_password) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc username = str(current_user.get("username") or "").strip() if not username: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") - new_password_clean = new_password.strip() stored_user = normalize_user_auth_provider(get_user_by_username(username)) auth_provider = resolve_user_auth_provider(stored_user or current_user) logger.info("password change requested username=%s provider=%s", username, auth_provider) diff --git a/backend/app/runtime.py b/backend/app/runtime.py index 5f6be4d..52bc149 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -4,6 +4,12 @@ from .db import get_settings_overrides _INT_FIELDS = { "magent_application_port", "magent_api_port", + "auth_rate_limit_window_seconds", + "auth_rate_limit_max_attempts_ip", + "auth_rate_limit_max_attempts_user", + "password_reset_rate_limit_window_seconds", + "password_reset_rate_limit_max_attempts_ip", + "password_reset_rate_limit_max_attempts_identifier", "sonarr_quality_profile_id", "radarr_quality_profile_id", "jwt_exp_minutes", diff --git a/backend/app/security.py b/backend/app/security.py index de971a8..ce983d8 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -1,13 +1,16 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional -from jose import JWTError, jwt from passlib.context import CryptContext +import jwt +from jwt import InvalidTokenError from .config import settings _pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") _ALGORITHM = "HS256" +MIN_PASSWORD_LENGTH = 8 +PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters." def hash_password(password: str) -> str: @@ -18,6 +21,13 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return _pwd_context.verify(plain_password, hashed_password) +def validate_password_policy(password: str) -> str: + candidate = password.strip() + if len(candidate) < MIN_PASSWORD_LENGTH: + raise ValueError(PASSWORD_POLICY_MESSAGE) + return candidate + + def _create_token( subject: str, role: str, @@ -55,5 +65,5 @@ class TokenError(Exception): def safe_decode_token(token: str) -> Dict[str, Any]: try: return decode_token(token) - except JWTError as exc: + except InvalidTokenError as exc: raise TokenError("Invalid token") from exc diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index 788f6ee..886dae0 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -451,6 +451,10 @@ def _normalize_email(value: object) -> Optional[str]: return str(value).strip() +def normalize_delivery_email(value: object) -> Optional[str]: + return _normalize_email(value) + + def _normalize_display_text(value: object, fallback: str = "") -> str: if value is None: return fallback @@ -880,6 +884,9 @@ def render_invite_email_template( def resolve_user_delivery_email(user: Optional[Dict[str, Any]], invite: Optional[Dict[str, Any]] = None) -> Optional[str]: if not isinstance(user, dict): return _normalize_email(invite.get("recipient_email") if isinstance(invite, dict) else None) + stored_email = _normalize_email(user.get("email")) + if stored_email: + return stored_email username_email = _normalize_email(user.get("username")) if username_email: return username_email diff --git a/backend/app/services/jellyfin_sync.py b/backend/app/services/jellyfin_sync.py index be8d931..0946ef3 100644 --- a/backend/app/services/jellyfin_sync.py +++ b/backend/app/services/jellyfin_sync.py @@ -6,12 +6,15 @@ from ..clients.jellyfin import JellyfinClient from ..db import ( create_user_if_missing, get_user_by_username, + set_user_email, set_user_auth_provider, set_user_jellyseerr_id, ) from ..runtime import get_runtime_settings from .user_cache import ( build_jellyseerr_candidate_map, + extract_jellyseerr_user_email, + find_matching_jellyseerr_user, get_cached_jellyseerr_users, match_jellyseerr_user_id, save_jellyfin_users_cache, @@ -41,10 +44,13 @@ async def sync_jellyfin_users() -> int: if not name: continue matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None + matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or []) + matched_email = extract_jellyseerr_user_email(matched_seerr_user) created = create_user_if_missing( name, "jellyfin-user", role="user", + email=matched_email, auth_provider="jellyfin", jellyseerr_user_id=matched_id, ) @@ -60,6 +66,8 @@ async def sync_jellyfin_users() -> int: set_user_auth_provider(name, "jellyfin") if matched_id is not None: set_user_jellyseerr_id(name, matched_id) + if matched_email: + set_user_email(name, matched_email) return imported diff --git a/backend/app/services/password_reset.py b/backend/app/services/password_reset.py index 817b287..4b4844b 100644 --- a/backend/app/services/password_reset.py +++ b/backend/app/services/password_reset.py @@ -112,6 +112,9 @@ async def _fetch_all_seerr_users() -> list[dict]: def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]: if isinstance(local_user, dict): + stored_email = str(local_user.get("email") or "").strip() + if "@" in stored_email: + return stored_email username = str(local_user.get("username") or "").strip() if "@" in username: return username diff --git a/backend/app/services/user_cache.py b/backend/app/services/user_cache.py index 360557e..abd5913 100644 --- a/backend/app/services/user_cache.py +++ b/backend/app/services/user_cache.py @@ -89,6 +89,33 @@ def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int return candidate_to_id +def find_matching_jellyseerr_user( + identifier: str, users: List[Dict[str, Any]] +) -> Optional[Dict[str, Any]]: + target_handles = set(_normalized_handles(identifier)) + if not target_handles: + return None + for user in users: + if not isinstance(user, dict): + continue + for key in ("username", "email", "displayName", "name"): + if target_handles.intersection(_normalized_handles(user.get(key))): + return user + return None + + +def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]: + if not isinstance(user, dict): + return None + value = user.get("email") + if not isinstance(value, str): + return None + candidate = value.strip() + if not candidate or "@" not in candidate: + return None + return candidate + + def match_jellyseerr_user_id( username: str, candidate_map: Dict[str, int] ) -> Optional[int]: diff --git a/backend/requirements.txt b/backend/requirements.txt index 9414392..2887725 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ uvicorn==0.41.0 httpx==0.28.1 pydantic==2.12.5 pydantic-settings==2.13.1 -python-jose[cryptography]==3.5.0 +PyJWT==2.11.0 passlib==1.7.4 python-multipart==0.0.22 Pillow==12.1.1 diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py new file mode 100644 index 0000000..cc462ea --- /dev/null +++ b/backend/tests/test_backend_quality.py @@ -0,0 +1,145 @@ +import os +import tempfile +import unittest +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException +from starlette.requests import Request + +from backend.app import db +from backend.app.config import settings +from backend.app.routers import auth as auth_router +from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy +from backend.app.services import password_reset + + +def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request: + scope = { + "type": "http", + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/auth/password/forgot", + "raw_path": b"/auth/password/forgot", + "query_string": b"", + "headers": [(b"user-agent", user_agent.encode("utf-8"))], + "client": (ip, 12345), + "server": ("testserver", 8000), + } + + async def receive() -> dict: + return {"type": "http.request", "body": b"", "more_body": False} + + return Request(scope, receive) + + +class TempDatabaseMixin: + def setUp(self) -> None: + super_method = getattr(super(), "setUp", None) + if callable(super_method): + super_method() + self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self._original_sqlite_path = settings.sqlite_path + self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE") + settings.sqlite_path = os.path.join(self._tempdir.name, "test.db") + settings.sqlite_journal_mode = "DELETE" + auth_router._LOGIN_ATTEMPTS_BY_IP.clear() + auth_router._LOGIN_ATTEMPTS_BY_USER.clear() + auth_router._RESET_ATTEMPTS_BY_IP.clear() + auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear() + db.init_db() + + def tearDown(self) -> None: + settings.sqlite_path = self._original_sqlite_path + settings.sqlite_journal_mode = self._original_journal_mode + auth_router._LOGIN_ATTEMPTS_BY_IP.clear() + auth_router._LOGIN_ATTEMPTS_BY_USER.clear() + auth_router._RESET_ATTEMPTS_BY_IP.clear() + auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear() + self._tempdir.cleanup() + super_method = getattr(super(), "tearDown", None) + if callable(super_method): + super_method() + + +class PasswordPolicyTests(unittest.TestCase): + def test_validate_password_policy_rejects_short_passwords(self) -> None: + with self.assertRaisesRegex(ValueError, PASSWORD_POLICY_MESSAGE): + validate_password_policy("short") + + def test_validate_password_policy_trims_whitespace(self) -> None: + self.assertEqual(validate_password_policy(" password123 "), "password123") + + +class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase): + def test_set_user_email_is_case_insensitive(self) -> None: + created = db.create_user_if_missing( + "MixedCaseUser", + "password123", + email=None, + auth_provider="local", + ) + self.assertTrue(created) + updated = db.set_user_email("mixedcaseuser", "mixed@example.com") + self.assertTrue(updated) + stored = db.get_user_by_username("MIXEDCASEUSER") + self.assertIsNotNone(stored) + self.assertEqual(stored.get("email"), "mixed@example.com") + + +class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + async def test_forgot_password_is_rate_limited(self) -> None: + request = _build_request(ip="10.1.2.3") + payload = {"identifier": "resetuser@example.com"} + with patch.object(auth_router, "smtp_email_config_ready", return_value=(True, "")), patch.object( + auth_router, + "request_password_reset", + new=AsyncMock(return_value={"status": "ok", "issued": False}), + ): + for _ in range(3): + result = await auth_router.forgot_password(payload, request) + self.assertEqual(result["status"], "ok") + + with self.assertRaises(HTTPException) as context: + await auth_router.forgot_password(payload, request) + + self.assertEqual(context.exception.status_code, 429) + self.assertEqual( + context.exception.detail, + "Too many password reset attempts. Try again shortly.", + ) + + async def test_request_password_reset_prefers_local_user_email(self) -> None: + db.create_user_if_missing( + "ResetUser", + "password123", + email="local@example.com", + auth_provider="local", + ) + with patch.object( + password_reset, + "send_password_reset_email", + new=AsyncMock(return_value={"status": "ok"}), + ) as send_email: + result = await password_reset.request_password_reset("ResetUser") + + self.assertTrue(result["issued"]) + self.assertEqual(result["recipient_email"], "local@example.com") + send_email.assert_awaited_once() + self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com") + + async def test_profile_invite_requires_recipient_email(self) -> None: + current_user = { + "username": "invite-owner", + "role": "user", + "invite_management_enabled": True, + "profile_id": None, + } + with self.assertRaises(HTTPException) as context: + await auth_router.create_profile_invite({"label": "Missing email"}, current_user) + + self.assertEqual(context.exception.status_code, 400) + self.assertEqual( + context.exception.detail, + "recipient_email is required and must be a valid email address.", + ) diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 3182269..91e8dda 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -156,6 +156,8 @@ const formatDate = (value?: string | null) => { return date.toLocaleString() } +const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) + const isInviteTraceRowInvited = (row: InviteTraceRow) => Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim()) @@ -349,6 +351,17 @@ export default function AdminInviteManagementPage() { const saveInvite = async (event: React.FormEvent) => { event.preventDefault() + const recipientEmail = inviteForm.recipient_email.trim() + if (!recipientEmail) { + setError('Recipient email is required.') + setStatus(null) + return + } + if (!isValidEmail(recipientEmail)) { + setError('Recipient email must be valid.') + setStatus(null) + return + } setInviteSaving(true) setError(null) setStatus(null) @@ -363,7 +376,7 @@ export default function AdminInviteManagementPage() { max_uses: inviteForm.max_uses || null, enabled: inviteForm.enabled, expires_at: inviteForm.expires_at || null, - recipient_email: inviteForm.recipient_email || null, + recipient_email: recipientEmail, send_email: inviteForm.send_email, message: inviteForm.message || null, } @@ -1607,18 +1620,19 @@ export default function AdminInviteManagementPage() {
Delivery - Save a recipient email and optionally send the invite immediately. + Recipient email is required. You can optionally send the invite immediately after saving.