diff --git a/app/modules/calendar/backend/router.py b/app/modules/calendar/backend/router.py index 5a02e1e..de230d7 100644 --- a/app/modules/calendar/backend/router.py +++ b/app/modules/calendar/backend/router.py @@ -1,14 +1,98 @@ import logging +import hmac +import hashlib from datetime import datetime, timedelta -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import Response +from app.core.auth_dependencies import get_current_user +from app.core.config import settings from app.core.database import execute_query logger = logging.getLogger(__name__) router = APIRouter() +def _calendar_token_secret() -> bytes: + return (settings.SECRET_KEY or settings.JWT_SECRET_KEY or "calendar-dev-secret").encode("utf-8") + + +def _create_calendar_feed_token(user_id: int, expires_at: datetime) -> str: + exp_ts = int(expires_at.timestamp()) + payload = f"{int(user_id)}:{exp_ts}".encode("utf-8") + digest = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest() + return f"{int(user_id)}.{exp_ts}.{digest}" + + +def _verify_calendar_feed_token(token: str) -> int: + parts = str(token or "").split(".") + if len(parts) != 3: + raise HTTPException(status_code=401, detail="Invalid calendar token") + + user_part, exp_part, sig_part = parts + try: + user_id = int(user_part) + exp_ts = int(exp_part) + except ValueError as exc: + raise HTTPException(status_code=401, detail="Invalid calendar token") from exc + + payload = f"{user_id}:{exp_ts}".encode("utf-8") + expected = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, sig_part): + raise HTTPException(status_code=401, detail="Invalid calendar token") + + if datetime.utcnow().timestamp() > exp_ts: + raise HTTPException(status_code=401, detail="Calendar token expired") + + return user_id + + +def _build_ical_response(events: list[dict], now: datetime) -> Response: + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//BMC Hub//Calendar//DA", + "CALSCALE:GREGORIAN", + "X-WR-CALNAME:BMC Hub Kalender", + "REFRESH-INTERVAL;VALUE=DURATION:PT15M", + "X-PUBLISHED-TTL:PT15M", + ] + + for event in events: + start_value = datetime.fromisoformat(event.get("start")) + summary = _escape_ical(event.get("title", "")) + description_parts = [] + if event.get("customer_name"): + description_parts.append(f"Kunde: {event.get('customer_name')}") + if event.get("event_type"): + description_parts.append(f"Type: {event.get('event_type')}") + if event.get("url"): + description_parts.append(f"Link: {event.get('url')}") + description = _escape_ical("\n".join(description_parts)) + uid = _escape_ical(f"{event.get('id')}@bmc-hub") + + lines.extend([ + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{_format_ical_dt(now)}", + f"DTSTART:{_format_ical_dt(start_value)}", + f"SUMMARY:{summary}", + f"DESCRIPTION:{description}", + "END:VEVENT", + ]) + + lines.append("END:VCALENDAR") + + return Response( + content="\r\n".join(lines), + media_type="text/calendar; charset=utf-8", + headers={ + "Content-Disposition": 'inline; filename="bmc-hub-calendar.ics"', + "Cache-Control": "public, max-age=300", + }, + ) + + def _parse_iso_datetime(value: str, fallback: datetime) -> datetime: if not value: return fallback @@ -302,40 +386,53 @@ async def get_calendar_ical( types=types, ) - lines = [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - "PRODID:-//BMC Hub//Calendar//DA", - "CALSCALE:GREGORIAN", - "X-WR-CALNAME:BMC Hub Kalender", - ] + return _build_ical_response(events, now) - for event in events: - start_value = datetime.fromisoformat(event.get("start")) - summary = _escape_ical(event.get("title", "")) - description_parts = [] - if event.get("customer_name"): - description_parts.append(f"Kunde: {event.get('customer_name')}") - if event.get("event_type"): - description_parts.append(f"Type: {event.get('event_type')}") - if event.get("url"): - description_parts.append(f"Link: {event.get('url')}") - description = _escape_ical("\n".join(description_parts)) - uid = _escape_ical(f"{event.get('id')}@bmc-hub") - lines.extend([ - "BEGIN:VEVENT", - f"UID:{uid}", - f"DTSTAMP:{_format_ical_dt(now)}", - f"DTSTART:{_format_ical_dt(start_value)}", - f"SUMMARY:{summary}", - f"DESCRIPTION:{description}", - "END:VEVENT", - ]) +@router.get("/calendar/ical/subscribe") +async def get_calendar_ical_subscribe_link( + request: Request, + current_user: dict = Depends(get_current_user), +): + """Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar.""" + expires_at = datetime.utcnow() + timedelta(days=3650) + token = _create_calendar_feed_token(int(current_user.get("id")), expires_at) + base = str(request.base_url).rstrip("/") + http_url = f"{base}/api/v1/calendar/ical/feed?token={token}" + webcal_url = http_url.replace("https://", "webcals://", 1).replace("http://", "webcal://", 1) - lines.append("END:VCALENDAR") + return { + "http_url": http_url, + "webcal_url": webcal_url, + "expires_at": expires_at.isoformat() + "Z", + } - return Response( - content="\r\n".join(lines), - media_type="text/calendar; charset=utf-8", + +@router.get("/calendar/ical/feed") +async def get_calendar_ical_feed( + request: Request, + token: str = Query(...), + start: str = Query(None), + end: str = Query(None), + customer_id: int | None = Query(None), + types: str | None = Query(None), +): + """Token-based iCal feed endpoint intended for external calendar subscriptions.""" + now = datetime.now() + start_dt = _parse_iso_datetime(start, now - timedelta(days=14)) + end_dt = _parse_iso_datetime(end, now + timedelta(days=60)) + + if end_dt < start_dt: + raise HTTPException(status_code=400, detail="Invalid date range") + + user_id = _verify_calendar_feed_token(token) + events = _get_calendar_events( + request=request, + start_dt=start_dt, + end_dt=end_dt, + only_mine=True, + user_id=user_id, + customer_id=customer_id, + types=types, ) + return _build_ical_response(events, now) diff --git a/app/modules/calendar/templates/index.html b/app/modules/calendar/templates/index.html index 71391e5..a322bd5 100644 --- a/app/modules/calendar/templates/index.html +++ b/app/modules/calendar/templates/index.html @@ -431,7 +431,15 @@