Build 2602262049: split magent settings and harden local login
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2602262030
|
2602262049
|
||||||
|
|||||||
@@ -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'
|
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'
|
||||||
|
|||||||
@@ -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]]:
|
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
user = get_user_by_username(username)
|
# Resolve case-insensitive duplicates safely by only considering local-provider rows.
|
||||||
if not user:
|
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
|
return None
|
||||||
if not verify_password(password, user["password_hash"]):
|
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
|
return None
|
||||||
return user
|
|
||||||
|
|
||||||
|
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:
|
def set_user_password(username: str, password: str) -> None:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..db import (
|
|||||||
create_user_if_missing,
|
create_user_if_missing,
|
||||||
set_last_login,
|
set_last_login,
|
||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
|
get_users_by_username_ci,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
set_jellyfin_auth_cache,
|
set_jellyfin_auth_cache,
|
||||||
set_user_jellyseerr_id,
|
set_user_jellyseerr_id,
|
||||||
@@ -447,6 +448,19 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
|||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||||
_enforce_login_rate_limit(request, form_data.username)
|
_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)
|
user = verify_user_password(form_data.username, form_data.password)
|
||||||
if not user:
|
if not user:
|
||||||
_record_login_failure(request, form_data.username)
|
_record_login_failure(request, form_data.username)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type ServiceOptions = {
|
|||||||
|
|
||||||
const SECTION_LABELS: Record<string, string> = {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
magent: 'Magent',
|
magent: 'Magent',
|
||||||
|
general: 'General',
|
||||||
|
notifications: 'Notifications',
|
||||||
jellyseerr: 'Jellyseerr',
|
jellyseerr: 'Jellyseerr',
|
||||||
jellyfin: 'Jellyfin',
|
jellyfin: 'Jellyfin',
|
||||||
artwork: 'Artwork cache',
|
artwork: 'Artwork cache',
|
||||||
@@ -82,7 +84,11 @@ const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
|||||||
|
|
||||||
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||||
magent:
|
magent:
|
||||||
'Application-level Magent settings including proxy, binding, TLS, and notification channels.',
|
'Magent service settings. Runtime and notification controls are organized under General and Notifications.',
|
||||||
|
general:
|
||||||
|
'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
|
||||||
|
notifications:
|
||||||
|
'Notification providers and delivery channel settings used by Magent messaging features.',
|
||||||
jellyseerr: 'Connect the request system where users submit content.',
|
jellyseerr: 'Connect the request system where users submit content.',
|
||||||
jellyfin: 'Control Jellyfin login and availability checks.',
|
jellyfin: 'Control Jellyfin login and availability checks.',
|
||||||
artwork: 'Cache posters/backdrops and review artwork coverage.',
|
artwork: 'Cache posters/backdrops and review artwork coverage.',
|
||||||
@@ -98,6 +104,8 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
|
|
||||||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||||
magent: 'magent',
|
magent: 'magent',
|
||||||
|
general: 'magent',
|
||||||
|
notifications: 'magent',
|
||||||
jellyseerr: 'jellyseerr',
|
jellyseerr: 'jellyseerr',
|
||||||
jellyfin: 'jellyfin',
|
jellyfin: 'jellyfin',
|
||||||
artwork: null,
|
artwork: null,
|
||||||
@@ -214,6 +222,17 @@ const MAGENT_SECTION_GROUPS: Array<{
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
||||||
|
general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']),
|
||||||
|
notifications: new Set([
|
||||||
|
'magent-notify-core',
|
||||||
|
'magent-notify-email',
|
||||||
|
'magent-notify-discord',
|
||||||
|
'magent-notify-telegram',
|
||||||
|
'magent-notify-push',
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||||
magent_application_url: 'Application URL',
|
magent_application_url: 'Application URL',
|
||||||
magent_application_port: 'Application port',
|
magent_application_port: 'Application port',
|
||||||
@@ -474,6 +493,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
||||||
|
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||||
const visibleSections = settingsSection ? [settingsSection] : []
|
const visibleSections = settingsSection ? [settingsSection] : []
|
||||||
const isCacheSection = section === 'cache'
|
const isCacheSection = section === 'cache'
|
||||||
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
||||||
@@ -502,18 +522,20 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
||||||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
||||||
]
|
]
|
||||||
: section === 'magent'
|
: isMagentGroupedSection
|
||||||
? (() => {
|
? (() => {
|
||||||
|
if (section === 'magent') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
const magentItems = groupedSettings.magent ?? []
|
const magentItems = groupedSettings.magent ?? []
|
||||||
const byKey = new Map(magentItems.map((item) => [item.key, item]))
|
const byKey = new Map(magentItems.map((item) => [item.key, item]))
|
||||||
const used = new Set<string>()
|
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
|
||||||
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.map((group) => {
|
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
|
||||||
|
allowedGroupKeys.has(group.key),
|
||||||
|
).map((group) => {
|
||||||
const items = group.keys
|
const items = group.keys
|
||||||
.map((key) => byKey.get(key))
|
.map((key) => byKey.get(key))
|
||||||
.filter((item): item is AdminSetting => Boolean(item))
|
.filter((item): item is AdminSetting => Boolean(item))
|
||||||
for (const item of items) {
|
|
||||||
used.add(item.key)
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
key: group.key,
|
key: group.key,
|
||||||
title: group.title,
|
title: group.title,
|
||||||
@@ -521,15 +543,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
items,
|
items,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const remaining = magentItems.filter((item) => !used.has(item.key))
|
|
||||||
if (remaining.length) {
|
|
||||||
groups.push({
|
|
||||||
key: 'magent-other',
|
|
||||||
title: 'Additional Magent settings',
|
|
||||||
description: 'Uncategorized Magent settings.',
|
|
||||||
items: remaining,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return groups
|
return groups
|
||||||
})()
|
})()
|
||||||
: visibleSections.map((sectionKey) => ({
|
: visibleSections.map((sectionKey) => ({
|
||||||
@@ -1320,12 +1333,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
||||||
(!settingsSection || section === 'magent') && (
|
(!settingsSection || isMagentGroupedSection) && (
|
||||||
<p className="section-subtitle">
|
<p className="section-subtitle">
|
||||||
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{section === 'magent' && sectionGroup.key === 'magent-runtime' && (
|
{section === 'general' && sectionGroup.key === 'magent-runtime' && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Runtime host/port and SSL values are configuration settings. Container/process
|
Runtime host/port and SSL values are configuration settings. Container/process
|
||||||
restarts may still be required before bind/port changes take effect.
|
restarts may still be required before bind/port changes take effect.
|
||||||
@@ -1845,7 +1858,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.
|
{section === 'magent'
|
||||||
|
? 'Magent runtime settings have moved to General. Notification provider settings have moved to Notifications.'
|
||||||
|
: 'No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const ALLOWED_SECTIONS = new Set([
|
|||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
'magent',
|
'magent',
|
||||||
|
'general',
|
||||||
|
'notifications',
|
||||||
'site',
|
'site',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
title: 'Services',
|
title: 'Services',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/magent', label: 'Magent' },
|
||||||
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
|
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
|
||||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||||
@@ -25,7 +26,8 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
title: 'Admin',
|
title: 'Admin',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/magent', label: 'Magent' },
|
{ href: '/admin/general', label: 'General' },
|
||||||
|
{ href: '/admin/notifications', label: 'Notifications' },
|
||||||
{ href: '/admin/site', label: 'Site' },
|
{ href: '/admin/site', label: 'Site' },
|
||||||
{ href: '/users', label: 'Users' },
|
{ href: '/users', label: 'Users' },
|
||||||
{ href: '/admin/invites', label: 'Invite management' },
|
{ href: '/admin/invites', label: 'Invite management' },
|
||||||
|
|||||||
Reference in New Issue
Block a user