Erste Produktivversion
This commit is contained in:
297
backend/app/routers/public.py
Normal file
297
backend/app/routers/public.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Öffentliche Ansichtslinks.
|
||||
|
||||
Wer den Link hat, darf die Liste sehen und - sofern erlaubt - Artikel
|
||||
abhaken. Sonst nichts: kein Anlegen, kein Löschen, keine Preise ändern,
|
||||
keine Mitgliederliste, keine Namen.
|
||||
|
||||
Sicherheitsmodell: Der Link *ist* die Berechtigung. Wer ihn
|
||||
weitergibt, gibt den Zugriff weiter. Deshalb ist ein Ablaufdatum Pflicht,
|
||||
der Widerruf jederzeit möglich, und in der Datenbank steht nur der Hash
|
||||
des Tokens.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.deps import DbSession, VerifiedUser, client_ip
|
||||
from app.list_view import build_view
|
||||
from app.print_view import render_print
|
||||
from app.models import ListItem, ListMember, PublicShare, ShoppingList
|
||||
from app.permissions import bump_rev
|
||||
from app.schemas import MessageOut
|
||||
from app.schemas_shopping import (
|
||||
PublicShareCreatedOut,
|
||||
PublicShareIn,
|
||||
PublicShareOut,
|
||||
PublicToggleIn,
|
||||
PublicViewOut,
|
||||
)
|
||||
from app.security import check_rate_limit, hash_token, new_token, utcnow
|
||||
|
||||
router = APIRouter(tags=["public"])
|
||||
|
||||
MAX_DAYS = 365
|
||||
MAX_ACTIVE_LINKS = 10
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Verwaltung (angemeldet)
|
||||
# ==========================================================================
|
||||
|
||||
def sharer(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
|
||||
"""Eigentümer oder Mitglied mit dem Zusatzrecht `may_share_public`."""
|
||||
lst = db.scalar(
|
||||
select(ShoppingList).where(
|
||||
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
member = db.get(ListMember, (list_id, user.id))
|
||||
if lst is None or member is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
|
||||
|
||||
if member.role != "owner" and not member.may_share_public:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"Für öffentliche Links fehlt dir die Berechtigung. Der Eigentümer "
|
||||
"der Liste kann sie dir erteilen.",
|
||||
)
|
||||
return lst
|
||||
|
||||
|
||||
SharableList = Annotated[ShoppingList, Depends(sharer)]
|
||||
|
||||
|
||||
def _status(share: PublicShare) -> str:
|
||||
if share.revoked_at:
|
||||
return "revoked"
|
||||
if share.expires_at <= utcnow():
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
|
||||
def _share_out(share: PublicShare) -> PublicShareOut:
|
||||
creator = share.creator
|
||||
return PublicShareOut(
|
||||
id=share.id,
|
||||
label=share.label,
|
||||
allow_check=share.allow_check,
|
||||
status=_status(share),
|
||||
created_by_name=(
|
||||
(creator.display_name or creator.email.split("@")[0]) if creator else None
|
||||
),
|
||||
created_at=share.created_at,
|
||||
expires_at=share.expires_at,
|
||||
last_access_at=share.last_access_at,
|
||||
access_count=share.access_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/lists/{list_id}/public-links", response_model=list[PublicShareOut])
|
||||
def get_public_links(lst: SharableList, db: DbSession):
|
||||
rows = db.scalars(
|
||||
select(PublicShare)
|
||||
.where(PublicShare.list_id == lst.id)
|
||||
.order_by(PublicShare.created_at.desc())
|
||||
).all()
|
||||
return [_share_out(s) for s in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/lists/{list_id}/public-links",
|
||||
response_model=PublicShareCreatedOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_public_link(
|
||||
payload: PublicShareIn, lst: SharableList, db: DbSession, user: VerifiedUser
|
||||
):
|
||||
expires = payload.expires_at.replace(tzinfo=None)
|
||||
now = utcnow()
|
||||
|
||||
if expires <= now:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Das Ablaufdatum muss in der Zukunft liegen."
|
||||
)
|
||||
if expires > now + timedelta(days=MAX_DAYS):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Ein Link darf höchstens {MAX_DAYS} Tage gültig sein.",
|
||||
)
|
||||
|
||||
active = [
|
||||
s
|
||||
for s in db.scalars(
|
||||
select(PublicShare).where(PublicShare.list_id == lst.id)
|
||||
).all()
|
||||
if _status(s) == "active"
|
||||
]
|
||||
if len(active) >= MAX_ACTIVE_LINKS:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Es sind bereits {MAX_ACTIVE_LINKS} Links aktiv. Widerrufe zuerst "
|
||||
"einen davon.",
|
||||
)
|
||||
|
||||
raw = new_token()
|
||||
share = PublicShare(
|
||||
list_id=lst.id,
|
||||
token_hash=hash_token(raw),
|
||||
label=(payload.label or None),
|
||||
allow_check=payload.allow_check,
|
||||
created_by=user.id,
|
||||
expires_at=expires,
|
||||
)
|
||||
db.add(share)
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
|
||||
return PublicShareCreatedOut(
|
||||
share=_share_out(share),
|
||||
url=f"{settings.public_base_url.rstrip('/')}/s/{raw}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/lists/{list_id}/public-links/{share_id}", response_model=MessageOut)
|
||||
def revoke_public_link(share_id: str, lst: SharableList, db: DbSession):
|
||||
share = db.get(PublicShare, share_id)
|
||||
if share is None or share.list_id != lst.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Link nicht gefunden")
|
||||
if share.revoked_at is None:
|
||||
share.revoked_at = utcnow()
|
||||
db.commit()
|
||||
return MessageOut(detail="Link widerrufen. Er funktioniert ab sofort nicht mehr.")
|
||||
|
||||
|
||||
@router.post("/api/lists/{list_id}/public-links/revoke-all", response_model=MessageOut)
|
||||
def revoke_all_public_links(lst: SharableList, db: DbSession):
|
||||
count = 0
|
||||
for share in db.scalars(
|
||||
select(PublicShare).where(
|
||||
PublicShare.list_id == lst.id, PublicShare.revoked_at.is_(None)
|
||||
)
|
||||
).all():
|
||||
share.revoked_at = utcnow()
|
||||
count += 1
|
||||
db.commit()
|
||||
return MessageOut(detail=f"{count} Link(s) widerrufen.")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Zugriff ohne Konto
|
||||
# ==========================================================================
|
||||
|
||||
def _load_share(db: DbSession, token: str, request: Request) -> PublicShare:
|
||||
# Ohne Anmeldung ist der Endpunkt für jeden erreichbar - deshalb
|
||||
# bremsen, bevor jemand Token durchprobiert. Bei 256 Bit Zufall ist
|
||||
# Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben.
|
||||
if not check_rate_limit(
|
||||
db, f"public:{client_ip(request)}", limit=120, window_minutes=15
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anfragen. Bitte kurz warten."
|
||||
)
|
||||
|
||||
share = db.scalar(
|
||||
select(PublicShare).where(PublicShare.token_hash == hash_token(token))
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Gleiche Antwort für "gibt es nicht" und "abgelaufen"? Nein: Der
|
||||
# Empfänger soll erfahren, warum der Link nicht mehr geht, sonst
|
||||
# sucht er den Fehler bei sich.
|
||||
if share is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dieser Link ist unbekannt.")
|
||||
state = _status(share)
|
||||
if state == "revoked":
|
||||
raise HTTPException(status.HTTP_410_GONE, "Dieser Link wurde widerrufen.")
|
||||
if state == "expired":
|
||||
raise HTTPException(status.HTTP_410_GONE, "Dieser Link ist abgelaufen.")
|
||||
return share
|
||||
|
||||
|
||||
@router.get("/api/public/{token}", response_model=PublicViewOut)
|
||||
def public_view(token: str, request: Request, response: Response, db: DbSession):
|
||||
share = _load_share(db, token, request)
|
||||
|
||||
lst = db.get(ShoppingList, share.list_id)
|
||||
if lst is None or lst.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||
|
||||
# Grobe Nutzungsanzeige für den Eigentümer: nur Zähler und Zeitpunkt,
|
||||
# keine IP-Adresse, keine Zeitreihe. Erkennbar soll sein, DASS der
|
||||
# Link benutzt wird, nicht von wem.
|
||||
share.access_count += 1
|
||||
share.last_access_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||||
|
||||
view = build_view(db, lst, anonymous=True)
|
||||
return PublicViewOut(
|
||||
list_name=view.list_name,
|
||||
rev=view.rev,
|
||||
allow_check=share.allow_check,
|
||||
expires_at=share.expires_at,
|
||||
markets=view.markets,
|
||||
grand_total_cents=view.grand_total_cents,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/public/{token}/print", response_class=HTMLResponse)
|
||||
def public_print(token: str, request: Request, db: DbSession):
|
||||
"""Druckansicht über den öffentlichen Link - ohne Konto.
|
||||
|
||||
Wie die Bildschirmansicht anonymisiert: Wer welchen Artikel
|
||||
eingetragen hat, steht dort ohnehin nicht drin, und im Ausdruck
|
||||
erst recht nicht.
|
||||
"""
|
||||
share = _load_share(db, token, request)
|
||||
|
||||
lst = db.get(ShoppingList, share.list_id)
|
||||
if lst is None or lst.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
|
||||
|
||||
share.access_count += 1
|
||||
share.last_access_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
view = build_view(db, lst, include_deferred=False, anonymous=True)
|
||||
return HTMLResponse(
|
||||
render_print(view),
|
||||
headers={"X-Robots-Tag": "noindex, nofollow"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/public/{token}/items/{item_id}", response_model=MessageOut)
|
||||
def public_toggle(
|
||||
token: str,
|
||||
item_id: str,
|
||||
payload: PublicToggleIn,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
):
|
||||
"""Die einzige Änderung, die über einen öffentlichen Link möglich ist.
|
||||
|
||||
Absichtlich auf offen/gekauft begrenzt: Wer mit dem Link einkaufen
|
||||
geht, soll abhaken können. Alles Weitere - anlegen, löschen, Preise,
|
||||
Märkte - bleibt den Mitgliedern vorbehalten.
|
||||
"""
|
||||
share = _load_share(db, token, request)
|
||||
if not share.allow_check:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "Über diesen Link ist nur Ansehen erlaubt."
|
||||
)
|
||||
|
||||
item = db.get(ListItem, item_id)
|
||||
if item is None or item.list_id != share.list_id or item.deleted_at is not None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||
|
||||
item.status = payload.status
|
||||
item.row_rev = bump_rev(db, share.list_id)
|
||||
db.commit()
|
||||
return MessageOut(detail="Gespeichert.")
|
||||
Reference in New Issue
Block a user