Build 2602262049: split magent settings and harden local login

This commit is contained in:
2026-02-26 20:50:38 +13:00
parent 0b73d9f4ee
commit 1c6b8255c1
7 changed files with 142 additions and 27 deletions

View File

@@ -1,2 +1,2 @@
BUILD_NUMBER = "2602262030"
BUILD_NUMBER = "2602262049"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -1162,12 +1162,94 @@ def increment_signup_invite_use(invite_id: int) -> None:
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username)
if not user:
# Resolve case-insensitive duplicates safely by only considering local-provider rows.
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, 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
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
if not rows:
return None
if not verify_password(password, user["password_hash"]):
return None
return user
for row in rows:
provider = str(row[4] or "local").lower()
if provider != "local":
continue
if not verify_password(password, row[2]):
continue
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],
}
return None
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,
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
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"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],
}
)
return results
def set_user_password(username: str, password: str) -> None:

View File

@@ -15,6 +15,7 @@ from ..db import (
create_user_if_missing,
set_last_login,
get_user_by_username,
get_users_by_username_ci,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
@@ -447,6 +448,19 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
# Provider placeholder passwords must never be accepted by the local-login endpoint.
if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
matching_users = get_users_by_username_ci(form_data.username)
has_external_match = any(
str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
)
if has_external_match:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
)
user = verify_user_password(form_data.username, form_data.password)
if not user:
_record_login_failure(request, form_data.username)