Files

299 lines
9.9 KiB
Python

"""Ö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 bucket_key, 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, bucket_key(db, "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.")