Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY data/branding /app/data/branding
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+26
-1
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException, Response
|
||||
from backend.app import db
|
||||
from backend.app.routers import auth
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def signup(self, code, username, **extra):
|
||||
with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'):
|
||||
return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response())
|
||||
|
||||
async def test_email_invite_binds_account_and_cannot_be_reused(self):
|
||||
invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20)
|
||||
self.assertEqual(invite['max_uses'], 1)
|
||||
public = auth._public_invite_payload(invite)
|
||||
self.assertTrue(public['email_bound'])
|
||||
self.assertNotIn('recipient@example.com', str(public))
|
||||
await self.signup('EMAILTEST', 'first-user')
|
||||
self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('EMAILTEST', 'second-user')
|
||||
|
||||
async def test_email_invite_rejects_recipient_override(self):
|
||||
db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com')
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('BOUNDTEST', 'override-user', email='different@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0)
|
||||
|
||||
async def test_manual_invite_requires_and_saves_email(self):
|
||||
db.create_signup_invite(code='MANUALTEST', max_uses=3)
|
||||
for email in ['', 'invalid']:
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('MANUALTEST', 'manual-user', email=email)
|
||||
await self.signup('MANUALTEST', 'manual-user', email='manual@example.com')
|
||||
self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com')
|
||||
self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2)
|
||||
|
||||
async def test_failed_creation_releases_reservation(self):
|
||||
invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com')
|
||||
with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')):
|
||||
with self.assertRaises(HTTPException):
|
||||
await self.signup('FAILTEST', 'failed-user')
|
||||
self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0)
|
||||
|
||||
async def test_single_use_reservation_is_atomic(self):
|
||||
invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com')
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4))
|
||||
self.assertEqual(sum(results), 1)
|
||||
Reference in New Issue
Block a user