feat(anhaenge): Datenmodell und Ablage fuer Termin-Anhaenge

Neue Tabelle event_attachments samt Ablage-Modul. Dateien liegen unter
DATA_DIR/attachments, geshardet nach einem servergenerierten Namen -- weder
der hochgeladene Dateiname noch die Termin-UID beruehren je einen Pfad, da
die UID bei CalDAV-Terminen aus einem fremden Client stammt.

Der Dateityp wird an den Bytes bestimmt, nie am Content-Type des Clients.
SVG und HTML sind bewusst nicht erlaubt: beide fuehren Skript im Origin der
App aus, wo das Sitzungstoken liegt.

Aufgeraeumt wird an allen fuenf Loeschpfaden (Termin, Kalender, Gruppe,
Benutzer, CalDAV-DELETE), und zwar Zeilen vor dem Commit, Dateien erst
danach -- ein Rollback darf keine Bytes verlieren. Der Sweep als
Sicherheitsnetz ist implementiert, haengt aber noch an keinem Scheduler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 10:04:44 +02:00
co-authored by Claude Opus 5
parent 6e981e7aea
commit 22a469d449
7 changed files with 633 additions and 0 deletions
+41
View File
@@ -243,6 +243,47 @@ class LocalEvent(Base):
calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User")
# Deleting the event drops its attachment rows; the FILES are removed by
# attachments_store.purge_for_events() — the ORM cascade never touches disk.
attachments = relationship(
"EventAttachment", cascade="all, delete-orphan", passive_deletes=False
)
class EventAttachment(Base):
"""A file attached to an event (PDF, image or text).
The event is addressed by ``(source, event_uid)`` so non-local sources
(CalDAV, Google, Home Assistant) can be enabled later without a migration.
Today only ``source="local"`` is ever written, and for those rows
``event_id`` carries the FK that makes joins and the ORM cascade work.
"""
__tablename__ = "event_attachments"
id = Column(Integer, primary_key=True, index=True)
source = Column(String(20), nullable=False, default="local")
event_uid = Column(String(255), nullable=False, index=True)
event_id = Column(Integer, ForeignKey("local_events.id"), nullable=True, index=True)
# The name the user uploaded — only ever used to label the download. It is
# NEVER part of an on-disk path: for CalDAV-created events even the event
# UID comes from an external client, so paths are built from stored_name.
filename = Column(String(255), nullable=False)
# Server-generated uuid4 hex plus extension, e.g. "a1b2...ef.pdf".
stored_name = Column(String(80), nullable=False, unique=True)
# The result of sniffing the bytes, never the client's Content-Type header.
content_type = Column(String(100), nullable=False)
size_bytes = Column(Integer, nullable=False)
has_thumb = Column(Boolean, default=False)
# Capability token for the ICS ATTACH URL, which external CalDAV clients
# fetch without any credentials. Unguessable, and revoked only by deleting
# the attachment.
token = Column(String(64), nullable=False, unique=True, index=True)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
uploader = relationship("User")
class ICalSubscription(Base):