From 56b9d14e84fbc8805bca0998bcde825aa74e7e17 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 16:08:36 +0300 Subject: [PATCH 1/3] Implement recoverable account deletion and lifecycle emails --- .env.example | 4 + ...8b9c0d1e234_order_account_notifications.py | 23 ++ .../e6f7a8b9c012_add_account_deletion.py | 34 ++ .../f7a8b9c0d123_add_account_notifications.py | 36 ++ papyrus/api/routes/__init__.py | 3 + papyrus/api/routes/account_deletion.py | 121 +++++++ papyrus/api/routes/auth.py | 15 +- papyrus/api/routes/users.py | 23 +- papyrus/config.py | 6 + papyrus/main.py | 10 +- papyrus/models/__init__.py | 13 +- papyrus/models/auth.py | 20 +- papyrus/models/media.py | 9 + papyrus/models/user.py | 3 + papyrus/schemas/account_deletion.py | 42 +++ papyrus/schemas/user.py | 7 +- papyrus/services/account_deletion.py | 312 ++++++++++++++++ papyrus/services/account_notifications.py | 230 ++++++++++++ papyrus/services/acquisition_monitor.py | 4 +- papyrus/services/auth/_core.py | 2 + papyrus/services/auth/google.py | 62 +++- papyrus/services/auth/sessions.py | 18 +- papyrus/services/auth/types.py | 1 + papyrus/services/email.py | 8 +- papyrus/services/media.py | 7 +- papyrus/services/sync.py | 8 +- papyrus/services/users.py | 22 +- papyrus/templates/account_notification.html | 31 ++ papyrus/web/account_deletion.html | 153 ++++++++ tests/api/routes/test_account_deletion.py | 335 ++++++++++++++++++ tests/api/routes/test_auth.py | 2 + tests/api/routes/test_users.py | 14 - tests/services/test_account_deletion.py | 106 ++++++ tests/services/test_account_notifications.py | 231 ++++++++++++ tests/services/test_users.py | 13 - tests/test_account_deletion_migration.py | 55 +++ tests/test_acquisition_monitor_lifespan.py | 30 +- 37 files changed, 1927 insertions(+), 86 deletions(-) create mode 100644 alembic/versions/a8b9c0d1e234_order_account_notifications.py create mode 100644 alembic/versions/e6f7a8b9c012_add_account_deletion.py create mode 100644 alembic/versions/f7a8b9c0d123_add_account_notifications.py create mode 100644 papyrus/api/routes/account_deletion.py create mode 100644 papyrus/schemas/account_deletion.py create mode 100644 papyrus/services/account_deletion.py create mode 100644 papyrus/services/account_notifications.py create mode 100644 papyrus/templates/account_notification.html create mode 100644 papyrus/web/account_deletion.html create mode 100644 tests/api/routes/test_account_deletion.py create mode 100644 tests/services/test_account_deletion.py create mode 100644 tests/services/test_account_notifications.py create mode 100644 tests/test_account_deletion_migration.py diff --git a/.env.example b/.env.example index 154c927..dda0f89 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,7 @@ POSTGRES_PASSWORD=papyrus POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_DB=papyrus + +# Optional public deletion notice for operator-managed backup/exceptional retention. +# Automated backups are not implemented. Planned future expiry: 30 days after deletion. +# ACCOUNT_DELETION_RETENTION_NOTICE="Your server's actual retention policy" diff --git a/alembic/versions/a8b9c0d1e234_order_account_notifications.py b/alembic/versions/a8b9c0d1e234_order_account_notifications.py new file mode 100644 index 0000000..18a7b46 --- /dev/null +++ b/alembic/versions/a8b9c0d1e234_order_account_notifications.py @@ -0,0 +1,23 @@ +"""Keep lifecycle notices ordered independently of delivery retries.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "a8b9c0d1e234" +down_revision: str | Sequence[str] | None = "f7a8b9c0d123" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "account_notifications", + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("account_notifications", "created_at") diff --git a/alembic/versions/e6f7a8b9c012_add_account_deletion.py b/alembic/versions/e6f7a8b9c012_add_account_deletion.py new file mode 100644 index 0000000..3b2ebb0 --- /dev/null +++ b/alembic/versions/e6f7a8b9c012_add_account_deletion.py @@ -0,0 +1,34 @@ +"""Add scheduled account deletion and durable media cleanup.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "e6f7a8b9c012" +down_revision: str | Sequence[str] | None = "b5c6d7e8f901" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("deletion_requested_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("deletion_due_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_users_deletion_due_at", "users", ["deletion_due_at"]) + op.add_column("auth_exchange_codes", sa.Column("code_challenge", sa.String(64), nullable=True)) + + op.create_table( + "account_media_cleanup", + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("user_id"), + ) + + +def downgrade() -> None: + op.drop_table("account_media_cleanup") + op.drop_column("auth_exchange_codes", "code_challenge") + op.drop_index("ix_users_deletion_due_at", table_name="users") + op.drop_column("users", "deletion_due_at") + op.drop_column("users", "deletion_requested_at") diff --git a/alembic/versions/f7a8b9c0d123_add_account_notifications.py b/alembic/versions/f7a8b9c0d123_add_account_notifications.py new file mode 100644 index 0000000..5a1c171 --- /dev/null +++ b/alembic/versions/f7a8b9c0d123_add_account_notifications.py @@ -0,0 +1,36 @@ +"""Queue account lifecycle notifications transactionally.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "f7a8b9c0d123" +down_revision: str | Sequence[str] | None = "e6f7a8b9c012" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "account_notifications", + sa.Column("notification_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("recipient", sa.String(255), nullable=False), + sa.Column("subject", sa.String(255), nullable=False), + sa.Column("text_body", sa.Text(), nullable=False), + sa.Column("html_body", sa.Text(), nullable=False), + sa.Column("attempts", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("notification_id"), + ) + + op.create_index("ix_account_notifications_user_id", "account_notifications", ["user_id"]) + op.create_index("ix_account_notifications_next_attempt_at", "account_notifications", ["next_attempt_at"]) + + +def downgrade() -> None: + op.drop_table("account_notifications") diff --git a/papyrus/api/routes/__init__.py b/papyrus/api/routes/__init__.py index b1b24af..93af6ab 100644 --- a/papyrus/api/routes/__init__.py +++ b/papyrus/api/routes/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI from papyrus.api.routes import ( + account_deletion, acquisition, annotations, auth, @@ -28,6 +29,8 @@ api_router = APIRouter() +api_router.include_router(account_deletion.router, prefix="/account-deletion", tags=["Account deletion"]) + api_router.include_router(opds.router, prefix="/opds", tags=["OPDS"]) api_router.include_router(acquisition.router, prefix="/acquisition", tags=["Acquisition"]) diff --git a/papyrus/api/routes/account_deletion.py b/papyrus/api/routes/account_deletion.py new file mode 100644 index 0000000..b1eeef1 --- /dev/null +++ b/papyrus/api/routes/account_deletion.py @@ -0,0 +1,121 @@ +"""Public, narrowly authorized deletion and recovery routes.""" + +from pathlib import Path +from secrets import token_urlsafe +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.api.deps import CurrentSessionId, CurrentUserId +from papyrus.api.routes.auth import _auth_rate_limit, _auth_tokens_response, _public_callback_url +from papyrus.config import get_settings +from papyrus.core.database import get_db +from papyrus.core.rate_limit import limiter +from papyrus.schemas.account_deletion import ( + AccountActionRequest, + AccountDeletionAuthorization, + AccountDeletionStatus, + AccountGoogleExchangeRequest, + AccountPasswordRequest, + AccountRecoveryRequest, +) +from papyrus.schemas.auth import AuthTokens +from papyrus.services import account_deletion +from papyrus.services.auth.google import build_google_account_authorization_url + + +def no_store(response: Response) -> None: + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + + +router = APIRouter(dependencies=[Depends(no_store)]) +DBSession = Annotated[AsyncSession, Depends(get_db)] + + +@router.get("", response_class=HTMLResponse, include_in_schema=False) +async def account_deletion_page() -> HTMLResponse: + nonce = token_urlsafe(24) + template = Path(__file__).parents[2] / "web" / "account_deletion.html" + + return HTMLResponse( + template.read_text().replace("__NONCE__", nonce), + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": ( + f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; " + "connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" + ), + }, + ) + + +@router.post("/password", response_model=AccountDeletionAuthorization) +@limiter.limit(_auth_rate_limit) +async def authenticate_password( + request: Request, payload: AccountPasswordRequest, db: DBSession +) -> AccountDeletionAuthorization: + return await account_deletion.authenticate_password(db, payload) + + +@router.get("/google/start") +@limiter.limit(_auth_rate_limit) +async def google_start( + request: Request, + redirect_uri: str, + challenge: str = Query(pattern=r"^[0-9a-f]{64}$"), + expected_user_id: UUID | None = None, +) -> RedirectResponse: + url = build_google_account_authorization_url( + redirect_uri, + _public_callback_url(request, "google_oauth_callback"), + challenge, + expected_user_id, + ) + + return RedirectResponse(url, status_code=302) + + +@router.post("/exchange", response_model=AccountDeletionAuthorization) +@limiter.limit(_auth_rate_limit) +async def exchange( + request: Request, payload: AccountGoogleExchangeRequest, db: DBSession +) -> AccountDeletionAuthorization: + return await account_deletion.exchange_google(db, payload.code, payload.verifier) + + +@router.post("/schedule", response_model=AccountDeletionStatus) +@limiter.limit(_auth_rate_limit) +async def schedule(request: Request, payload: AccountActionRequest, db: DBSession) -> AccountDeletionStatus: + return await account_deletion.schedule_deletion(db, payload.authorization_token) + + +@router.post("/current", response_model=AccountDeletionStatus) +@limiter.limit(_auth_rate_limit) +async def schedule_current( + request: Request, db: DBSession, user_id: CurrentUserId, session_id: CurrentSessionId +) -> AccountDeletionStatus: + return await account_deletion.schedule_current_account(db, user_id, session_id) + + +@router.post("/cancel", response_model=AuthTokens) +@limiter.limit(_auth_rate_limit) +async def cancel(request: Request, payload: AccountRecoveryRequest, db: DBSession) -> AuthTokens: + result = await account_deletion.cancel_deletion( + db, + payload.authorization_token, + client_type=payload.client_type, + device_label=payload.device_label, + user_agent=request.headers.get("user-agent"), + ) + + return await _auth_tokens_response(db, result) + + +@router.get("/policy") +async def retention_policy() -> dict[str, str]: + return {"retention_notice": get_settings().account_deletion_retention_notice} diff --git a/papyrus/api/routes/auth.py b/papyrus/api/routes/auth.py index f390328..e601840 100644 --- a/papyrus/api/routes/auth.py +++ b/papyrus/api/routes/auth.py @@ -32,6 +32,7 @@ from papyrus.schemas.common import MessageResponse from papyrus.schemas.user import User from papyrus.services import auth as auth_service +from papyrus.services.users import user_response router = APIRouter() DBSession = Annotated[AsyncSession, Depends(get_db)] @@ -41,13 +42,13 @@ def _auth_rate_limit() -> str: return f"{get_settings().rate_limit_auth}/minute" -def _auth_tokens_response(result: auth_service.AuthResult) -> AuthTokens: +async def _auth_tokens_response(db: AsyncSession, result: auth_service.AuthResult) -> AuthTokens: return AuthTokens( access_token=result.access_token, refresh_token=result.refresh_token, token_type="Bearer", expires_in=result.expires_in, - user=User.model_validate(result.user), + user=await user_response(db, result.user), ) @@ -86,7 +87,7 @@ async def register_user( ) -> AuthTokens: """Create a new user account with email and password.""" result = await auth_service.register_user(db, payload, request.headers.get("user-agent")) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.post( @@ -102,7 +103,7 @@ async def login_user( ) -> AuthTokens: """Authenticate a user with email and password credentials.""" result = await auth_service.login_user(db, payload, request.headers.get("user-agent")) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.get( @@ -161,7 +162,7 @@ async def exchange_code( ) -> AuthTokens: """Exchange a one-time OAuth handoff code for Papyrus tokens.""" result = await auth_service.exchange_login_code(db, payload, request.headers.get("user-agent")) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.post( @@ -177,7 +178,7 @@ async def refresh_token( ) -> AuthTokens: """Exchange a valid refresh token for a new access token.""" result = await auth_service.refresh_tokens(db, payload) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.post( @@ -246,7 +247,7 @@ async def complete_google_link( ) -> User: """Consume a one-time code and link the Google identity to the authenticated user.""" user = await auth_service.complete_google_link(db, user_id, request.code) - return User.model_validate(user) + return await user_response(db, user) @router.post( diff --git a/papyrus/api/routes/users.py b/papyrus/api/routes/users.py index 9ca5263..7ca81a9 100644 --- a/papyrus/api/routes/users.py +++ b/papyrus/api/routes/users.py @@ -2,14 +2,14 @@ from typing import Annotated -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId from papyrus.core.database import get_db from papyrus.schemas.auth import ChangePasswordRequest from papyrus.schemas.common import MessageResponse -from papyrus.schemas.user import DeleteAccountRequest, UpdateUserRequest, User, UserPreferences +from papyrus.schemas.user import UpdateUserRequest, User, UserPreferences from papyrus.services import users as user_service router = APIRouter() @@ -24,7 +24,7 @@ async def get_current_user(user_id: CurrentUserId, db: DBSession) -> User: """Return the authenticated user's profile information.""" user = await user_service.get_user_profile(db, user_id) - return User.model_validate(user) + return await user_service.user_response(db, user) @router.patch( @@ -39,22 +39,7 @@ async def update_current_user( ) -> User: """Update the authenticated user's profile information.""" user = await user_service.update_user_profile(db, user_id, request) - return User.model_validate(user) - - -@router.delete( - "/me", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete current user account", -) -async def delete_current_user( - user_id: CurrentUserId, - request: DeleteAccountRequest, - db: DBSession, -) -> Response: - """Disable the user account and revoke active sessions.""" - await user_service.delete_user_account(db, user_id, request.password) - return Response(status_code=status.HTTP_204_NO_CONTENT) + return await user_service.user_response(db, user) @router.get( diff --git a/papyrus/config.py b/papyrus/config.py index 7558a32..9f9cc82 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -24,6 +24,12 @@ class Settings(BaseSettings): postgres_db: str secret_key: str algorithm: str + account_deletion_retention_notice: str = ( + "Automated database and media backups are not currently implemented. " + "The planned backup retention limit is 30 days after permanent deletion. " + "Self-hosted operators must disclose any separate backups or exceptional retention." + ) + access_token_expire_minutes: int refresh_token_expire_days: int rate_limit_auth: int diff --git a/papyrus/main.py b/papyrus/main.py index 817a5cb..d27b21b 100644 --- a/papyrus/main.py +++ b/papyrus/main.py @@ -22,7 +22,7 @@ from papyrus.core.dev_pages import DEV_PAGES_DIST_DIR, DEV_PAGES_STATIC_URL from papyrus.core.exceptions import AppError from papyrus.core.rate_limit import limiter, rate_limit_exceeded_handler -from papyrus.services import acquisition_monitor +from papyrus.services import account_deletion, acquisition_monitor settings = get_settings() @@ -44,6 +44,9 @@ def configure_logging() -> None: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan events.""" + cleanup_task = asyncio.create_task( + account_deletion.run_cleanup(async_session_maker), name="account-deletion-cleanup" + ) monitor_task: asyncio.Task[None] | None = None if settings.acquisition_enabled and settings.acquisition_import_root is not None: @@ -58,6 +61,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: try: yield finally: + cleanup_task.cancel() + + with suppress(asyncio.CancelledError): + await cleanup_task + if monitor_task is not None: monitor_task.cancel() diff --git a/papyrus/models/__init__.py b/papyrus/models/__init__.py index 1d8c4d2..76e3807 100644 --- a/papyrus/models/__init__.py +++ b/papyrus/models/__init__.py @@ -1,6 +1,13 @@ from papyrus.core.database import Base from papyrus.models.acquisition import AcquisitionEndpoint, AcquisitionJob, AcquisitionRule -from papyrus.models.auth import AuthExchangeCode, AuthSession, EmailActionToken, PasswordCredential, UserIdentity +from papyrus.models.auth import ( + AccountNotification, + AuthExchangeCode, + AuthSession, + EmailActionToken, + PasswordCredential, + UserIdentity, +) from papyrus.models.library import ( SyncAnnotation, SyncBookmark, @@ -11,7 +18,7 @@ SyncTag, SyncTombstone, ) -from papyrus.models.media import MediaAsset +from papyrus.models.media import AccountMediaCleanup, MediaAsset from papyrus.models.powersync_demo import PowerSyncDemoItem from papyrus.models.sync import SyncBook from papyrus.models.tracking import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal @@ -32,11 +39,13 @@ "AcquisitionEndpoint", "AcquisitionJob", "AcquisitionRule", + "AccountNotification", "AuthExchangeCode", "AuthSession", "Base", "EmailActionToken", "MediaAsset", + "AccountMediaCleanup", "PasswordCredential", "PowerSyncDemoItem", "SyncBook", diff --git a/papyrus/models/auth.py b/papyrus/models/auth.py index 8f6f6bb..10b303d 100644 --- a/papyrus/models/auth.py +++ b/papyrus/models/auth.py @@ -5,7 +5,7 @@ from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, Uuid, func, text +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, Uuid, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from papyrus.core.database import Base @@ -96,6 +96,7 @@ class AuthExchangeCode(Base): email_verified_at_provider: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) + code_challenge: Mapped[str | None] = mapped_column(String(64), nullable=True) redirect_uri: Mapped[str] = mapped_column(Text, nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) @@ -117,3 +118,20 @@ class EmailActionToken(Base): from papyrus.models.user import User # noqa: E402 + + +class AccountNotification(Base): + """Pending notices are removed after delivery, expiry, or permanent account deletion.""" + + __tablename__ = "account_notifications" + + notification_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True) + recipient: Mapped[str] = mapped_column(String(255), nullable=False) + subject: Mapped[str] = mapped_column(String(255), nullable=False) + text_body: Mapped[str] = mapped_column(Text, nullable=False) + html_body: Mapped[str] = mapped_column(Text, nullable=False) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/papyrus/models/media.py b/papyrus/models/media.py index 1dc2c77..0bc36b7 100644 --- a/papyrus/models/media.py +++ b/papyrus/models/media.py @@ -35,3 +35,12 @@ class MediaAsset(Base): storage_path: Mapped[str] = mapped_column(String(2048), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class AccountMediaCleanup(Base): + """Durable cleanup work surviving the account's cascading database deletion.""" + + __tablename__ = "account_media_cleanup" + + user_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/papyrus/models/user.py b/papyrus/models/user.py index f13b041..18d14e7 100644 --- a/papyrus/models/user.py +++ b/papyrus/models/user.py @@ -32,6 +32,9 @@ class User(Base): last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deletion_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deletion_due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + identities: Mapped[list[UserIdentity]] = relationship( back_populates="user", cascade="all, delete-orphan", diff --git a/papyrus/schemas/account_deletion.py b/papyrus/schemas/account_deletion.py new file mode 100644 index 0000000..2387755 --- /dev/null +++ b/papyrus/schemas/account_deletion.py @@ -0,0 +1,42 @@ +"""Restricted account deletion and recovery contracts.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, EmailStr, Field + +from papyrus.schemas.auth import ClientType + + +class AccountPasswordRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=128) + expected_user_id: UUID | None = None + + +class AccountGoogleExchangeRequest(BaseModel): + code: str = Field(min_length=1, max_length=512) + verifier: str = Field(min_length=43, max_length=128) + + +class AccountActionRequest(BaseModel): + authorization_token: str = Field(min_length=1, max_length=512) + + +class AccountRecoveryRequest(AccountActionRequest): + client_type: ClientType = "unknown" + device_label: str | None = Field(default=None, max_length=255) + + +class AccountDeletionAuthorization(BaseModel): + authorization_token: str + user_id: UUID + email: str | None + deletion_pending: bool + deletion_due_at: datetime + expires_at: datetime + + +class AccountDeletionStatus(BaseModel): + deletion_due_at: datetime | None + message: str diff --git a/papyrus/schemas/user.py b/papyrus/schemas/user.py index 95bf0a6..134609e 100644 --- a/papyrus/schemas/user.py +++ b/papyrus/schemas/user.py @@ -18,6 +18,7 @@ class User(BaseModel): email_verified: bool = False created_at: datetime last_login_at: datetime | None = None + connected_providers: list[str] = Field(default_factory=list) class UpdateUserRequest(BaseModel): @@ -36,9 +37,3 @@ class UserPreferences(BaseModel): notifications_enabled: bool | None = Field(None, examples=[True]) default_shelf: str | None = Field(None, examples=["Currently Reading"]) sync_wifi_only: bool | None = Field(None, examples=[False]) - - -class DeleteAccountRequest(BaseModel): - """Account deletion request.""" - - password: str = Field(..., description="Current password for confirmation") diff --git a/papyrus/services/account_deletion.py b/papyrus/services/account_deletion.py new file mode 100644 index 0000000..945ad50 --- /dev/null +++ b/papyrus/services/account_deletion.py @@ -0,0 +1,312 @@ +"""Reauthentication capabilities, explicit recovery, and retryable account erasure.""" + +import asyncio +import logging +import secrets +import shutil +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import NoReturn +from uuid import UUID + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from papyrus.config import get_settings +from papyrus.core.exceptions import AppError, ForbiddenError, UnauthorizedError, ValidationError +from papyrus.core.security import generate_opaque_token, hash_opaque_token, verify_password +from papyrus.models import ( + AccountMediaCleanup, + AuthExchangeCode, + AuthSession, + EmailActionToken, + PasswordCredential, + User, +) +from papyrus.schemas.account_deletion import AccountDeletionAuthorization, AccountDeletionStatus, AccountPasswordRequest +from papyrus.services.account_notifications import deliver_pending_notifications, queue_account_notification +from papyrus.services.auth._core import _create_session_for_user +from papyrus.services.auth.types import AuthResult +from papyrus.services.media import media_root + +RECOVERY_WINDOW = timedelta(days=30) +AUTHORIZATION_LIFETIME = timedelta(minutes=5) +AUTHORIZATION_PURPOSE = "account_deletion" +GOOGLE_HANDOFF_PURPOSE = "account_deletion_google" +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(UTC) + + +async def _lock_user(session: AsyncSession, user_id: UUID) -> User: + user = await session.scalar( + select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True) + ) + + if user is None: + raise UnauthorizedError("Account is unavailable") + + if user.deletion_due_at is not None and user.deletion_due_at <= _now(): + raise ForbiddenError("The recovery deadline has passed") + + if user.disabled_at is not None and user.deletion_due_at is None: + raise ForbiddenError("User account is disabled") + + return user + + +async def _authorize(session: AsyncSession, user: User) -> AccountDeletionAuthorization: + now = _now() + token = generate_opaque_token() + expires_at = now + AUTHORIZATION_LIFETIME + + session.add( + AuthExchangeCode( + code_hash=hash_opaque_token(token), + purpose=AUTHORIZATION_PURPOSE, + user_id=user.user_id, + redirect_uri="", + created_at=now, + expires_at=expires_at, + ) + ) + + await session.commit() + + return AccountDeletionAuthorization( + authorization_token=token, + user_id=user.user_id, + email=user.primary_email, + deletion_pending=user.deletion_due_at is not None, + deletion_due_at=user.deletion_due_at or now + RECOVERY_WINDOW, + expires_at=expires_at, + ) + + +async def require_account_recovery(session: AsyncSession, user_id: UUID) -> NoReturn: + """Offer recovery after successful sign-in without granting a session.""" + user = await _lock_user(session, user_id) + + if user.deletion_due_at is None: + raise UnauthorizedError("Account status changed. Sign in again.") + + authorization = await _authorize(session, user) + + raise AppError( + "This account is scheduled for deletion.", + code="ACCOUNT_DELETION_PENDING", + status_code=403, + details={"authorization": authorization.model_dump(mode="json")}, + ) + + +async def authenticate_password( + session: AsyncSession, + request: AccountPasswordRequest, +) -> AccountDeletionAuthorization: + result = await session.execute( + select(User.user_id, PasswordCredential.password_hash) + .join(PasswordCredential, PasswordCredential.user_id == User.user_id) + .where(User.primary_email == request.email.strip().lower()) + ) + + credentials = result.one_or_none() + + if credentials is None or not verify_password(request.password, credentials.password_hash): + raise UnauthorizedError("Invalid email or password") + + if request.expected_user_id is not None and credentials.user_id != request.expected_user_id: + raise ForbiddenError("Authenticate with the account currently signed in") + + user = await _lock_user(session, credentials.user_id) + return await _authorize(session, user) + + +async def _lock_authorization(session: AsyncSession, token: str, purpose: str) -> tuple[User, AuthExchangeCode]: + """Lock the owner before the capability, matching scheduling and purge lock order.""" + token_hash = hash_opaque_token(token) + + user_id = await session.scalar( + select(AuthExchangeCode.user_id).where( + AuthExchangeCode.code_hash == token_hash, AuthExchangeCode.purpose == purpose + ) + ) + + if user_id is None: + raise UnauthorizedError("Authorization is invalid or expired. Authenticate again.") + + user = await _lock_user(session, user_id) + + authorization = await session.scalar( + select(AuthExchangeCode) + .where(AuthExchangeCode.code_hash == token_hash, AuthExchangeCode.purpose == purpose) + .with_for_update() + .execution_options(populate_existing=True) + ) + + if authorization is None or authorization.used_at is not None or authorization.expires_at <= _now(): + raise UnauthorizedError("Authorization is invalid or expired. Authenticate again.") + + return user, authorization + + +async def exchange_google(session: AsyncSession, code: str, verifier: str) -> AccountDeletionAuthorization: + user, handoff = await _lock_authorization(session, code, GOOGLE_HANDOFF_PURPOSE) + + if handoff.code_challenge is None or not secrets.compare_digest( + handoff.code_challenge, hash_opaque_token(verifier) + ): + raise UnauthorizedError("Google authorization does not match this browser session") + + handoff.used_at = _now() + return await _authorize(session, user) + + +async def _revoke_credentials(session: AsyncSession, user_id: UUID) -> None: + now = _now() + await session.execute(update(AuthSession).where(AuthSession.user_id == user_id).values(revoked_at=now)) + await session.execute(update(AuthExchangeCode).where(AuthExchangeCode.user_id == user_id).values(used_at=now)) + await session.execute(update(EmailActionToken).where(EmailActionToken.user_id == user_id).values(used_at=now)) + + +async def schedule_deletion(session: AsyncSession, token: str) -> AccountDeletionStatus: + user, authorization = await _lock_authorization(session, token, AUTHORIZATION_PURPOSE) + return await _schedule_deletion(session, user, authorization.created_at) + + +async def schedule_current_account(session: AsyncSession, user_id: UUID, session_id: UUID) -> AccountDeletionStatus: + """Serialize deletion with recovery and verify that the initiating session remains active.""" + user = await _lock_user(session, user_id) + + active_session = await session.scalar( + select(AuthSession) + .where( + AuthSession.session_id == session_id, + AuthSession.user_id == user_id, + AuthSession.revoked_at.is_(None), + AuthSession.expires_at > _now(), + ) + .execution_options(populate_existing=True) + ) + + if active_session is None: + raise UnauthorizedError("Session is no longer active") + + return await _schedule_deletion(session, user, _now()) + + +async def _schedule_deletion(session: AsyncSession, user: User, requested_at: datetime) -> AccountDeletionStatus: + if user.deletion_due_at is not None: + raise ValidationError("Deletion is already scheduled") + + user.deletion_requested_at = requested_at + user.deletion_due_at = requested_at + RECOVERY_WINDOW + user.disabled_at = _now() + await _revoke_credentials(session, user.user_id) + await queue_account_notification(session, user) + await session.commit() + + return AccountDeletionStatus( + deletion_due_at=user.deletion_due_at, + message="Account deletion scheduled. All sessions have been revoked. Local device data has not been deleted.", + ) + + +async def cancel_deletion( + session: AsyncSession, + token: str, + *, + client_type: str = "unknown", + device_label: str | None = None, + user_agent: str | None = None, +) -> AuthResult: + """Consume the recovery proof and restore access with a new session in one transaction.""" + user, _ = await _lock_authorization(session, token, AUTHORIZATION_PURPOSE) + + if user.deletion_due_at is None: + raise ValidationError("No account deletion is scheduled") + + user.deletion_requested_at = None + user.deletion_due_at = None + user.disabled_at = None + await _revoke_credentials(session, user.user_id) + result = await _create_session_for_user(session, user, client_type, device_label, user_agent) + await queue_account_notification(session, user) + await session.commit() + return result + + +async def purge_due_accounts(session: AsyncSession) -> int: + """Commit cascade deletion and cleanup work atomically before touching physical files.""" + users = ( + await session.scalars( + select(User).where(User.deletion_due_at <= _now()).with_for_update(skip_locked=True).limit(100) + ) + ).all() + + for user in users: + session.add(AccountMediaCleanup(user_id=user.user_id)) + await session.execute(delete(User).where(User.user_id == user.user_id)) + + await session.commit() + return len(users) + + +def _remove_owner_files(user_id: UUID) -> None: + """Only remove the UUID-scoped directories, never follow an owner symlink.""" + roots = [media_root()] + import_root = get_settings().acquisition_import_root + + if import_root is not None: + roots.append(Path(import_root)) + + for root in roots: + path = root / str(user_id) + + if path.is_symlink(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + +async def cleanup_deleted_media(session: AsyncSession) -> int: + cleaned = 0 + user_ids = (await session.scalars(select(AccountMediaCleanup.user_id).limit(100))).all() + await session.commit() + + for user_id in user_ids: + try: + job = await session.scalar( + select(AccountMediaCleanup) + .where(AccountMediaCleanup.user_id == user_id) + .with_for_update(skip_locked=True) + ) + + if job is None: + await session.rollback() + continue + + await asyncio.to_thread(_remove_owner_files, user_id) + await session.delete(job) + await session.commit() + cleaned += 1 + except Exception: + await session.rollback() + logger.exception("Account media cleanup failed and will be retried") + + return cleaned + + +async def run_cleanup(session_maker: async_sessionmaker[AsyncSession]) -> None: + while True: + try: + async with session_maker() as session: + await purge_due_accounts(session) + await cleanup_deleted_media(session) + await deliver_pending_notifications(session) + except Exception: + logger.exception("Account deletion cleanup cycle failed and will be retried") + + await asyncio.sleep(60) diff --git a/papyrus/services/account_notifications.py b/papyrus/services/account_notifications.py new file mode 100644 index 0000000..5d3cb86 --- /dev/null +++ b/papyrus/services/account_notifications.py @@ -0,0 +1,230 @@ +"""Branded account notices and a transactional, retryable delivery queue.""" + +import asyncio +import logging +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from html import escape +from pathlib import Path +from string import Template +from urllib.parse import urlsplit + +from sqlalchemy import delete, exists, select, tuple_, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from papyrus.config import get_settings +from papyrus.models import AccountNotification, User +from papyrus.services import email + +logger = logging.getLogger(__name__) +NOTICE_LIFETIME = timedelta(days=7) +DELIVERY_LEASE = timedelta(minutes=5) + + +@dataclass(frozen=True) +class AccountEmail: + subject: str + text: str + html: str + + +def _sign_in_url() -> str | None: + settings = get_settings() + + if settings.app_public_base_url: + url = f"{settings.app_public_base_url.rstrip('/')}/login" + elif settings.public_base_url: + url = f"{settings.public_base_url.rstrip('/')}{settings.api_prefix}/account-deletion" + else: + return None + + parsed = urlsplit(url) + return url if parsed.scheme in {"https", "http"} and parsed.netloc else None + + +def render_account_email(display_name: str, deletion_due_at: datetime | None) -> AccountEmail: + """Keep email links informational: recovery still requires sign-in and explicit consent.""" + greeting = f"Hi {display_name}," if display_name else "Hello," + deadline_html = "" + deadline_text = "" + + if deletion_due_at is not None: + deadline = deletion_due_at.astimezone(UTC).strftime("%B %d, %Y at %H:%M UTC") + subject = "Your Papyrus account is scheduled for deletion" + heading = "Your account is scheduled for deletion" + preheader = f"You can keep your account until {deadline}." + summary = ( + "We received your request to delete your Papyrus account. " + "Your profile, synced library and reading data will be permanently deleted after the deadline below." + ) + + guidance = "Changed your mind? Sign in before this date and choose Keep account." + + security_note = ( + "If you didn't request this, sign in and choose Keep account before the deadline, " + "then secure the password or Google account you use to sign in." + ) + + action_label = "Sign in to keep your account" + deadline_text = f"Recovery deadline: {deadline}\n\n" + deadline_html = ( + '
' + '

