security: harden data auth and deployment
This commit is contained in:
@@ -39,8 +39,7 @@ from ..db import (
|
||||
set_user_jellyseerr_id,
|
||||
set_setting,
|
||||
set_user_blocked,
|
||||
delete_user_by_username,
|
||||
delete_user_activity_by_username,
|
||||
delete_user_data_by_username,
|
||||
set_user_auto_search_enabled,
|
||||
set_auto_search_enabled_for_non_admin_users,
|
||||
set_user_email,
|
||||
@@ -49,6 +48,7 @@ from ..db import (
|
||||
set_user_profile_id,
|
||||
set_user_expires_at,
|
||||
set_user_password,
|
||||
increment_user_auth_version,
|
||||
sync_jellyfin_password_state,
|
||||
set_user_role,
|
||||
run_integrity_check,
|
||||
@@ -69,6 +69,7 @@ from ..db import (
|
||||
get_signup_invite_by_id,
|
||||
create_signup_invite,
|
||||
update_signup_invite,
|
||||
rotate_signup_invite_code,
|
||||
delete_signup_invite,
|
||||
get_signup_invite_by_code,
|
||||
disable_signup_invites_by_creator,
|
||||
@@ -779,7 +780,7 @@ async def test_email_settings(request: Request) -> Dict[str, Any]:
|
||||
result = await send_test_email(recipient_email=recipient_email)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
|
||||
logger.info("Admin triggered SMTP test")
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@@ -1307,12 +1308,12 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
|
||||
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
|
||||
|
||||
if action == "remove":
|
||||
deleted = delete_user_by_username(username)
|
||||
activity_deleted = delete_user_activity_by_username(username)
|
||||
deletion = delete_user_data_by_username(username)
|
||||
deleted = bool(deletion.get("deleted"))
|
||||
result["local"] = {
|
||||
"status": "ok" if deleted else "not_found",
|
||||
"deleted": bool(deleted),
|
||||
"activity_deleted": activity_deleted,
|
||||
"data_cleanup": deletion,
|
||||
}
|
||||
|
||||
if any(
|
||||
@@ -1574,6 +1575,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
|
||||
sync_jellyfin_password_state(username, new_password_clean)
|
||||
increment_user_auth_version(username)
|
||||
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -1917,6 +1919,11 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
|
||||
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
|
||||
)
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail='Invite not found')
|
||||
invite = rotate_signup_invite_code(int(invite['id']), _generate_invite_code())
|
||||
if not invite:
|
||||
raise HTTPException(status_code=409, detail='Invite is unavailable')
|
||||
|
||||
try:
|
||||
result = await send_templated_email(
|
||||
@@ -1930,9 +1937,8 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
logger.info(
|
||||
"Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
|
||||
"Admin sent invite email template: template=%s invite_id=%s username=%s",
|
||||
template_key,
|
||||
result.get("recipient_email"),
|
||||
invite.get("id") if invite else None,
|
||||
user.get("username") if user else None,
|
||||
)
|
||||
@@ -1998,15 +2004,14 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
logger.info(
|
||||
"Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
|
||||
"Admin created invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
|
||||
invite.get("id"),
|
||||
invite.get("code"),
|
||||
invite.get("label"),
|
||||
invite.get("profile_id"),
|
||||
invite.get("role"),
|
||||
invite.get("max_uses"),
|
||||
invite.get("enabled"),
|
||||
invite.get("recipient_email"),
|
||||
bool(invite.get("recipient_email")),
|
||||
send_email,
|
||||
)
|
||||
return {
|
||||
@@ -2029,7 +2034,11 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
existing = get_signup_invite_by_id(invite_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
|
||||
requested_code = _normalize_optional_text(payload.get("code"))
|
||||
if requested_code and not requested_code.startswith("••••") and requested_code != "Protected invite":
|
||||
code = _normalize_invite_code(requested_code)
|
||||
else:
|
||||
code = str(existing.get("code") or "")
|
||||
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
|
||||
enabled = payload.get("enabled")
|
||||
if not isinstance(enabled, bool):
|
||||
@@ -2063,6 +2072,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
email_error = None
|
||||
if send_email:
|
||||
try:
|
||||
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
|
||||
if not rotated:
|
||||
raise ValueError("Invite is unavailable")
|
||||
invite = rotated
|
||||
email_result = await send_templated_email(
|
||||
"invited",
|
||||
invite=invite,
|
||||
@@ -2072,15 +2085,14 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
logger.info(
|
||||
"Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
|
||||
"Admin updated invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
|
||||
invite.get("id"),
|
||||
invite.get("code"),
|
||||
invite.get("label"),
|
||||
invite.get("profile_id"),
|
||||
invite.get("role"),
|
||||
invite.get("max_uses"),
|
||||
invite.get("enabled"),
|
||||
invite.get("recipient_email"),
|
||||
bool(invite.get("recipient_email")),
|
||||
send_email,
|
||||
)
|
||||
return {
|
||||
@@ -2096,6 +2108,22 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/invites/{invite_id}/rotate")
|
||||
async def rotate_invite(
|
||||
invite_id: int,
|
||||
current_user: Dict[str, Any] = Depends(require_admin),
|
||||
) -> Dict[str, Any]:
|
||||
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
|
||||
if not invite:
|
||||
raise HTTPException(status_code=409, detail="Invite is unavailable")
|
||||
logger.info(
|
||||
"Admin rotated invite: invite_id=%s actor=%s",
|
||||
invite_id,
|
||||
current_user.get("username"),
|
||||
)
|
||||
return {"status": "ok", "invite": invite}
|
||||
|
||||
|
||||
@router.delete("/invites/{invite_id}")
|
||||
async def remove_invite(invite_id: int) -> Dict[str, Any]:
|
||||
deleted = delete_signup_invite(invite_id)
|
||||
|
||||
Reference in New Issue
Block a user