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)