Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-07 19:59:51 +12:00
parent 13edcb8136
commit a3b5759708
19 changed files with 439 additions and 305 deletions
+26 -1
View File
@@ -1573,7 +1573,7 @@ def delete_user_profile(profile_id: int) -> bool:
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = row[6]
max_uses = 1 if row[10] else row[6]
use_count = int(row[7] or 0)
expires_at = row[9]
is_expired = _is_datetime_in_past(expires_at)
@@ -1657,6 +1657,8 @@ def create_signup_invite(
recipient_email: Optional[str] = None,
created_by: Optional[str] = None,
) -> Dict[str, Any]:
if recipient_email:
max_uses = 1
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
@@ -1714,6 +1716,11 @@ def update_signup_invite(
expires_at: Optional[str],
recipient_email: Optional[str],
) -> Optional[Dict[str, Any]]:
existing = get_signup_invite_by_id(invite_id)
if recipient_email or (existing and existing.get('recipient_email')):
max_uses = 1
if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
raise ValueError('A used email invitation cannot be reassigned.')
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
@@ -1751,6 +1758,24 @@ def delete_signup_invite(invite_id: int) -> bool:
return cursor.rowcount > 0
def reserve_signup_invite_use(invite_id: int) -> bool:
"""Atomically reserve capacity before any remote account is provisioned."""
with _connect() as conn:
cursor = conn.execute('''
UPDATE signup_invites SET use_count = use_count + 1
WHERE id = ? AND enabled = 1
AND (expires_at IS NULL OR julianday(expires_at) > julianday('now'))
AND ((recipient_email IS NOT NULL AND recipient_email != '' AND use_count < 1)
OR ((recipient_email IS NULL OR recipient_email = '') AND (max_uses IS NULL OR use_count < max_uses)))
''', (invite_id,))
return cursor.rowcount == 1
def release_signup_invite_use(invite_id: int) -> None:
with _connect() as conn:
conn.execute('UPDATE signup_invites SET use_count = MAX(0, use_count - 1) WHERE id = ?', (invite_id,))
def increment_signup_invite_use(invite_id: int) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
+14
View File
@@ -1919,6 +1919,20 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
message = _normalize_optional_text(payload.get("message"))
reason = _normalize_optional_text(payload.get("reason"))
if template_key == 'invited':
if not invite:
raise HTTPException(status_code=400, detail='Choose an invitation before sending it.')
if int(invite.get('use_count') or 0) > 0:
raise HTTPException(status_code=400, detail='This invitation has already been used. Create a new invitation.')
if invite.get('recipient_email') and normalize_delivery_email(invite['recipient_email']) != recipient_email:
raise HTTPException(status_code=400, detail='This invitation belongs to a different recipient. Create a new invitation.')
invite = update_signup_invite(
int(invite['id']), code=invite['code'], label=invite.get('label'),
description=invite.get('description'), profile_id=invite.get('profile_id'),
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
)
try:
result = await send_templated_email(
template_key,
+114 -95
View File
@@ -28,7 +28,8 @@ from ..db import (
create_signup_invite,
update_signup_invite,
delete_signup_invite,
increment_signup_invite_use,
reserve_signup_invite_use,
release_signup_invite_use,
get_user_profile,
get_user_activity,
get_user_activity_summary,
@@ -398,6 +399,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"email_bound": bool(invite.get("recipient_email")),
"label": invite.get("label"),
"description": invite.get("description"),
"enabled": bool(invite.get("enabled")),
@@ -920,6 +922,16 @@ async def signup(payload: dict, response: Response) -> dict:
if remaining_uses is not None and int(remaining_uses) <= 0:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
account_email = normalize_delivery_email(invite.get('recipient_email'))
if account_email:
supplied_email = str(payload.get('email') or '').strip()
if supplied_email and normalize_delivery_email(supplied_email) != account_email:
raise HTTPException(status_code=400, detail='This invitation is tied to the email address it was sent to.')
else:
account_email = normalize_delivery_email(payload.get('email'))
if not account_email:
raise HTTPException(status_code=400, detail='A valid email address is required to create your account.')
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
@@ -946,113 +958,120 @@ async def signup(payload: dict, response: Response) -> dict:
if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
runtime = get_runtime_settings()
auth_provider = "local"
local_password_value = password_value
matched_jellyseerr_user_id: int | None = None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
logger.info("signup provisioning jellyfin username=%s", username)
auth_provider = "jellyfin"
if not reserve_signup_invite_use(int(invite['id'])):
raise HTTPException(status_code=403, detail='This invitation has already been used or is unavailable.')
account_created = False
try:
runtime = get_runtime_settings()
auth_provider = "local"
local_password_value = password_value
try:
await jellyfin_client.create_user_with_password(username, password_value)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else None
duplicate_like = status_code in {400, 409}
if duplicate_like:
try:
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
matched_jellyseerr_user_id: int | None = None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
logger.info("signup provisioning jellyfin username=%s", username)
auth_provider = "jellyfin"
local_password_value = password_value
try:
await jellyfin_client.create_user_with_password(username, password_value)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else None
duplicate_like = status_code in {400, 409}
if duplicate_like:
try:
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
) from exc
if not isinstance(auth_response, dict) or not auth_response.get("User"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.",
) from exc
else:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
if not isinstance(auth_response, dict) or not auth_response.get("User"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.",
) from exc
else:
except Exception as exc:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
except Exception as exc:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
await _refresh_jellyfin_user_cache(jellyfin_client)
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
if candidate_map:
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
await _refresh_jellyfin_user_cache(jellyfin_client)
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
if candidate_map:
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
try:
create_user(
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,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
if auth_provider == "jellyfin":
sync_jellyfin_password_state(username, password_value)
if (
created_user
and created_user.get("jellyseerr_user_id") is None
and matched_jellyseerr_user_id is not None
):
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
created_user = get_user_by_username(username)
if created_user:
try:
await send_templated_email(
"welcome",
invite=invite,
user=created_user,
create_user(
username,
local_password_value,
role=role,
email=account_email,
auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
)
except Exception as exc:
# Welcome email delivery is best-effort and must not break signup.
logger.warning("Welcome email send skipped for %s: %s", username, exc)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
logger.info(
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
username,
role,
created_user.get("auth_provider") if created_user else auth_provider,
created_user.get("profile_id") if created_user else None,
invite.get("code"),
)
return _auth_success_response(
response,
token,
{
"username": username,
"role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
account_created = True
created_user = get_user_by_username(username)
if auth_provider == "jellyfin":
sync_jellyfin_password_state(username, password_value)
if (
created_user
and created_user.get("jellyseerr_user_id") is None
and matched_jellyseerr_user_id is not None
):
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
created_user = get_user_by_username(username)
if created_user:
try:
await send_templated_email(
"welcome",
invite=invite,
user=created_user,
)
except Exception as exc:
# Welcome email delivery is best-effort and must not break signup.
logger.warning("Welcome email send skipped for %s: %s", username, exc)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
logger.info(
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
username,
role,
created_user.get("auth_provider") if created_user else auth_provider,
created_user.get("profile_id") if created_user else None,
invite.get("code"),
)
return _auth_success_response(
response,
token,
{
"username": username,
"role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
)
finally:
if not account_created:
release_signup_invite_use(int(invite['id']))
@router.post("/password/forgot")