Erste Produktivversion
This commit is contained in:
412
backend/app/routers/sharing.py
Normal file
412
backend/app/routers/sharing.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""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.")
|
||||
Reference in New Issue
Block a user