298 lines
9.8 KiB
Python
298 lines
9.8 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 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.")
|