diff --git a/backend/attachments_store.py b/backend/attachments_store.py
new file mode 100644
index 0000000..a54df94
--- /dev/null
+++ b/backend/attachments_store.py
@@ -0,0 +1,293 @@
+"""On-disk storage and validation for event attachments.
+
+Files live under ``DATA_DIR/attachments``, sharded by the first two characters
+of a server-generated name::
+
+ DATA_DIR/attachments/a1/a1b2....pdf the file
+ DATA_DIR/attachments/a1/a1b2....thumb.jpg thumbnail, images only
+
+Nothing here ever builds a path from user input. That is not caution for its
+own sake: for events created through CalDAV even the event UID comes straight
+from an external client (``dav_router._handle_put``), so neither the UID nor
+the uploaded filename may reach the filesystem. The original name lives only in
+``EventAttachment.filename`` and is re-attached on download.
+
+The ORM cascade on ``LocalEvent.attachments`` removes attachment ROWS when an
+event goes away, but it can never remove files. ``purge_for_events`` is the
+helper every explicit delete path calls; ``sweep`` is the safety net for the
+paths where a request died halfway or where someone adds a delete path later
+and forgets the helper.
+"""
+
+import io
+import logging
+import secrets
+import time
+import uuid
+from pathlib import Path
+from typing import Iterable
+
+from fastapi import HTTPException
+from PIL import Image
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+import models
+from database import DATA_DIR
+
+logger = logging.getLogger(__name__)
+
+ATTACH_DIR = DATA_DIR / "attachments"
+ATTACH_DIR.mkdir(parents=True, exist_ok=True)
+
+MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 # 10 MB
+MAX_ATTACHMENTS_PER_EVENT = 10
+THUMB_SIZE = (320, 320)
+# Pillow only warns above its own limit, so guard explicitly: a 200-byte PNG
+# can declare a 60000x60000 canvas and cost gigabytes to decode.
+MAX_IMAGE_PIXELS = 50_000_000
+
+# Pillow format -> (content type, extension). Deliberately NOT a general image
+# allowlist: SVG is an XML document that runs script in the app's own origin,
+# where the session token lives in localStorage. An uploaded SVG or HTML file
+# served back to another user would be account takeover, and the calendar
+# sharing feature would deliver it. Do not widen this without re-reading that.
+_IMAGE_FORMATS = {
+ "PNG": ("image/png", ".png"),
+ "JPEG": ("image/jpeg", ".jpg"),
+ "WEBP": ("image/webp", ".webp"),
+ "GIF": ("image/gif", ".gif"),
+}
+_IMAGE_CONTENT_TYPES = {ct for ct, _ in _IMAGE_FORMATS.values()}
+
+# Text subtypes are picked from the uploaded name's extension, but only after
+# the BYTES were confirmed to be text — the extension alone decides nothing
+# about whether the upload is accepted.
+_TEXT_TYPES = {
+ ".txt": "text/plain",
+ ".md": "text/markdown",
+ ".markdown": "text/markdown",
+ ".csv": "text/csv",
+}
+
+# How long a file is left alone before the sweep may call it an orphan. Without
+# this the sweep races an upload in progress: the bytes are written before the
+# row is committed, and a sweep landing in between would delete a good file.
+SWEEP_GRACE_SECONDS = 3600
+
+
+def sniff(raw: bytes, filename: str) -> tuple[str, str]:
+ """Determine ``(content_type, extension)`` from the BYTES.
+
+ The client's Content-Type header is never read — it is trivially forged,
+ and unlike the avatar upload there is no Pillow re-encode downstream that
+ would neutralise a mislabelled file. Raises 400 for anything off the
+ allowlist.
+ """
+ if raw.startswith(b"%PDF-"):
+ return "application/pdf", ".pdf"
+
+ fmt = None
+ try:
+ probe = Image.open(io.BytesIO(raw))
+ w, h = probe.size
+ if w * h <= MAX_IMAGE_PIXELS:
+ probe.verify() # verify() consumes the object; reopen to use it
+ fmt = probe.format
+ except Exception:
+ fmt = None
+ if fmt in _IMAGE_FORMATS:
+ return _IMAGE_FORMATS[fmt]
+
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeDecodeError:
+ text = None
+ if text is not None and "\x00" not in text:
+ # Both halves must agree: the bytes have to BE text, and the name has to
+ # claim one of the three text kinds we accept. Without the extension
+ # check anything UTF-8 would slip through as text/plain — an .html or
+ # .svg upload would be stored (harmlessly, as text/plain) rather than
+ # refused, which is not what "TXT, Markdown, CSV" promises the user.
+ ext = Path(filename or "").suffix.lower()
+ if ext in _TEXT_TYPES:
+ return _TEXT_TYPES[ext], ext
+
+ raise HTTPException(400, "Dateityp nicht erlaubt (PDF, Bild oder Textdatei)")
+
+
+def safe_filename(raw_name: str, ext: str) -> str:
+ """Strip any directory part and control characters from an uploaded name.
+
+ Only ever used as a label (download header, ICS FILENAME parameter) — never
+ as a path — but a name carrying quotes or newlines would break the headers
+ it is spliced into.
+ """
+ name = Path(raw_name or "").name
+ name = "".join(c for c in name if c.isprintable() and c not in '"\\/\r\n')
+ name = name.strip()[:120]
+ return name or f"anhang{ext}"
+
+
+def path_for(stored_name: str) -> Path:
+ return ATTACH_DIR / stored_name[:2] / stored_name
+
+
+def thumb_path_for(stored_name: str) -> Path:
+ stem = stored_name.split(".", 1)[0]
+ return ATTACH_DIR / stem[:2] / f"{stem}.thumb.jpg"
+
+
+def new_token() -> str:
+ """Capability token for the unauthenticated ICS ATTACH URL (256 bits)."""
+ return secrets.token_urlsafe(32)
+
+
+def new_stored_name(ext: str) -> str:
+ return f"{uuid.uuid4().hex}{ext}"
+
+
+def write_file(stored_name: str, raw: bytes) -> Path:
+ path = path_for(stored_name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(raw)
+ return path
+
+
+def make_thumb(raw: bytes, content_type: str, stored_name: str) -> bool:
+ """Render a small JPEG preview for images. CPU-bound — call off the loop."""
+ if content_type not in _IMAGE_CONTENT_TYPES:
+ return False
+ try:
+ img = Image.open(io.BytesIO(raw))
+ img.thumbnail(THUMB_SIZE)
+ target = thumb_path_for(stored_name)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ img.convert("RGB").save(str(target), "JPEG", quality=82)
+ return True
+ except Exception:
+ # A thumbnail is a nicety; a broken one must not fail the upload.
+ logger.warning("Could not build thumbnail for %s", stored_name)
+ return False
+
+
+def unlink_all(paths: Iterable[Path]) -> None:
+ """Best-effort removal. Call this only AFTER the transaction committed."""
+ for p in paths:
+ try:
+ p.unlink()
+ except FileNotFoundError:
+ pass
+ except OSError:
+ logger.warning("Could not remove attachment file %s", p)
+
+
+def files_of(row: "models.EventAttachment") -> list[Path]:
+ paths = [path_for(row.stored_name)]
+ if row.has_thumb:
+ paths.append(thumb_path_for(row.stored_name))
+ return paths
+
+
+def purge_for_events(db: Session, event_ids: Iterable[int]) -> list[Path]:
+ """Delete attachment ROWS for these local events; return the files to unlink.
+
+ The caller unlinks only after committing. Removing the bytes first would
+ lose data if the surrounding transaction then rolled back — the row would
+ come back pointing at a file that no longer exists.
+ """
+ ids = [i for i in event_ids if i is not None]
+ if not ids:
+ return []
+ rows = (
+ db.query(models.EventAttachment)
+ .filter(models.EventAttachment.event_id.in_(ids))
+ .all()
+ )
+ paths: list[Path] = []
+ for row in rows:
+ paths.extend(files_of(row))
+ db.delete(row)
+ return paths
+
+
+def purge_for_calendars(db: Session, calendar_ids: Iterable[int]) -> list[Path]:
+ """Same, for every event in the given local calendars."""
+ ids = [i for i in calendar_ids if i is not None]
+ if not ids:
+ return []
+ event_ids = [
+ r[0]
+ for r in db.query(models.LocalEvent.id)
+ .filter(models.LocalEvent.calendar_id.in_(ids))
+ .all()
+ ]
+ return purge_for_events(db, event_ids)
+
+
+def counts_for_events(db: Session, event_ids: Iterable[int]) -> dict[int, int]:
+ """Attachment count per event id, in ONE query.
+
+ The merged event read loops over every event; a lazy relationship there
+ would be one extra query per event across a whole month view.
+ """
+ ids = [i for i in event_ids if i is not None]
+ if not ids:
+ return {}
+ rows = (
+ db.query(models.EventAttachment.event_id, func.count(models.EventAttachment.id))
+ .filter(models.EventAttachment.event_id.in_(ids))
+ .group_by(models.EventAttachment.event_id)
+ .all()
+ )
+ return {event_id: n for event_id, n in rows}
+
+
+def sweep(db: Session) -> tuple[int, int]:
+ """Drop attachments whose event is gone, and files with no row.
+
+ Returns ``(rows_removed, files_removed)``. Both directions are needed
+ because the failure modes differ: an event removed through a cascade that
+ skipped the purge helper leaves rows behind, while a crash mid-upload
+ leaves a file behind.
+ """
+ live_ids = {r[0] for r in db.query(models.LocalEvent.id).all()}
+ stale_paths: list[Path] = []
+ rows_removed = 0
+ for row in db.query(models.EventAttachment).filter(
+ models.EventAttachment.source == "local"
+ ):
+ if row.event_id is None or row.event_id not in live_ids:
+ stale_paths.extend(files_of(row))
+ db.delete(row)
+ rows_removed += 1
+ if rows_removed:
+ db.commit()
+ unlink_all(stale_paths)
+
+ known = {
+ r[0].split(".", 1)[0]
+ for r in db.query(models.EventAttachment.stored_name).all()
+ }
+ cutoff = time.time() - SWEEP_GRACE_SECONDS
+ files_removed = 0
+ for path in ATTACH_DIR.rglob("*"):
+ if not path.is_file():
+ continue
+ if path.name.split(".", 1)[0] in known:
+ continue
+ try:
+ if path.stat().st_mtime > cutoff:
+ continue # inside the grace window; an upload may still own it
+ path.unlink()
+ files_removed += 1
+ except OSError:
+ logger.warning("Could not remove orphaned attachment file %s", path)
+
+ if rows_removed or files_removed:
+ logger.info(
+ "Attachment sweep: removed %d orphaned row(s), %d orphaned file(s)",
+ rows_removed,
+ files_removed,
+ )
+ return rows_removed, files_removed
diff --git a/backend/models.py b/backend/models.py
index 044eb5b..0a42d9a 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -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):
diff --git a/backend/routers/dav_router.py b/backend/routers/dav_router.py
index 8548326..41df4f8 100644
--- a/backend/routers/dav_router.py
+++ b/backend/routers/dav_router.py
@@ -30,6 +30,7 @@ from fastapi.responses import RedirectResponse, Response
from sqlalchemy import func
from sqlalchemy.orm import Session
+import attachments_store
import dav_util
import ical_io
import models
@@ -357,6 +358,10 @@ def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Sessi
ev.description = item.get("description")
ev.rrule = item.get("rrule")
ev.exdate = item.get("exdate")
+ # Only LocalEvent columns are written here, so attachments in the side table
+ # survive an external client rewriting the event. Do NOT start parsing the
+ # ICS ATTACH property: an inline ENCODING=BASE64 attachment would be
+ # unbounded in size and its content never sniffed.
dav_util.bump_dav(cal, ev)
db.commit()
db.refresh(ev)
@@ -368,8 +373,10 @@ def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Res
if not ev:
return Response(status_code=404)
dav_util.bump_dav(cal)
+ stale = attachments_store.purge_for_events(db, [ev.id])
db.delete(ev)
db.commit()
+ attachments_store.unlink_all(stale)
return Response(status_code=204)
diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py
index e2bf905..e3ddbc0 100644
--- a/backend/routers/groups_router.py
+++ b/backend/routers/groups_router.py
@@ -16,6 +16,7 @@ from pydantic import BaseModel
from sqlalchemy import or_
from sqlalchemy.orm import Session
+import attachments_store
import models
from auth import get_current_user
from database import get_db
@@ -311,12 +312,15 @@ def delete_group(
_require_owner(db, group, current_user)
# Remove the group calendar (and its events) too.
gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first()
+ stale = []
if gc:
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first()
if cal:
+ stale = attachments_store.purge_for_calendars(db, [cal.id])
db.delete(cal) # cascades to events
db.delete(group) # cascades to members + group_calendar link
db.commit()
+ attachments_store.unlink_all(stale)
return {"ok": True}
diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py
index 923339f..407f1d2 100644
--- a/backend/routers/local_router.py
+++ b/backend/routers/local_router.py
@@ -7,6 +7,7 @@ from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
+import attachments_store
import dav_util
import ical_io
import models
@@ -391,8 +392,10 @@ def delete_calendar(
)
if not cal:
raise HTTPException(404, "Calendar not found")
+ stale = attachments_store.purge_for_calendars(db, [cal.id])
db.delete(cal)
db.commit()
+ attachments_store.unlink_all(stale)
return {"ok": True}
@@ -525,8 +528,12 @@ def delete_event(
):
ev = _writable_event(db, current_user, uid)
dav_util.bump_dav(ev.calendar)
+ # Drop the attachment rows here so their files can be named, then unlink
+ # only after the commit — a rollback must not leave rows without bytes.
+ stale = attachments_store.purge_for_events(db, [ev.id])
db.delete(ev)
db.commit()
+ attachments_store.unlink_all(stale)
return {"ok": True}
diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py
index ca24a97..51ba86d 100644
--- a/backend/routers/users_router.py
+++ b/backend/routers/users_router.py
@@ -5,6 +5,7 @@ from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
+import attachments_store
import models
from auth import get_current_admin, get_current_user, get_password_hash
from database import get_db
@@ -101,8 +102,18 @@ def delete_user(
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
+ # user -> calendars -> events -> attachments: the longest cascade chain, and
+ # the one where orphaned files would pile up unnoticed.
+ cal_ids = [
+ r[0]
+ for r in db.query(models.LocalCalendar.id)
+ .filter(models.LocalCalendar.user_id == user_id)
+ .all()
+ ]
+ stale = attachments_store.purge_for_calendars(db, cal_ids)
db.delete(user)
db.commit()
+ attachments_store.unlink_all(stale)
return {"ok": True}
diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py
new file mode 100644
index 0000000..41404a2
--- /dev/null
+++ b/backend/tests/test_attachments.py
@@ -0,0 +1,270 @@
+"""Event attachments: content validation, storage and cleanup."""
+
+import io
+import os
+import shutil
+import time
+
+import pytest
+from fastapi import HTTPException
+from PIL import Image
+
+import attachments_store
+import models
+from conftest import auth, create_user, register_admin
+from database import SessionLocal
+
+RANGE = {"start": "2026-06-01T00:00:00Z", "end": "2026-06-30T00:00:00Z"}
+
+
+@pytest.fixture(autouse=True)
+def clean_attachment_dir():
+ """conftest wipes the tables but not the files — without this the orphan
+ sweep test would trip over leftovers from earlier tests."""
+ for child in attachments_store.ATTACH_DIR.iterdir():
+ shutil.rmtree(child, ignore_errors=True) if child.is_dir() else child.unlink()
+ yield
+
+
+# ── Fixtures for file bytes ───────────────────────────────
+
+def png_bytes(size=(8, 8)) -> bytes:
+ buf = io.BytesIO()
+ Image.new("RGB", size, "red").save(buf, "PNG")
+ return buf.getvalue()
+
+
+PDF_BYTES = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n"
+TXT_BYTES = "Zugangsdaten: siehe Umschlag\n".encode("utf-8")
+
+
+# ── Helpers ───────────────────────────────────────────────
+
+def _make_calendar(client, token, name="Privat"):
+ r = client.post("/api/local/calendars", headers=auth(token),
+ json={"name": name, "color": "#4285f4"})
+ assert r.status_code == 200, r.text
+ return r.json()["id"]
+
+
+def _make_event(client, token, cal_id, title="Termin"):
+ r = client.post("/api/local/events", headers=auth(token), json={
+ "calendar_id": cal_id, "title": title,
+ "start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
+ })
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+def _attach_row(event_uid, event_id, raw=PDF_BYTES, content_type="application/pdf",
+ ext=".pdf", filename="Rechnung.pdf"):
+ """Insert an attachment row and write its bytes, bypassing the API.
+
+ Used by the cleanup tests, which must work before the upload endpoint
+ exists and must be able to build states the API would refuse to create.
+ """
+ stored = attachments_store.new_stored_name(ext)
+ attachments_store.write_file(stored, raw)
+ has_thumb = attachments_store.make_thumb(raw, content_type, stored)
+ db = SessionLocal()
+ try:
+ row = models.EventAttachment(
+ source="local", event_uid=event_uid, event_id=event_id,
+ filename=filename, stored_name=stored, content_type=content_type,
+ size_bytes=len(raw), has_thumb=has_thumb,
+ token=attachments_store.new_token(),
+ )
+ db.add(row)
+ db.commit()
+ return stored
+ finally:
+ db.close()
+
+
+def _row_count() -> int:
+ db = SessionLocal()
+ try:
+ return db.query(models.EventAttachment).count()
+ finally:
+ db.close()
+
+
+def _exists(stored_name) -> bool:
+ return attachments_store.path_for(stored_name).exists()
+
+
+# ── Content sniffing ──────────────────────────────────────
+
+@pytest.mark.parametrize("raw,name,expected", [
+ (PDF_BYTES, "x.pdf", "application/pdf"),
+ (png_bytes(), "x.png", "image/png"),
+ (TXT_BYTES, "notiz.txt", "text/plain"),
+ (b"a,b\n1,2", "liste.csv", "text/csv"),
+ (b"# Titel", "doku.md", "text/markdown"),
+])
+def test_sniff_accepts_allowed_types(raw, name, expected):
+ content_type, _ = attachments_store.sniff(raw, name)
+ assert content_type == expected
+
+
+@pytest.mark.parametrize("raw,name", [
+ (b'', "x.svg"),
+ (b"", "x.html"),
+ (b"print('hi')", "x.py"),
+ (bytes(range(256)), "x.bin"),
+ (b"plain text but no known extension", "readme"),
+])
+def test_sniff_rejects_everything_else(raw, name):
+ """SVG and HTML matter most here: both execute script in the app's origin,
+ where the session token lives in localStorage."""
+ with pytest.raises(HTTPException) as e:
+ attachments_store.sniff(raw, name)
+ assert e.value.status_code == 400
+
+
+def test_sniff_ignores_the_uploaded_name_for_binary_types():
+ """A PNG named .pdf is a PNG. The extension never overrides the bytes."""
+ content_type, ext = attachments_store.sniff(png_bytes(), "getarnt.pdf")
+ assert (content_type, ext) == ("image/png", ".png")
+
+
+def test_stored_name_never_contains_path_separators():
+ """The uploaded name is attacker-controlled, and on the CalDAV path so is
+ the event UID — neither may reach the filesystem."""
+ _, ext = attachments_store.sniff(PDF_BYTES, "../../../etc/passwd.pdf")
+ stored = attachments_store.new_stored_name(ext)
+ assert "/" not in stored and "\\" not in stored and ".." not in stored
+ path = attachments_store.path_for(stored)
+ assert attachments_store.ATTACH_DIR in path.parents
+
+
+def test_safe_filename_strips_directories_and_quotes():
+ assert attachments_store.safe_filename("../../etc/passwd", ".txt") == "passwd"
+ assert '"' not in attachments_store.safe_filename('a"b.pdf', ".pdf")
+ assert attachments_store.safe_filename("", ".pdf") == "anhang.pdf"
+
+
+def test_thumbnail_only_for_images():
+ stored = attachments_store.new_stored_name(".png")
+ attachments_store.write_file(stored, png_bytes((600, 400)))
+ assert attachments_store.make_thumb(png_bytes((600, 400)), "image/png", stored) is True
+ assert attachments_store.thumb_path_for(stored).exists()
+
+ stored_pdf = attachments_store.new_stored_name(".pdf")
+ assert attachments_store.make_thumb(PDF_BYTES, "application/pdf", stored_pdf) is False
+
+
+# ── Cleanup on every delete path ──────────────────────────
+
+def test_deleting_an_event_removes_rows_and_files(client):
+ token = register_admin(client)
+ cal_id = _make_calendar(client, token)
+ ev = _make_event(client, token, cal_id)
+ db = SessionLocal()
+ ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
+ db.close()
+
+ stored = _attach_row(ev["id"], ev_id)
+ assert _row_count() == 1 and _exists(stored)
+
+ r = client.delete(f"/api/local/events/{ev['id']}", headers=auth(token))
+ assert r.status_code == 200, r.text
+ assert _row_count() == 0
+ assert not _exists(stored)
+
+
+def test_deleting_a_calendar_removes_rows_and_files(client):
+ token = register_admin(client)
+ cal_id = _make_calendar(client, token)
+ ev = _make_event(client, token, cal_id)
+ db = SessionLocal()
+ ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
+ db.close()
+ stored = _attach_row(ev["id"], ev_id)
+
+ r = client.delete(f"/api/local/calendars/{cal_id}", headers=auth(token))
+ assert r.status_code == 200, r.text
+ assert _row_count() == 0
+ assert not _exists(stored)
+
+
+def test_deleting_a_user_removes_rows_and_files(client):
+ """The longest cascade: user -> calendars -> events -> attachments."""
+ admin = register_admin(client)
+ bob_id, bob = create_user(client, admin, "bob")
+ cal_id = _make_calendar(client, bob, "Bobs Kalender")
+ ev = _make_event(client, bob, cal_id)
+ db = SessionLocal()
+ ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
+ db.close()
+ stored = _attach_row(ev["id"], ev_id)
+
+ r = client.delete(f"/api/users/{bob_id}", headers=auth(admin))
+ assert r.status_code == 200, r.text
+ assert _row_count() == 0
+ assert not _exists(stored)
+
+
+# ── The background sweep ──────────────────────────────────
+
+def test_sweep_removes_rows_whose_event_is_gone(client):
+ token = register_admin(client)
+ cal_id = _make_calendar(client, token)
+ ev = _make_event(client, token, cal_id)
+ db = SessionLocal()
+ ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
+ db.close()
+ stored = _attach_row(ev["id"], ev_id)
+
+ # Delete the event behind the API's back, as a cascade that skipped the
+ # purge helper would.
+ db = SessionLocal()
+ db.query(models.LocalEvent).filter(models.LocalEvent.id == ev_id).delete()
+ db.commit()
+ db.close()
+
+ db = SessionLocal()
+ rows, files = attachments_store.sweep(db)
+ db.close()
+ assert rows == 1
+ assert not _exists(stored)
+
+
+def test_sweep_removes_files_with_no_row_but_respects_the_grace_period():
+ stored = attachments_store.new_stored_name(".pdf")
+ path = attachments_store.write_file(stored, PDF_BYTES)
+
+ # Fresh file, no row: must survive — an upload in flight writes the bytes
+ # before it commits the row.
+ db = SessionLocal()
+ rows, files = attachments_store.sweep(db)
+ db.close()
+ assert files == 0 and path.exists()
+
+ # Same file, aged past the grace window: now it is an orphan.
+ old = time.time() - attachments_store.SWEEP_GRACE_SECONDS - 60
+ os.utime(path, (old, old))
+ db = SessionLocal()
+ rows, files = attachments_store.sweep(db)
+ db.close()
+ assert files == 1 and not path.exists()
+
+
+def test_sweep_leaves_live_attachments_alone(client):
+ token = register_admin(client)
+ cal_id = _make_calendar(client, token)
+ ev = _make_event(client, token, cal_id)
+ db = SessionLocal()
+ ev_id = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first().id
+ db.close()
+ stored = _attach_row(ev["id"], ev_id)
+
+ old = time.time() - attachments_store.SWEEP_GRACE_SECONDS - 60
+ for p in (attachments_store.path_for(stored),):
+ os.utime(p, (old, old))
+
+ db = SessionLocal()
+ rows, files = attachments_store.sweep(db)
+ db.close()
+ assert (rows, files) == (0, 0)
+ assert _row_count() == 1 and _exists(stored)