feat(anhaenge): ATTACH-Zeile im erzeugten ICS

Jeder Anhang wird als ATTACH mit FMTTYPE, SIZE und FILENAME ausgegeben,
als URI statt inline base64 -- bei 10 MB je Datei wuerde sonst bei jeder
Synchronisation der ganze Anhang im ICS-Rumpf mitgeschleppt.

Die absolute URL kommt aus dav_util.public_base, das PUBLIC_BASE_URL und
die Forwarded-Header schon behandelt; ohne Basis-URL wird die Zeile
weggelassen, weil eine relative ATTACH-URL einem externen Client nichts
nuetzt. Abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.

Der CalDAV-PUT ist geprueft: ein externer Client, der den Termin
zurueckschreibt, laesst die Anhaenge unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 10:19:14 +02:00
co-authored by Claude Opus 5
parent 4b9c09df74
commit a024fa54b9
6 changed files with 207 additions and 21 deletions
+1 -8
View File
@@ -10,7 +10,6 @@ Two routers live here:
send a bearer token. See the README for the tradeoff.
"""
import os
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
@@ -29,12 +28,6 @@ from database import get_db
router = APIRouter()
public_router = APIRouter()
# Deployments that would rather lose attachments in external calendar clients
# than hand out unauthenticated URLs can set this to 0: the capability endpoint
# then 404s and no ATTACH line is emitted into any ICS.
PUBLIC_LINKS_ENABLED = os.environ.get("ATTACHMENT_PUBLIC_LINKS", "1") != "0"
def _to_dict(att: models.EventAttachment) -> dict:
uploader = None
if att.uploader is not None:
@@ -261,7 +254,7 @@ def public_attachment(token: str, name: str = "", db: Session = Depends(get_db))
Never distinguish "wrong token" from "deleted": both are 404.
"""
if not PUBLIC_LINKS_ENABLED:
if not attachments_store.PUBLIC_LINKS_ENABLED:
raise HTTPException(404, "Not found")
att = (
db.query(models.EventAttachment)
+19 -9
View File
@@ -160,8 +160,15 @@ def _name_cache(cal: models.LocalCalendar, db: Session) -> dict:
return {}
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session) -> str:
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db))
def _build_ics(cal: models.LocalCalendar, evs: list[models.LocalEvent], db: Session,
base_url: str | None = None) -> str:
# Without a base URL (or with public links switched off) no ATTACH is
# emitted: a relative attachment URL is useless to an external client.
atts = None
if base_url and attachments_store.PUBLIC_LINKS_ENABLED:
atts = attachments_store.by_event(db, [e.id for e in evs])
return ical_io.build_ics(cal, evs, name_cache=_name_cache(cal, db),
attachments_by_event=atts, base_url=base_url)
# ── XML builders ──────────────────────────────────────────
@@ -277,7 +284,8 @@ def _handle_propfind(cal: models.LocalCalendar, base: str, resource: str, depth:
return _multistatus("\n".join(parts))
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session) -> Response:
def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Session,
base_url: str | None = None) -> Response:
report_type = None
hrefs: list[str] = []
if body:
@@ -297,17 +305,17 @@ def _handle_report(cal: models.LocalCalendar, base: str, body: bytes, db: Sessio
parts = []
for ev in evs:
ics = _build_ics(cal, [ev], db)
ics = _build_ics(cal, [ev], db, base_url)
parts.append(_event_propstat(base, ev, with_data=True, ics=ics))
return _multistatus("\n".join(parts) if parts else "")
def _handle_get(cal: models.LocalCalendar, resource: str, db: Session,
*, head: bool = False) -> Response:
*, head: bool = False, base_url: str | None = None) -> Response:
ev = _find_event(cal, resource, db)
if not ev:
return Response(status_code=404)
ics = _build_ics(cal, [ev], db)
ics = _build_ics(cal, [ev], db, base_url)
headers = {"ETag": f'"{_etag(ev)}"'}
return Response(
content=b"" if head else ics,
@@ -390,13 +398,15 @@ async def _dispatch_collection(request: Request, cal: models.LocalCalendar, base
depth = request.headers.get("Depth", "0")
return _handle_propfind(cal, base, resource, depth, db,
principal_href=principal_href, home_href=home_href)
# The public origin as clients actually reach us, for ATTACH URLs.
base_url = dav_util.public_base(request)
if method == "REPORT":
return _handle_report(cal, base, await request.body(), db)
return _handle_report(cal, base, await request.body(), db, base_url)
if method in ("GET", "HEAD"):
if not resource:
ics = _build_ics(cal, _events(cal, db), db)
ics = _build_ics(cal, _events(cal, db), db, base_url)
return Response(content=ics, media_type="text/calendar; charset=utf-8")
return _handle_get(cal, resource, db, head=(method == "HEAD"))
return _handle_get(cal, resource, db, head=(method == "HEAD"), base_url=base_url)
if method == "PUT":
return _handle_put(cal, resource, await request.body(), db)
if method == "DELETE":
+7 -1
View File
@@ -772,6 +772,7 @@ async def import_generic(
@router.get("/calendars/{calendar_id}/export")
def export_calendar(
calendar_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
@@ -783,7 +784,12 @@ def export_calendar(
)
# Resolve creator display names for ORGANIZER.
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
ics = ical_io.build_ics(cal, events, name_cache=name_cache)
base_url = dav_util.public_base(request)
atts = None
if base_url and attachments_store.PUBLIC_LINKS_ENABLED:
atts = attachments_store.by_event(db, [e.id for e in events])
ics = ical_io.build_ics(cal, events, name_cache=name_cache,
attachments_by_event=atts, base_url=base_url)
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
return Response(
content=ics,