Files
Calendarr/backend/attachments_store.py
ScarriffleandClaude Opus 5 a024fa54b9 feat(anhaenge): ATTACH-Zeile im erzeugten ICS
Jeder Anhang wird als ATTACH mit FMTTYPE, SIZE und FILENAME ausgegeben,
als URI statt inline base64 -- bei 10 MB je Datei wuerde sonst bei jeder
Synchronisation der ganze Anhang im ICS-Rumpf mitgeschleppt.

Die absolute URL kommt aus dav_util.public_base, das PUBLIC_BASE_URL und
die Forwarded-Header schon behandelt; ohne Basis-URL wird die Zeile
weggelassen, weil eine relative ATTACH-URL einem externen Client nichts
nuetzt. Abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.

Der CalDAV-PUT ist geprueft: ein externer Client, der den Termin
zurueckschreibt, laesst die Anhaenge unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:19:14 +02:00

317 lines
11 KiB
Python

"""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 os
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
# Deployments that would rather lose attachments in external calendar clients
# than hand out unauthenticated URLs set this to 0: no ATTACH line is emitted
# and the capability endpoint 404s.
PUBLIC_LINKS_ENABLED = os.environ.get("ATTACHMENT_PUBLIC_LINKS", "1") != "0"
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 by_event(db: Session, event_ids: Iterable[int]) -> dict[int, list]:
"""Attachments grouped by event id, in ONE query — for ICS generation."""
ids = [i for i in event_ids if i is not None]
if not ids:
return {}
out: dict[int, list] = {}
rows = (
db.query(models.EventAttachment)
.filter(models.EventAttachment.event_id.in_(ids))
.order_by(models.EventAttachment.id)
.all()
)
for row in rows:
out.setdefault(row.event_id, []).append(row)
return out
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