RECOVERY DEADLINE

' + f'

{escape(deadline)}

' + ) + else: + subject = "Your Papyrus account deletion was cancelled" + heading = "Your account is staying" + preheader = "Deletion has been cancelled. Your library and reading data are still here." + summary = "Your account deletion request has been cancelled. Your profile, library and reading data are safe." + guidance = "You can continue using Papyrus as usual. No further action is needed." + + security_note = ( + "If you didn't keep this account, secure the password or Google account you use to sign in. " + "You can review your account in Papyrus." + ) + + action_label = "Open Papyrus" + + url = _sign_in_url() + action_html = "" + action_text = "" + + if url is not None: + escaped_url = escape(url, quote=True) + action_text = f"{action_label}: {url}\n\n" + + action_html = ( + '' + '
' + f'' + f"{escape(action_label)}
" + '

' + f'Or open {escaped_url}

' + ) + + template = Template((Path(__file__).parents[1] / "templates" / "account_notification.html").read_text()) + + html = template.substitute( + subject=escape(subject), + preheader=escape(preheader), + heading=escape(heading), + greeting=escape(greeting), + summary=escape(summary), + deadline=deadline_html, + guidance=escape(guidance), + action=action_html, + security_note=escape(security_note), + ) + + text = ( + f"Papyrus\n\n{heading}\n\n{greeting}\n\n{summary}\n\n" + f"{deadline_text}{guidance}\n\n{action_text}{security_note}\n\n" + "This is an automatic account notification from Papyrus." + ) + + return AccountEmail(subject=subject, text=text, html=html) + + +async def queue_account_notification(session: AsyncSession, user: User) -> None: + """Keep a notice for each committed lifecycle event, including rapid deletion and recovery.""" + if not user.primary_email or not user.primary_email_verified: + return + + content = render_account_email(user.display_name, user.deletion_due_at) + now = datetime.now(UTC) + expires_at = now + NOTICE_LIFETIME + + if user.deletion_due_at is not None: + expires_at = min(expires_at, user.deletion_due_at) + + session.add( + AccountNotification( + user_id=user.user_id, + recipient=user.primary_email, + subject=content.subject, + text_body=content.text, + html_body=content.html, + created_at=now, + next_attempt_at=now, + expires_at=expires_at, + ) + ) + + +async def deliver_pending_notifications(session: AsyncSession) -> int: + """Lease before SMTP so delivery never holds account locks or blocks recovery. + + SMTP is at-least-once: a crash after acceptance but before deletion can repeat + a notice. Later notices for the same account wait for earlier deliveries or + their expiry. Pending messages expire after seven days and contain no auth tokens. + """ + await session.execute(delete(AccountNotification).where(AccountNotification.expires_at <= datetime.now(UTC))) + await session.commit() + + if not email.is_email_delivery_configured(): + return 0 + + delivered = 0 + earlier = aliased(AccountNotification) + + has_earlier_notice = exists().where( + earlier.user_id == AccountNotification.user_id, + tuple_(earlier.created_at, earlier.notification_id) + < tuple_(AccountNotification.created_at, AccountNotification.notification_id), + ) + + for _ in range(100): + now = datetime.now(UTC) + + notice = await session.scalar( + select(AccountNotification) + .where( + AccountNotification.next_attempt_at <= now, + AccountNotification.expires_at > now, + ~has_earlier_notice, + ) + .order_by(AccountNotification.created_at, AccountNotification.notification_id) + .with_for_update(skip_locked=True) + .limit(1) + ) + + if notice is None: + await session.rollback() + break + + notice.attempts += 1 + notice.next_attempt_at = now + DELIVERY_LEASE + await session.commit() + + try: + await asyncio.to_thread( + email.send_email, + notice.recipient, + notice.subject, + notice.text_body, + html_body=notice.html_body, + ) + except Exception: + retry_at = datetime.now(UTC) + timedelta(seconds=min(60 * 2 ** min(notice.attempts - 1, 6), 3600)) + + await session.execute( + update(AccountNotification) + .where(AccountNotification.notification_id == notice.notification_id) + .values(next_attempt_at=retry_at) + ) + + await session.commit() + logger.warning("Account notification delivery failed and will be retried") + continue + + await session.execute( + delete(AccountNotification).where(AccountNotification.notification_id == notice.notification_id) + ) + + await session.commit() + delivered += 1 + + return delivered diff --git a/papyrus/services/acquisition_monitor.py b/papyrus/services/acquisition_monitor.py index b265efa..4a333c9 100644 --- a/papyrus/services/acquisition_monitor.py +++ b/papyrus/services/acquisition_monitor.py @@ -15,7 +15,7 @@ from papyrus.config import get_settings from papyrus.core.exceptions import AppError, ConflictError, ForbiddenError, NotFoundError, ValidationError -from papyrus.models import AcquisitionEndpoint, AcquisitionJob, MediaAsset +from papyrus.models import AcquisitionEndpoint, AcquisitionJob, MediaAsset, User from papyrus.services import media as media_service from papyrus.services.acquisition import QbittorrentClient, QbittorrentFile, QbittorrentTorrent @@ -62,7 +62,9 @@ async def claim_due_jobs( AcquisitionEndpoint, AcquisitionEndpoint.endpoint_id == AcquisitionJob.endpoint_id, ) + .join(User, User.user_id == AcquisitionJob.owner_user_id) .where( + User.disabled_at.is_(None), AcquisitionEndpoint.kind == "qbittorrent", AcquisitionJob.status.in_(ACTIVE_JOB_STATUSES), AcquisitionJob.rule_id.is_(None), diff --git a/papyrus/services/auth/_core.py b/papyrus/services/auth/_core.py index ffcf86a..cedace9 100644 --- a/papyrus/services/auth/_core.py +++ b/papyrus/services/auth/_core.py @@ -120,6 +120,7 @@ async def _create_exchange_code( email_verified_at_provider: datetime | None = None, display_name: str | None = None, avatar_url: str | None = None, + code_challenge: str | None = None, ) -> str: plain_code = generate_opaque_token() @@ -135,6 +136,7 @@ async def _create_exchange_code( display_name=display_name, avatar_url=avatar_url, redirect_uri=redirect_uri, + code_challenge=code_challenge, expires_at=_now() + timedelta(minutes=get_settings().auth_exchange_code_expire_minutes), ) ) diff --git a/papyrus/services/auth/google.py b/papyrus/services/auth/google.py index 5c6b0f2..2dda05a 100644 --- a/papyrus/services/auth/google.py +++ b/papyrus/services/auth/google.py @@ -4,7 +4,7 @@ import json from urllib.error import HTTPError, URLError -from urllib.parse import urlencode, urlsplit +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.request import Request, urlopen from uuid import UUID @@ -109,6 +109,7 @@ def exchange_code_for_identity(self, code: str, callback_uri: str) -> GoogleIden email_verified=bool(claims.get("email_verified", False)), display_name=str(claims["name"]) if isinstance(claims.get("name"), str) else None, avatar_url=str(claims["picture"]) if isinstance(claims.get("picture"), str) else None, + nonce=claims.get("nonce"), ) @@ -176,6 +177,29 @@ def build_google_login_authorization_url(redirect_uri: str, callback_uri: str) - return google_oauth_client.build_authorization_url(callback_uri, state) +def build_google_account_authorization_url( + redirect_uri: str, + callback_uri: str, + challenge: str, + expected_user_id: UUID | None = None, +) -> str: + _validate_oauth_redirect_uri(redirect_uri) + + state = create_state_token( + { + "redirect_uri": redirect_uri, + "mode": "account_deletion", + "challenge": challenge, + "user_id": str(expected_user_id) if expected_user_id is not None else None, + } + ) + + url = urlsplit(google_oauth_client.build_authorization_url(callback_uri, state)) + query = dict(parse_qsl(url.query)) + query.update({"prompt": "select_account consent", "nonce": challenge}) + return urlunsplit((url.scheme, url.netloc, url.path, urlencode(query), url.fragment)) + + async def build_google_link_authorization_url(user_id: UUID, redirect_uri: str, callback_uri: str) -> str: _validate_oauth_redirect_uri(redirect_uri) @@ -198,6 +222,9 @@ async def _resolve_google_login_user(session: AsyncSession, identity: GoogleIden existing_identity = identity_result.scalar_one_or_none() if existing_identity is not None: + if existing_identity.user.deletion_due_at is not None: + return existing_identity.user, None + if existing_identity.user.disabled_at is not None: return None, "account_disabled" @@ -262,6 +289,39 @@ async def handle_google_callback( identity = google_oauth_client.exchange_code_for_identity(code, callback_uri) mode = state.get("mode") + if mode == "account_deletion": + challenge = state.get("challenge") + + if not isinstance(challenge, str) or identity.nonce != challenge: + raise ValidationError("Google authorization does not match this request") + + user_id = await session.scalar( + select(UserIdentity.user_id).where( + UserIdentity.provider == GOOGLE_PROVIDER, + UserIdentity.provider_subject == identity.subject, + ) + ) + + expected_user_id = state.get("user_id") + + if user_id is None or (expected_user_id is not None and str(user_id) != expected_user_id): + return _build_redirect_uri(redirect_uri, {"error": "Use the Google account linked to your Papyrus account"}) + + from papyrus.services.account_deletion import GOOGLE_HANDOFF_PURPOSE, _lock_user + + await _lock_user(session, user_id) + + exchange_code = await _create_exchange_code( + session, + purpose=GOOGLE_HANDOFF_PURPOSE, + redirect_uri=redirect_uri, + user_id=user_id, + code_challenge=challenge, + ) + + await session.commit() + return _build_redirect_uri(redirect_uri, {"code": exchange_code}) + if mode == "login": user, callback_error = await _resolve_google_login_user(session, identity) diff --git a/papyrus/services/auth/sessions.py b/papyrus/services/auth/sessions.py index e2e6e5d..afbd1d7 100644 --- a/papyrus/services/auth/sessions.py +++ b/papyrus/services/auth/sessions.py @@ -88,12 +88,17 @@ async def login_user( if user is None or user.password_credential is None: raise UnauthorizedError("Invalid email or password") - if user.disabled_at is not None: - raise ForbiddenError("User account is disabled") - if not verify_password(request.password, user.password_credential.password_hash): raise UnauthorizedError("Invalid email or password") + if user.deletion_due_at is not None: + from papyrus.services.account_deletion import require_account_recovery + + await require_account_recovery(session, user.user_id) + + if user.disabled_at is not None: + raise ForbiddenError("User account is disabled") + auth_result = await _create_session_for_user( session, user, @@ -173,6 +178,13 @@ async def exchange_login_code( if exchange_code.user_id is None: raise UnauthorizedError("Exchange code is missing a user") + user = await session.get(User, exchange_code.user_id) + + if user is not None and user.deletion_due_at is not None: + from papyrus.services.account_deletion import require_account_recovery + + await require_account_recovery(session, user.user_id) + user = await _get_active_user(session, exchange_code.user_id) auth_result = await _create_session_for_user( diff --git a/papyrus/services/auth/types.py b/papyrus/services/auth/types.py index 3594700..37594af 100644 --- a/papyrus/services/auth/types.py +++ b/papyrus/services/auth/types.py @@ -30,3 +30,4 @@ class GoogleIdentity: email_verified: bool display_name: str | None avatar_url: str | None + nonce: str | None = None diff --git a/papyrus/services/email.py b/papyrus/services/email.py index ee4adbd..d9b28c6 100644 --- a/papyrus/services/email.py +++ b/papyrus/services/email.py @@ -16,8 +16,8 @@ def is_email_delivery_configured() -> bool: return bool(settings.email_delivery_enabled and settings.smtp_host and settings.smtp_from_email) -def send_email(recipient: str, subject: str, body: str) -> None: - """Send a plain-text email through the configured SMTP server.""" +def send_email(recipient: str, subject: str, body: str, *, html_body: str | None = None) -> None: + """Send plain text with an optional HTML alternative through the configured SMTP server.""" settings = get_settings() if not is_email_delivery_configured(): @@ -37,6 +37,10 @@ def send_email(recipient: str, subject: str, body: str) -> None: message["To"] = recipient message["Subject"] = subject message.set_content(body) + + if html_body is not None: + message.add_alternative(html_body, subtype="html") + smtp_host = settings.smtp_host if smtp_host is None: diff --git a/papyrus/services/media.py b/papyrus/services/media.py index e1ed681..0554ce5 100644 --- a/papyrus/services/media.py +++ b/papyrus/services/media.py @@ -270,7 +270,12 @@ async def _used_bytes(session: AsyncSession, user_id: UUID) -> int: async def _lock_user_uploads(session: AsyncSession, user_id: UUID) -> None: """Serialize quota and replacement decisions for one user's uploads.""" - await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update()) + user = await session.scalar( + select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True) + ) + + if user is None or user.disabled_at is not None: + raise ForbiddenError("User account is unavailable") async def _write_upload_to_temp_file( diff --git a/papyrus/services/sync.py b/papyrus/services/sync.py index b7d4bd5..9681a46 100644 --- a/papyrus/services/sync.py +++ b/papyrus/services/sync.py @@ -6,6 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from papyrus.core.exceptions import ForbiddenError from papyrus.models import User from papyrus.schemas.sync import PowerSyncCrudMutation from papyrus.services import media as media_service @@ -14,7 +15,12 @@ async def lock_owner_mutations(session: AsyncSession, user_id: UUID) -> None: """Hold the owner's mutation lock until the surrounding transaction ends.""" - await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update()) + user = await session.scalar( + select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True) + ) + + if user is None or user.disabled_at is not None: + raise ForbiddenError("User account is unavailable") async def apply_powersync_upload_batch( diff --git a/papyrus/services/users.py b/papyrus/services/users.py index 17b8f4d..0e4be63 100644 --- a/papyrus/services/users.py +++ b/papyrus/services/users.py @@ -10,8 +10,9 @@ from papyrus.core.exceptions import ForbiddenError, NotFoundError, UnauthorizedError, ValidationError from papyrus.core.security import hash_password, verify_password -from papyrus.models import AuthSession, PasswordCredential, User +from papyrus.models import AuthSession, PasswordCredential, User, UserIdentity from papyrus.schemas.user import UpdateUserRequest +from papyrus.schemas.user import User as UserResponse def _now() -> datetime: @@ -57,18 +58,6 @@ async def update_user_profile(session: AsyncSession, user_id: UUID, request: Upd return user -async def delete_user_account(session: AsyncSession, user_id: UUID, password: str) -> None: - user = await _get_active_user(session, user_id) - credential = await session.get(PasswordCredential, user_id) - - if credential is not None and not verify_password(password, credential.password_hash): - raise UnauthorizedError("Current password is incorrect") - - user.disabled_at = _now() - await _revoke_user_sessions(session, user_id) - await session.commit() - - async def change_user_password(session: AsyncSession, user_id: UUID, current_password: str, new_password: str) -> None: user = await _get_active_user(session, user_id) credential = await session.get(PasswordCredential, user.user_id) @@ -83,3 +72,10 @@ async def change_user_password(session: AsyncSession, user_id: UUID, current_pas credential.password_changed_at = _now() await _revoke_user_sessions(session, user_id) await session.commit() + + +async def user_response(session: AsyncSession, user: User) -> UserResponse: + providers = await session.scalars(select(UserIdentity.provider).where(UserIdentity.user_id == user.user_id)) + response = UserResponse.model_validate(user) + response.connected_providers = sorted(set(providers)) + return response diff --git a/papyrus/templates/account_notification.html b/papyrus/templates/account_notification.html new file mode 100644 index 0000000..5e36e65 --- /dev/null +++ b/papyrus/templates/account_notification.html @@ -0,0 +1,31 @@ + + + + + +$subject + + +
$preheader
+ + +
+ + + + +
Papyrus
+

