Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ 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"

# Optional: fine-grained GitHub token with Issues write access to PapyrusReader/client.
# Keep this secret on the server. Empty disables public support submissions.
# Attachment uploads also require the token owner to have repository write access.
Expand Down
23 changes: 23 additions & 0 deletions alembic/versions/a8b9c0d1e234_order_account_notifications.py
Original file line number Diff line number Diff line change
@@ -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")
34 changes: 34 additions & 0 deletions alembic/versions/e6f7a8b9c012_add_account_deletion.py
Original file line number Diff line number Diff line change
@@ -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")
36 changes: 36 additions & 0 deletions alembic/versions/f7a8b9c0d123_add_account_notifications.py
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 3 additions & 0 deletions papyrus/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fastapi import APIRouter, FastAPI

from papyrus.api.routes import (
account_deletion,
acquisition,
annotations,
auth,
Expand All @@ -29,6 +30,8 @@

api_router = APIRouter()

api_router.include_router(account_deletion.router, prefix="/account-deletion", tags=["Account deletion"])

api_router.include_router(support.router, prefix="/support", tags=["Support"])

api_router.include_router(opds.router, prefix="/opds", tags=["OPDS"])
Expand Down
121 changes: 121 additions & 0 deletions papyrus/api/routes/account_deletion.py
Original file line number Diff line number Diff line change
@@ -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}
15 changes: 8 additions & 7 deletions papyrus/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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),
)


Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 4 additions & 19 deletions papyrus/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions papyrus/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading