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
+293
View File
@@ -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
+41
View File
@@ -243,6 +243,47 @@ class LocalEvent(Base):
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")
creator = relationship("User") 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): class ICalSubscription(Base):
+7
View File
@@ -30,6 +30,7 @@ from fastapi.responses import RedirectResponse, Response
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import attachments_store
import dav_util import dav_util
import ical_io import ical_io
import models import models
@@ -357,6 +358,10 @@ def _handle_put(cal: models.LocalCalendar, resource: str, body: bytes, db: Sessi
ev.description = item.get("description") ev.description = item.get("description")
ev.rrule = item.get("rrule") ev.rrule = item.get("rrule")
ev.exdate = item.get("exdate") 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) dav_util.bump_dav(cal, ev)
db.commit() db.commit()
db.refresh(ev) db.refresh(ev)
@@ -368,8 +373,10 @@ def _handle_delete(cal: models.LocalCalendar, resource: str, db: Session) -> Res
if not ev: if not ev:
return Response(status_code=404) return Response(status_code=404)
dav_util.bump_dav(cal) dav_util.bump_dav(cal)
stale = attachments_store.purge_for_events(db, [ev.id])
db.delete(ev) db.delete(ev)
db.commit() db.commit()
attachments_store.unlink_all(stale)
return Response(status_code=204) return Response(status_code=204)
+4
View File
@@ -16,6 +16,7 @@ from pydantic import BaseModel
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import attachments_store
import models import models
from auth import get_current_user from auth import get_current_user
from database import get_db from database import get_db
@@ -311,12 +312,15 @@ def delete_group(
_require_owner(db, group, current_user) _require_owner(db, group, current_user)
# Remove the group calendar (and its events) too. # Remove the group calendar (and its events) too.
gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first() gc = db.query(models.GroupCalendar).filter(models.GroupCalendar.group_id == group_id).first()
stale = []
if gc: if gc:
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first() cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == gc.calendar_id).first()
if cal: if cal:
stale = attachments_store.purge_for_calendars(db, [cal.id])
db.delete(cal) # cascades to events db.delete(cal) # cascades to events
db.delete(group) # cascades to members + group_calendar link db.delete(group) # cascades to members + group_calendar link
db.commit() db.commit()
attachments_store.unlink_all(stale)
return {"ok": True} return {"ok": True}
+7
View File
@@ -7,6 +7,7 @@ from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import attachments_store
import dav_util import dav_util
import ical_io import ical_io
import models import models
@@ -391,8 +392,10 @@ def delete_calendar(
) )
if not cal: if not cal:
raise HTTPException(404, "Calendar not found") raise HTTPException(404, "Calendar not found")
stale = attachments_store.purge_for_calendars(db, [cal.id])
db.delete(cal) db.delete(cal)
db.commit() db.commit()
attachments_store.unlink_all(stale)
return {"ok": True} return {"ok": True}
@@ -525,8 +528,12 @@ def delete_event(
): ):
ev = _writable_event(db, current_user, uid) ev = _writable_event(db, current_user, uid)
dav_util.bump_dav(ev.calendar) 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.delete(ev)
db.commit() db.commit()
attachments_store.unlink_all(stale)
return {"ok": True} return {"ok": True}
+11
View File
@@ -5,6 +5,7 @@ from pydantic import BaseModel
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import attachments_store
import models import models
from auth import get_current_admin, get_current_user, get_password_hash from auth import get_current_admin, get_current_user, get_password_hash
from database import get_db from database import get_db
@@ -101,8 +102,18 @@ def delete_user(
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(models.User.id == user_id).first()
if not user: if not user:
raise HTTPException(404, "User not found") 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.delete(user)
db.commit() db.commit()
attachments_store.unlink_all(stale)
return {"ok": True} return {"ok": True}
+270
View File
@@ -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'<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>', "x.svg"),
(b"<!doctype html><html><script>alert(1)</script></html>", "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)