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:
co-authored by
Claude Opus 5
parent
6e981e7aea
commit
22a469d449
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user