Files
timemaster/backend/app/routers/ical.py
T
patrickandClaude Opus 4.8 43ebc83905
Security Audit / Node.js Dependency Audit (push) Has been cancelled
Security Audit / Python Dependency Audit (push) Has been cancelled
feat(ical): abonnierbarer read-only Kalender-Feed pro Nutzer
Token-gescoper iCal-Feed (/absences/ical/<token>.ics), abonnierbar in
Outlook/Apple/Google. Anders als der CalDAV-Client (Push nach Nextcloud)
pollt der Kalender die URL selbst. Feed zeigt nur die eigenen bestätigten
Abwesenheiten des Token-Inhabers.

- users.ical_token_hash (SHA-256, rotierbar) + Migration 0041 (nur Spalte,
  keine RLS-Aenderung; users-Policy deckt neue nullable Spalte ab)
- Router ical.py: oeffentlicher Feed (kein JWT) + Token-Verwaltung
  POST/GET/DELETE /users/me/ical-token (authentifiziert)
- ProfilePage: Sektion "Kalender-Abo (iCal)" mit Erzeugen/Rotieren/
  Deaktivieren, URL-Anzeige einmalig + Kopieren
- test_ical.py: Token-Lifecycle + oeffentlicher Feed (3 Tests)

Deployed auf 137 (Migration 0041, 196/196 Tests gruen). 164 ausstehend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:12:05 +02:00

133 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""iCal-Abo-Feed pro Nutzer (read-only, abonnierbar in Outlook/Apple/Google).
Anders als der CalDAV-Client (der Events aktiv nach Nextcloud *pusht*), stellt
dieser Endpunkt einen abonnierbaren `text/calendar`-Feed bereit: der Kalender
des Nutzers pollt die URL selbst.
Sicherheit / DSGVO:
- Token-gescoped auf GENAU EINEN Nutzer (gehasht in `users.ical_token_hash`).
- Feed enthält ausschließlich die eigenen Abwesenheiten dieses Nutzers.
- Kein JWT (Kalender-Apps können kein Bearer) → Zugang allein über das
geheime, rotierbare Token in der URL. Opt-in: ohne Token kein Feed.
- Read-only, kein DB-Write → RLS-Bypass-Kontext (get_db default) bleibt stabil.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from icalendar import Calendar, Event
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
from app.models.user import User
router = APIRouter(tags=["iCal"])
# Nur bestätigte / laufende Anträge im Kalender zeigen keine offenen/abgelehnten.
_FEED_STATUSES = (
AbsenceStatus.APPROVED,
AbsenceStatus.FIRST_APPROVED,
AbsenceStatus.CANCELLATION_REQUESTED,
)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _feed_url(token: str) -> str:
base = settings.frontend_url.rstrip("/")
return f"{base}/api/v1/absences/ical/{token}.ics"
# ── Öffentlicher Feed (kein JWT) ──────────────────────────────────────────────
@router.get("/absences/ical/{token}.ics")
async def ical_feed(token: str, db: AsyncSession = Depends(get_db)) -> Response:
user = await db.scalar(select(User).where(User.ical_token_hash == _hash_token(token)))
if user is None or not user.is_active:
raise HTTPException(status_code=404, detail="Kalender-Feed nicht gefunden.")
rows = (
await db.execute(
select(Absence, AbsenceType.name, AbsenceType.color)
.join(AbsenceType, AbsenceType.id == Absence.type_id)
.where(
Absence.user_id == user.id,
Absence.status.in_(_FEED_STATUSES),
)
.order_by(Absence.start_date)
)
).all()
cal = Calendar()
cal.add("prodid", "-//TimeMaster//iCal Feed//DE")
cal.add("version", "2.0")
cal.add("x-wr-calname", f"TimeMaster {user.first_name} {user.last_name}")
cal.add("x-published-ttl", "PT12H")
now = datetime.now(timezone.utc)
for absence, type_name, _color in rows:
summary = type_name
if absence.status == AbsenceStatus.FIRST_APPROVED:
summary = f"{type_name} (vorläufig)"
elif absence.status == AbsenceStatus.CANCELLATION_REQUESTED:
summary = f"{type_name} (Storno beantragt)"
if absence.half_day_start or absence.half_day_end:
summary += " ½"
ev = Event()
ev.add("uid", f"absence-{absence.id}@timemaster")
ev.add("dtstart", absence.start_date)
ev.add("dtend", absence.end_date + timedelta(days=1)) # DTEND exklusiv
ev.add("summary", summary)
if absence.note:
ev.add("description", absence.note)
ev.add("status", "CONFIRMED")
ev.add("transp", "TRANSPARENT")
ev.add("dtstamp", now)
ev.add("last-modified", absence.created_at or now)
cal.add_component(ev)
return Response(
content=cal.to_ical(),
media_type="text/calendar; charset=utf-8",
headers={"Content-Disposition": 'inline; filename="timemaster.ics"'},
)
# ── Token-Verwaltung (authentifiziert) ────────────────────────────────────────
@router.get("/users/me/ical-token")
async def ical_token_status(current_user: User = Depends(get_current_user)) -> dict:
"""Ist der Feed aktiviert? Das Token selbst wird nie zurückgegeben (nur Hash gespeichert)."""
return {"enabled": current_user.ical_token_hash is not None}
@router.post("/users/me/ical-token")
async def create_or_rotate_ical_token(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Erzeugt ein neues Token (rotiert ein bestehendes) und liefert die Feed-URL einmalig zurück."""
token = secrets.token_urlsafe(32)
current_user.ical_token_hash = _hash_token(token)
await db.commit()
return {"enabled": True, "url": _feed_url(token)}
@router.delete("/users/me/ical-token", status_code=204)
async def delete_ical_token(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
current_user.ical_token_hash = None
await db.commit()
return Response(status_code=204)