ACCOUNT UPDATE

+

$heading

+

$greeting

+

$summary

+$deadline +

$guidance

+$action +

$security_note

+
+This is an automatic account notification from Papyrus.
You received it because this email address is linked to your account. +
+
+ + diff --git a/papyrus/web/account_deletion.html b/papyrus/web/account_deletion.html new file mode 100644 index 0000000..2875ebd --- /dev/null +++ b/papyrus/web/account_deletion.html @@ -0,0 +1,153 @@ + + + + +Delete your Papyrus account + + + +
+
Papyrus
+

Delete your account

+

Your account and synced library will be permanently deleted after 30 days. Sign in to continue.

+ +
+ + + +

or

+ +
+ +

+
+What happens to my data? +

Your profile, synced books, notes, bookmarks and reading history will be permanently deleted after 30 days. Sign in before then and choose Keep account to cancel deletion.

+

Files on your devices or in external storage won’t be removed.

+

+
+
+ + + diff --git a/tests/api/routes/test_account_deletion.py b/tests/api/routes/test_account_deletion.py new file mode 100644 index 0000000..a6c6924 --- /dev/null +++ b/tests/api/routes/test_account_deletion.py @@ -0,0 +1,335 @@ +"""Deletion capabilities cannot be used as ordinary login credentials.""" + +from datetime import UTC, datetime, timedelta +from urllib.parse import parse_qs, urlsplit +from uuid import UUID, uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.config import get_settings +from papyrus.core.security import hash_opaque_token +from papyrus.models import AuthSession, User, UserIdentity +from papyrus.services import account_deletion, auth + + +async def authorize(client: AsyncClient, auth_user: dict[str, str]) -> dict: + response = await client.post( + "/v1/account-deletion/password", + json={"email": "user@example.com", "password": auth_user["password"]}, + ) + + assert response.status_code == 200, response.text + return response.json() + + +async def test_password_schedule_recovery_requires_explicit_cancellation( + client: AsyncClient, + auth_user: dict[str, str], + auth_headers: dict[str, str], + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = datetime(2026, 10, 23, 12, tzinfo=UTC) + monkeypatch.setattr(account_deletion, "_now", lambda: now) + preview = await authorize(client, auth_user) + assert preview["deletion_pending"] is False + assert datetime.fromisoformat(preview["deletion_due_at"]) == now + timedelta(days=30) + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 200 + second_preview = await authorize(client, auth_user) + + scheduled = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]} + ) + + assert scheduled.status_code == 200 + assert scheduled.json()["deletion_due_at"] == preview["deletion_due_at"] + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + replay = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": second_preview["authorization_token"]} + ) + + assert replay.status_code == 401 + login = await client.post("/v1/auth/login", json={"email": "user@example.com", "password": auth_user["password"]}) + assert login.status_code == 403 + assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING" + recovery = login.json()["error"]["details"]["authorization"] + assert recovery["deletion_pending"] is True + assert recovery["deletion_due_at"] == preview["deletion_due_at"] + user = await db_session.get(User, UUID(auth_user["user_id"])) + assert user is not None + await db_session.refresh(user) + assert user.disabled_at is not None + + restricted = await client.get( + "/v1/users/me", headers={"Authorization": f"Bearer {recovery['authorization_token']}"} + ) + assert restricted.status_code == 401 + + cancelled = await client.post( + "/v1/account-deletion/cancel", json={"authorization_token": recovery["authorization_token"]} + ) + + assert cancelled.status_code == 200 + restored = cancelled.json() + assert restored["user"]["user_id"] == auth_user["user_id"] + assert ( + await client.get("/v1/users/me", headers={"Authorization": f"Bearer {restored['access_token']}"}) + ).status_code == 200 + assert (await client.post("/v1/auth/refresh", json={"refresh_token": restored["refresh_token"]})).status_code == 200 + assert ( + await client.post("/v1/account-deletion/cancel", json={"authorization_token": recovery["authorization_token"]}) + ).status_code == 401 + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + +async def test_signed_in_deletion_uses_current_session_without_reauthentication( + client: AsyncClient, auth_user: dict[str, str], auth_headers: dict[str, str], db_session: AsyncSession +) -> None: + other = User(display_name="Other account") + db_session.add(other) + await db_session.commit() + assert (await client.post("/v1/account-deletion/current")).status_code == 401 + before = datetime.now(UTC) + scheduled = await client.post("/v1/account-deletion/current", headers=auth_headers) + assert scheduled.status_code == 200 + due = datetime.fromisoformat(scheduled.json()["deletion_due_at"]) + assert before + timedelta(days=30) <= due <= datetime.now(UTC) + timedelta(days=30) + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + assert (await client.post("/v1/account-deletion/current", headers=auth_headers)).status_code == 401 + await db_session.refresh(other) + assert other.disabled_at is None + assert other.deletion_due_at is None + login = await client.post("/v1/auth/login", json={"email": "user@example.com", "password": auth_user["password"]}) + assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING" + assert ( + await db_session.scalar(select(func.count()).select_from(AuthSession).where(AuthSession.revoked_at.is_(None))) + == 0 + ) + + +async def test_password_reauthentication_and_expected_account_are_required( + client: AsyncClient, auth_user: dict[str, str] +) -> None: + response = await client.post( + "/v1/account-deletion/password", json={"email": "user@example.com", "password": "incorrect"} + ) + + assert response.status_code == 401 + + response = await client.post( + "/v1/account-deletion/password", + json={"email": "user@example.com", "password": auth_user["password"], "expected_user_id": str(uuid4())}, + ) + + assert response.status_code == 403 + assert ( + await client.post("/v1/account-deletion/schedule", json={"authorization_token": "invalid"}) + ).status_code == 401 + + +async def test_expired_preview_and_recovery_deadline_are_enforced( + client: AsyncClient, auth_user: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + now = datetime(2026, 10, 23, 12, tzinfo=UTC) + monkeypatch.setattr(account_deletion, "_now", lambda: now) + preview = await authorize(client, auth_user) + now += timedelta(minutes=5) + + response = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]} + ) + + assert response.status_code == 401 + preview = await authorize(client, auth_user) + + response = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]} + ) + + assert response.status_code == 200 + now += timedelta(days=30) + + response = await client.post( + "/v1/account-deletion/password", json={"email": "user@example.com", "password": auth_user["password"]} + ) + + assert response.status_code == 403 + + +async def test_disabled_accounts_cannot_be_recovered_without_scheduled_deletion( + client: AsyncClient, auth_user: dict[str, str], db_session: AsyncSession +) -> None: + user = await db_session.get(User, UUID(auth_user["user_id"])) + assert user is not None + user.disabled_at = datetime.now(UTC) + await db_session.commit() + + response = await client.post( + "/v1/account-deletion/password", json={"email": "user@example.com", "password": auth_user["password"]} + ) + + assert response.status_code == 403 + + +async def test_google_reauthentication_is_bound_to_identity_and_browser( + client: AsyncClient, + auth_user: dict[str, str], + auth_headers: dict[str, str], + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = get_settings() + monkeypatch.setattr(settings, "google_oauth_client_id", "test-client") + monkeypatch.setattr(settings, "google_oauth_client_secret", "test-secret") + verifier = "v" * 43 + challenge = hash_opaque_token(verifier) + + db_session.add( + UserIdentity(user_id=UUID(auth_user["user_id"]), provider="google", provider_subject="linked-subject") + ) + await db_session.commit() + + monkeypatch.setattr( + auth.google_oauth_client, + "exchange_code_for_identity", + lambda *_: auth.GoogleIdentity( + subject="linked-subject", + email="different-provider-email@example.com", + email_verified=True, + display_name=None, + avatar_url=None, + nonce=challenge, + ), + ) + + start = await client.get( + "/v1/account-deletion/google/start", + params={ + "redirect_uri": "papyrus://auth/callback", + "challenge": challenge, + "expected_user_id": auth_user["user_id"], + }, + ) + + parameters = parse_qs(urlsplit(start.headers["location"]).query) + assert parameters["prompt"] == ["select_account consent"] + assert parameters["nonce"] == [challenge] + + callback = await client.get( + "/v1/auth/oauth/google/callback", params={"code": "provider-code", "state": parameters["state"][0]} + ) + + code = parse_qs(urlsplit(callback.headers["location"]).query)["code"][0] + assert (await client.post("/v1/auth/exchange-code", json={"code": code})).status_code == 401 + + wrong_browser = await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": "x" * 43}) + assert wrong_browser.status_code == 401 + response = await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": verifier}) + assert response.status_code == 200 + assert response.json()["user_id"] == auth_user["user_id"] + assert ( + await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": verifier}) + ).status_code == 401 + assert (await client.get("/v1/users/me", headers=auth_headers)).json()["connected_providers"] == ["google"] + assert await db_session.scalar(select(func.count()).select_from(AuthSession)) == 1 + authorization = response.json()["authorization_token"] + response = await client.post("/v1/account-deletion/schedule", json={"authorization_token": authorization}) + assert response.status_code == 200 + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + login_start = await client.get("/v1/auth/oauth/google/start", params={"redirect_uri": "papyrus://auth/callback"}) + + login_state = parse_qs(urlsplit(login_start.headers["location"]).query)["state"][0] + + login_callback = await client.get( + "/v1/auth/oauth/google/callback", params={"code": "fresh-provider-code", "state": login_state} + ) + + login_code = parse_qs(urlsplit(login_callback.headers["location"]).query)["code"][0] + login = await client.post("/v1/auth/exchange-code", json={"code": login_code}) + assert login.status_code == 403 + assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING" + assert login.json()["error"]["details"]["authorization"]["deletion_pending"] is True + assert (await client.post("/v1/auth/exchange-code", json={"code": login_code})).status_code == 401 + assert await db_session.scalar(select(func.count()).select_from(AuthSession)) == 1 + + recovery_callback = await client.get( + "/v1/auth/oauth/google/callback", params={"code": "fresh-provider-code", "state": parameters["state"][0]} + ) + + recovery_code = parse_qs(urlsplit(recovery_callback.headers["location"]).query)["code"][0] + recovery = await client.post("/v1/account-deletion/exchange", json={"code": recovery_code, "verifier": verifier}) + assert recovery.status_code == 200 + assert recovery.json()["deletion_pending"] is True + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + cancelled = await client.post( + "/v1/account-deletion/cancel", json={"authorization_token": recovery.json()["authorization_token"]} + ) + + assert cancelled.status_code == 200 + restored_headers = {"Authorization": f"Bearer {cancelled.json()['access_token']}"} + assert (await client.get("/v1/users/me", headers=restored_headers)).status_code == 200 + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + +@pytest.mark.parametrize("mismatch", ["identity", "nonce", "account"]) +async def test_google_cannot_authorize_a_different_account_or_flow( + client: AsyncClient, + auth_user: dict[str, str], + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, + mismatch: str, +) -> None: + monkeypatch.setattr(get_settings(), "google_oauth_client_id", "test-client") + challenge = hash_opaque_token("v" * 43) + db_session.add(UserIdentity(user_id=UUID(auth_user["user_id"]), provider="google", provider_subject="linked")) + await db_session.commit() + + monkeypatch.setattr( + auth.google_oauth_client, + "exchange_code_for_identity", + lambda *_: auth.GoogleIdentity( + subject="unlinked" if mismatch == "identity" else "linked", + email="user@example.com", + email_verified=True, + display_name=None, + avatar_url=None, + nonce="wrong" if mismatch == "nonce" else challenge, + ), + ) + + start = await client.get( + "/v1/account-deletion/google/start", + params={ + "redirect_uri": "papyrus://auth/callback", + "challenge": challenge, + "expected_user_id": str(uuid4()) if mismatch == "account" else auth_user["user_id"], + }, + ) + + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + callback = await client.get("/v1/auth/oauth/google/callback", params={"code": "provider-code", "state": state}) + + if mismatch == "nonce": + assert callback.status_code == 400 + else: + assert callback.status_code == 302 + assert "code" not in parse_qs(urlsplit(callback.headers["location"]).query) + + assert await db_session.scalar(select(func.count()).select_from(User)) == 1 + + +async def test_public_resource_and_policy(client: AsyncClient) -> None: + response = await client.get("/v1/account-deletion") + assert response.status_code == 200 + assert "Continue with Google" in response.text + assert "Keep account" in response.text + assert response.headers["cache-control"] == "no-store" + assert "frame-ancestors 'none'" in response.headers["content-security-policy"] + policy = await client.get("/v1/account-deletion/policy") + assert "not currently implemented" in policy.json()["retention_notice"] diff --git a/tests/api/routes/test_auth.py b/tests/api/routes/test_auth.py index 4d37d0b..285b56d 100644 --- a/tests/api/routes/test_auth.py +++ b/tests/api/routes/test_auth.py @@ -368,6 +368,7 @@ async def test_google_oauth_flow_creates_user_and_exchanges_code( assert exchange_response.status_code == 200 data = exchange_response.json() assert data["user"]["email"] == "google_user@example.com" + assert data["user"]["connected_providers"] == ["google"] assert data["user"]["display_name"] == "Google User" @@ -514,6 +515,7 @@ async def test_google_link_flow_links_identity_to_existing_user( ) assert complete_response.status_code == 200 + assert complete_response.json()["connected_providers"] == ["google"] identity_result = await db_session.execute( select(UserIdentity).where(UserIdentity.provider_subject == "google-sub-link") diff --git a/tests/api/routes/test_users.py b/tests/api/routes/test_users.py index f705251..f919288 100644 --- a/tests/api/routes/test_users.py +++ b/tests/api/routes/test_users.py @@ -32,20 +32,6 @@ async def test_update_current_user(client: AsyncClient, auth_headers: dict[str, assert data["display_name"] == "Updated Name" -async def test_delete_current_user(client: AsyncClient, auth_headers: dict[str, str], auth_user: dict[str, str]): - """Test deleting current user account.""" - response = await client.request( - "DELETE", - "/v1/users/me", - headers=auth_headers, - json={"password": auth_user["password"]}, - ) - - assert response.status_code == 204 - protected_response = await client.get("/v1/users/me", headers=auth_headers) - assert protected_response.status_code == 401 - - async def test_get_user_preferences(client: AsyncClient, auth_headers: dict[str, str]): """Test getting user preferences.""" response = await client.get("/v1/users/me/preferences", headers=auth_headers) diff --git a/tests/services/test_account_deletion.py b/tests/services/test_account_deletion.py new file mode 100644 index 0000000..60e24ae --- /dev/null +++ b/tests/services/test_account_deletion.py @@ -0,0 +1,106 @@ +"""Account erasure preserves other owners and retries physical cleanup after commit.""" + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.config import get_settings +from papyrus.core.exceptions import ForbiddenError +from papyrus.models import ( + AccountMediaCleanup, + AuthSession, + PasswordCredential, + SyncBook, + SyncNote, + SyncReadingActivity, + User, +) +from papyrus.services import account_deletion, media, sync + + +async def test_purge_cascades_owned_data_and_retries_file_cleanup( + db_session: AsyncSession, + auth_user: dict[str, str], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = get_settings() + monkeypatch.setattr(settings, "media_storage_root", str(tmp_path / "media")) + monkeypatch.setattr(settings, "acquisition_import_root", tmp_path / "imports") + user_id = UUID(auth_user["user_id"]) + other_id = uuid4() + other = User(user_id=other_id, display_name="Other", primary_email="other@example.com") + user = await db_session.get(User, user_id) + assert user is not None + user.disabled_at = datetime.now(UTC) + user.deletion_due_at = datetime.now(UTC) - timedelta(seconds=1) + db_session.add(other) + await db_session.flush() + book = SyncBook(owner_user_id=user_id, title="Deleted book") + other_book = SyncBook(owner_user_id=other_id, title="Kept book") + db_session.add_all([book, other_book]) + await db_session.flush() + db_session.add(SyncNote(owner_user_id=user_id, book_id=book.book_id, title="Note", content="Private")) + db_session.add(SyncReadingActivity(owner_user_id=user_id, payload={"book_id": str(book.book_id)})) + await db_session.commit() + other_book_id = other_book.book_id + + for root in (tmp_path / "media", tmp_path / "imports"): + for owner in (user_id, other_id): + directory = root / str(owner) + directory.mkdir(parents=True) + (directory / "book.epub").write_bytes(b"private") + + assert await account_deletion.purge_due_accounts(db_session) == 1 + assert await db_session.scalar(select(User).where(User.user_id == user_id)) is None + assert await db_session.scalar(select(AuthSession).where(AuthSession.user_id == user_id)) is None + assert await db_session.scalar(select(PasswordCredential).where(PasswordCredential.user_id == user_id)) is None + assert await db_session.scalar(select(SyncNote).where(SyncNote.owner_user_id == user_id)) is None + assert ( + await db_session.scalar(select(SyncReadingActivity).where(SyncReadingActivity.owner_user_id == user_id)) is None + ) + assert await db_session.get(AccountMediaCleanup, user_id) is not None + assert (tmp_path / "media" / str(user_id) / "book.epub").exists() + + original_cleanup = account_deletion._remove_owner_files + + def fail_cleanup(_: UUID) -> None: + raise OSError("Storage is temporarily unavailable") + + monkeypatch.setattr(account_deletion, "_remove_owner_files", fail_cleanup) + assert await account_deletion.cleanup_deleted_media(db_session) == 0 + assert await db_session.get(AccountMediaCleanup, user_id) is not None + monkeypatch.setattr(account_deletion, "_remove_owner_files", original_cleanup) + assert await account_deletion.cleanup_deleted_media(db_session) == 1 + assert await db_session.get(AccountMediaCleanup, user_id) is None + assert await db_session.get(User, other_id) is not None + assert await db_session.get(SyncBook, other_book_id) is not None + + for root in (tmp_path / "media", tmp_path / "imports"): + assert not (root / str(user_id)).exists() + assert (root / str(other_id) / "book.epub").read_bytes() == b"private" + + assert await account_deletion.purge_due_accounts(db_session) == 0 + assert await account_deletion.cleanup_deleted_media(db_session) == 0 + + +async def test_pending_account_blocks_queued_sync_and_media_and_is_not_purged_early( + db_session: AsyncSession, auth_user: dict[str, str] +) -> None: + user_id = UUID(auth_user["user_id"]) + user = await db_session.get(User, user_id) + assert user is not None + user.disabled_at = datetime.now(UTC) + user.deletion_due_at = datetime.now(UTC) + timedelta(days=1) + await db_session.commit() + assert await account_deletion.purge_due_accounts(db_session) == 0 + + with pytest.raises(ForbiddenError): + await sync.apply_powersync_upload_batch(db_session, user_id, []) + + with pytest.raises(ForbiddenError): + await media._lock_user_uploads(db_session, user_id) diff --git a/tests/services/test_account_notifications.py b/tests/services/test_account_notifications.py new file mode 100644 index 0000000..87f5ac5 --- /dev/null +++ b/tests/services/test_account_notifications.py @@ -0,0 +1,231 @@ +"""Account mail is safe to render, transactional, retryable and never restores access by link.""" + +import asyncio +import threading +from datetime import UTC, datetime, timedelta +from email.message import EmailMessage +from uuid import UUID + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.config import get_settings +from papyrus.core.exceptions import ServiceUnavailableError +from papyrus.models import AccountNotification, User +from papyrus.services import account_notifications, email + + +@pytest.fixture +def smtp_messages(monkeypatch: pytest.MonkeyPatch) -> list[EmailMessage]: + settings = get_settings() + monkeypatch.setattr(settings, "email_delivery_enabled", True) + monkeypatch.setattr(settings, "smtp_host", "smtp.example.test") + monkeypatch.setattr(settings, "smtp_from_email", "noreply@example.test") + monkeypatch.setattr(settings, "smtp_username", None) + monkeypatch.setattr(settings, "smtp_password", None) + monkeypatch.setattr(settings, "smtp_use_ssl", False) + monkeypatch.setattr(settings, "smtp_use_tls", False) + monkeypatch.setattr(settings, "app_public_base_url", "https://reader.example.test") + messages: list[EmailMessage] = [] + + class SMTP: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def send_message(self, message): + messages.append(message) + + monkeypatch.setattr(email.smtplib, "SMTP", SMTP) + return messages + + +def test_notifications_have_html_and_plain_text_with_escaped_content(smtp_messages): + deadline = datetime(2026, 11, 22, 14, 4, tzinfo=UTC) + content = account_notifications.render_account_email('', deadline) + email.send_email("reader@example.test", content.subject, content.text, html_body=content.html) + message = smtp_messages[0] + assert message.get_content_type() == "multipart/alternative" + plain = message.get_body(preferencelist=("plain",)).get_content() + html = message.get_body(preferencelist=("html",)).get_content() + assert "November 22, 2026 at 14:04 UTC" in plain + assert "November 22, 2026 at 14:04 UTC" in html + assert "', }, ) @@ -722,13 +723,26 @@ async def test_forgot_password_sends_email_when_configured( assert response.status_code == 200 assert response.json()["message"] == "If the email is registered, a reset link has been sent" assert len(configured_email_delivery) == 1 - recipient, subject, body = configured_email_delivery[0] + recipient, subject, body, html = configured_email_delivery[0] assert recipient == "test@example.com" assert subject == "Reset your Papyrus password" assert "http://papyrus.localhost:3000/reset-password?token=" in body assert "This link expires in 60 minutes." in body assert "Reset token:" not in body assert "/v1/auth/reset-password" not in body + assert html is not None + assert "Reset your password" in html + assert "This link expires in 60 minutes and can only be used once." in html + assert "If you didn't request this" in html + assert "