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
+23
View File
@@ -21,6 +21,7 @@ and forgets the helper.
import io
import logging
import os
import secrets
import time
import uuid
@@ -75,6 +76,11 @@ _TEXT_TYPES = {
# row is committed, and a sweep landing in between would delete a good file.
SWEEP_GRACE_SECONDS = 3600
# Deployments that would rather lose attachments in external calendar clients
# than hand out unauthenticated URLs set this to 0: no ATTACH line is emitted
# and the capability endpoint 404s.
PUBLIC_LINKS_ENABLED = os.environ.get("ATTACHMENT_PUBLIC_LINKS", "1") != "0"
def sniff(raw: bytes, filename: str) -> tuple[str, str]:
"""Determine ``(content_type, extension)`` from the BYTES.
@@ -243,6 +249,23 @@ def counts_for_events(db: Session, event_ids: Iterable[int]) -> dict[int, int]:
return {event_id: n for event_id, n in rows}
def by_event(db: Session, event_ids: Iterable[int]) -> dict[int, list]:
"""Attachments grouped by event id, in ONE query — for ICS generation."""
ids = [i for i in event_ids if i is not None]
if not ids:
return {}
out: dict[int, list] = {}
rows = (
db.query(models.EventAttachment)
.filter(models.EventAttachment.event_id.in_(ids))
.order_by(models.EventAttachment.id)
.all()
)
for row in rows:
out.setdefault(row.event_id, []).append(row)
return out
def sweep(db: Session) -> tuple[int, int]:
"""Drop attachments whose event is gone, and files with no row.