"""Ö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.")