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>
317 lines
11 KiB
Python
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
|