Files
einkaufsapp/backend/app/routers/sharing.py

428 lines
14 KiB
Python

"""Teilen von Listen: Einladungen, Widerruf, Eigentümerwechsel.
Ablauf einer Einladung:
1. Der Eigentümer gibt eine E-Mail-Adresse ein.
2. Es entsteht ein Datensatz mit Zufallstoken; versendet wird ein Link
auf /invite?token=…
3. Der Empfänger meldet sich an oder registriert sich und nimmt an.
4. Beim Annehmen entsteht die Mitgliedschaft.
Bewusst unabhängig davon, ob unter der Adresse schon ein Konto besteht.
Damit verrät das Einladen nicht, wer registriert ist, und neue wie
bestehende Nutzer durchlaufen denselben Weg.
"""
from datetime import timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from sqlalchemy import select
from app.deps import DbSession, VerifiedUser, client_ip
from app.mail import send_invitation
from app.models import ListInvite, ListMember, ShoppingList, User
from app.permissions import OwnedList, ReadableList
from app.schemas import MessageOut
from app.schemas_shopping import (
InviteCreateIn,
InviteOut,
InvitePreviewOut,
MemberOut,
TransferOwnershipIn,
)
from app.security import (
bucket_key,
check_rate_limit,
hash_token,
new_token,
normalize_email,
utcnow,
)
router = APIRouter(tags=["sharing"])
# Vorgabewert; maßgeblich ist die Einstellung in der Verwaltung.
INVITE_DAYS = 14
MAX_OPEN_INVITES = 50
def _invite_days(db) -> int:
from app.runtime_settings import get_duration
return get_duration(db, "invite_days")
def _status(invite: ListInvite) -> str:
if invite.revoked_at:
return "revoked"
if invite.accepted_at:
return "accepted"
if invite.expires_at <= utcnow():
return "expired"
return "pending"
def _invite_out(invite: ListInvite) -> InviteOut:
inviter = invite.inviter
return InviteOut(
id=invite.id,
email=invite.email,
role=invite.role,
status=_status(invite),
invited_by_name=(
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
),
created_at=invite.created_at,
last_sent_at=invite.last_sent_at,
send_count=invite.send_count,
expires_at=invite.expires_at,
)
def _guard_invite_rate(db: DbSession, request: Request, list_id: str) -> None:
"""Zwei Bremsen: pro Liste und pro absendender IP.
Ohne die zweite ließe sich die Anwendung als Versandhilfe für
unerwünschte Mails missbrauchen - jede Einladung erzeugt schließlich
eine Nachricht an eine frei wählbare Adresse.
"""
ok_list = check_rate_limit(
db, bucket_key(db, "invite-list", list_id), limit=20, window_minutes=60
)
ok_ip = check_rate_limit(
db, bucket_key(db, "invite-ip", client_ip(request)),
limit=30, window_minutes=60,
)
if not (ok_list and ok_ip):
db.commit()
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"Zu viele Einladungen in kurzer Zeit. Bitte später erneut versuchen.",
)
# ==========================================================================
# Einladungen verwalten (nur Eigentümer)
# ==========================================================================
@router.get("/api/lists/{list_id}/invites", response_model=list[InviteOut])
def get_invites(lst: OwnedList, db: DbSession):
rows = db.scalars(
select(ListInvite)
.where(ListInvite.list_id == lst.id)
.order_by(ListInvite.created_at.desc())
).all()
return [_invite_out(i) for i in rows]
@router.post(
"/api/lists/{list_id}/invites",
response_model=InviteOut,
status_code=status.HTTP_201_CREATED,
)
def create_invite(
payload: InviteCreateIn,
request: Request,
lst: OwnedList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
email = normalize_email(payload.email)
if email == normalize_email(user.email):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Du bist bereits Eigentümer dieser Liste."
)
# Ist unter der Adresse jemand bereits Mitglied?
existing_user = db.scalar(select(User).where(User.email == email))
if existing_user is not None:
member = db.get(ListMember, (lst.id, existing_user.id))
if member is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Diese Person hat bereits Zugriff auf die Liste.",
)
open_count = sum(
1
for i in db.scalars(
select(ListInvite).where(ListInvite.list_id == lst.id)
).all()
if _status(i) == "pending"
)
if open_count >= MAX_OPEN_INVITES:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Es sind bereits {MAX_OPEN_INVITES} Einladungen offen.",
)
_guard_invite_rate(db, request, lst.id)
# Eine noch offene Einladung an dieselbe Adresse ersetzen, statt eine
# zweite danebenzulegen.
for old in db.scalars(
select(ListInvite).where(
ListInvite.list_id == lst.id,
ListInvite.email == email,
ListInvite.accepted_at.is_(None),
ListInvite.revoked_at.is_(None),
)
).all():
old.revoked_at = utcnow()
valid_days = _invite_days(db)
raw = new_token()
invite = ListInvite(
list_id=lst.id,
email=email,
role=payload.role,
token_hash=hash_token(raw),
invited_by=user.id,
expires_at=utcnow() + timedelta(days=valid_days),
)
db.add(invite)
db.commit()
db.refresh(invite)
background.add_task(
send_invitation, email, raw,
lst.name, user.display_name or user.email.split("@")[0], valid_days,
)
return _invite_out(invite)
@router.post("/api/lists/{list_id}/invites/{invite_id}/resend", response_model=InviteOut)
def resend_invite(
invite_id: str,
request: Request,
lst: OwnedList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Erneuter Versand erzeugt ein NEUES Token und entwertet das alte.
In der Datenbank steht nur der Hash, der Klartext des ursprünglichen
Tokens lässt sich also nicht rekonstruieren. Nebeneffekt: Ein Link,
der versehentlich weitergeleitet wurde, verliert damit seine Gültigkeit.
"""
invite = db.get(ListInvite, invite_id)
if invite is None or invite.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
if invite.accepted_at:
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
)
_guard_invite_rate(db, request, lst.id)
valid_days = _invite_days(db)
raw = new_token()
invite.token_hash = hash_token(raw)
invite.revoked_at = None
invite.expires_at = utcnow() + timedelta(days=valid_days)
invite.last_sent_at = utcnow()
invite.send_count += 1
db.commit()
db.refresh(invite)
background.add_task(
send_invitation, invite.email, raw,
lst.name, user.display_name or user.email.split("@")[0], valid_days,
)
return _invite_out(invite)
@router.delete("/api/lists/{list_id}/invites/{invite_id}", response_model=MessageOut)
def revoke_invite(invite_id: str, lst: OwnedList, db: DbSession):
invite = db.get(ListInvite, invite_id)
if invite is None or invite.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
if invite.accepted_at:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Diese Einladung wurde bereits angenommen. Entziehe stattdessen "
"die Mitgliedschaft.",
)
invite.revoked_at = utcnow()
db.commit()
return MessageOut(detail="Einladung widerrufen. Der Link funktioniert nicht mehr.")
# ==========================================================================
# Freigabe insgesamt aufheben
# ==========================================================================
@router.post("/api/lists/{list_id}/unshare", response_model=MessageOut)
def unshare(lst: OwnedList, db: DbSession):
"""Entfernt alle Mitglieder außer dem Eigentümer und widerruft alle
offenen Einladungen."""
members = db.scalars(
select(ListMember).where(
ListMember.list_id == lst.id, ListMember.user_id != lst.owner_id
)
).all()
for member in members:
db.delete(member)
revoked = 0
for invite in db.scalars(
select(ListInvite).where(
ListInvite.list_id == lst.id,
ListInvite.accepted_at.is_(None),
ListInvite.revoked_at.is_(None),
)
).all():
invite.revoked_at = utcnow()
revoked += 1
lst.rev += 1
db.commit()
return MessageOut(
detail=f"{len(members)} Zugriff(e) entzogen, {revoked} offene Einladung(en) "
"widerrufen."
)
# ==========================================================================
# Eigentümerwechsel
# ==========================================================================
@router.post("/api/lists/{list_id}/transfer", response_model=list[MemberOut])
def transfer_ownership(
payload: TransferOwnershipIn, lst: OwnedList, db: DbSession, user: VerifiedUser
):
"""Der bisherige Eigentümer wird zum Bearbeiter - so verliert er nicht
versehentlich den Zugang zu einer Liste, an der er mitarbeitet."""
if payload.user_id == lst.owner_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Diese Person ist bereits Eigentümer."
)
target = db.get(ListMember, (lst.id, payload.user_id))
if target is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"Diese Person ist kein Mitglied der Liste. Lade sie zuerst ein.",
)
previous = db.get(ListMember, (lst.id, lst.owner_id))
if previous is not None:
previous.role = "editor"
target.role = "owner"
target.may_share_public = True
lst.owner_id = payload.user_id
lst.rev += 1
db.commit()
rows = db.execute(
select(ListMember, User)
.join(User, User.id == ListMember.user_id)
.where(ListMember.list_id == lst.id)
.order_by(ListMember.joined_at)
).all()
return [
MemberOut(
user_id=u.id, email=u.email, display_name=u.display_name,
role=m.role, may_share_public=m.may_share_public,
joined_at=m.joined_at,
)
for m, u in rows
]
# ==========================================================================
# Einladung ansehen und annehmen (Empfängerseite)
# ==========================================================================
def _load_invite(db: DbSession, token: str) -> ListInvite:
invite = db.scalar(
select(ListInvite).where(ListInvite.token_hash == hash_token(token))
)
if invite is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Diese Einladung ist unbekannt."
)
state = _status(invite)
if state == "revoked":
raise HTTPException(status.HTTP_410_GONE, "Diese Einladung wurde widerrufen.")
if state == "expired":
raise HTTPException(
status.HTTP_410_GONE,
"Diese Einladung ist abgelaufen. Bitte um eine neue.",
)
if state == "accepted":
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
)
return invite
@router.get("/api/invites/{token}", response_model=InvitePreviewOut)
def preview_invite(token: str, db: DbSession, user: VerifiedUser):
invite = _load_invite(db, token)
lst = db.get(ShoppingList, invite.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
inviter = invite.inviter
return InvitePreviewOut(
list_name=lst.name,
invited_by_name=(
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
),
role=invite.role,
email=invite.email,
matches_current_user=normalize_email(user.email) == invite.email,
)
@router.post("/api/invites/{token}/accept", response_model=MessageOut)
def accept_invite(token: str, db: DbSession, user: VerifiedUser):
invite = _load_invite(db, token)
# Die Adresse muss zum angemeldeten Konto passen. Sonst würde ein
# weitergeleiteter Link jedem den Zugang öffnen.
if normalize_email(user.email) != invite.email:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Diese Einladung ist an {invite.email} gerichtet. "
"Melde dich mit diesem Konto an oder bitte um eine neue Einladung.",
)
lst = db.get(ShoppingList, invite.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
if db.get(ListMember, (lst.id, user.id)) is None:
db.add(ListMember(list_id=lst.id, user_id=user.id, role=invite.role))
lst.rev += 1
invite.accepted_at = utcnow()
invite.accepted_by = user.id
db.commit()
return MessageOut(detail=f"Du hast jetzt Zugriff auf „{lst.name}“.")
# ==========================================================================
# Eigene Mitgliedschaft beenden
# ==========================================================================
@router.post("/api/lists/{list_id}/leave", response_model=MessageOut)
def leave_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
if lst.owner_id == user.id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Als Eigentümer kannst du die Liste nicht verlassen. Übertrage sie "
"zuvor oder lösche sie.",
)
member = db.get(ListMember, (lst.id, user.id))
if member is not None:
db.delete(member)
lst.rev += 1
db.commit()
return MessageOut(detail="Du hast die Liste verlassen.")