413 lines
13 KiB
Python
413 lines
13 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 (
|
|
check_rate_limit,
|
|
hash_token,
|
|
new_token,
|
|
normalize_email,
|
|
utcnow,
|
|
)
|
|
|
|
router = APIRouter(tags=["sharing"])
|
|
|
|
INVITE_DAYS = 14
|
|
MAX_OPEN_INVITES = 50
|
|
|
|
|
|
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, f"invite-list:{list_id}", limit=20, window_minutes=60)
|
|
ok_ip = check_rate_limit(db, f"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()
|
|
|
|
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=INVITE_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], INVITE_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)
|
|
|
|
raw = new_token()
|
|
invite.token_hash = hash_token(raw)
|
|
invite.revoked_at = None
|
|
invite.expires_at = utcnow() + timedelta(days=INVITE_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], INVITE_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.")
|