Compare commits

..
4 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 4b9c09df74 feat(scheduler): Hintergrund-Jobs im Serverprozess
Das Backend hatte bisher keinerlei Hintergrund-Mechanik. Neues Modul mit
einer Job-Registry, angebunden ueber den FastAPI-Lifespan. Der Server
laeuft als genau ein Prozess, ein In-Process-Loop laeuft damit garantiert
nur einmal -- steht als Vorbehalt im Modulkopf, falls spaeter Worker
dazukommen.

Jeder Job laeuft im Thread mit eigener Session, weil SQLAlchemy hier
synchron ist und Datei- oder Netz-I/O sonst den Event-Loop blockiert.
Fehler werden pro Job gefangen.

Zwei Jobs: der Anhang-Sweep alle 24 h und die iCal-Aktualisierung alle
15 min. Letztere treibt nur den vorhandenen _refresh_if_needed an, der das
Intervall je Abo schon respektiert -- neu ist allein, dass Abos auch
aktuell bleiben, wenn niemand die App offen hat. Jedes Abo einzeln
gekapselt, weil der Helfer ohne Cache weiterwirft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:14:28 +02:00
ScarriffleandClaude Opus 5 b885e731ee feat(anhaenge): attachment_count im Termin-JSON
Die Terminliste traegt nur die Anzahl, nicht die Anhaenge selbst. Der
Merge-Read laeuft pro Termin durch eine Schleife -- eine Lazy-Relationship
waere dort eine Abfrage je Termin ueber eine ganze Monatsansicht, und ein
wiederkehrender Termin wuerde dieselbe Liste an jeder Instanz wiederholen.
Stattdessen je Kalender eine gruppierte Abfrage; die Liste holen die
Clients beim Oeffnen der Detailansicht.

mask_busy_event setzt die Zahl ausdruecklich auf 0: dass ein als
"Beschaeftigt" maskierter Termin drei Dateien haette, ist auch eine
Information.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:11:30 +02:00
ScarriffleandClaude Opus 5 0e8a1a6a3d feat(anhaenge): Endpunkte zum Hochladen, Abrufen und Loeschen
Neuer Router unter /api/local mit Upload, Liste, Download, Thumbnail und
Loeschen, dazu permissions.readable_local_event als fehlendes Gegenstueck
zu _writable_event.

Der Lese-Helfer prueft nicht nur den Kalender, sondern auch die
Privatsphaere: ein fremder privater Termin wird im Lesepfad maskiert, also
duerfen auch seine Anhaenge nicht ueber die UID erreichbar sein. Fremde
bekommen durchgaengig 404 statt 403, damit die Existenz nicht durchsickert.

Die Mengenbegrenzung zaehlt nach dem Insert innerhalb derselben
Transaktion -- zaehlen-dann-einfuegen waere ein Wettlauf, bei dem zwei
gleichzeitige Uploads beide 9 sehen.

Ausgeliefert wird immer als Download mit nosniff, nie inline. Dazu die
Capability-URL unter /api/attach/<token> fuer externe CalDAV-Clients,
abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:08:47 +02:00
ScarriffleandClaude Opus 5 22a469d449 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>
2026-09-14 10:04:44 +02:00
14 changed files with 1494 additions and 10 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
+13 -3
View File
@@ -71,6 +71,9 @@ def mask_busy_event(event: dict) -> dict:
masked["rrule"] = None masked["rrule"] = None
masked["exdate"] = None masked["exdate"] = None
masked["private"] = True masked["private"] = True
# "This busy block has 3 files" is a small but real leak, and _BUSY_KEEP
# would drop the key entirely — set it so clients get a defined value.
masked["attachment_count"] = 0
return masked return masked
@@ -103,6 +106,7 @@ def build_local_event_dict(
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False, read_only: bool = False,
attachment_count: int = 0,
) -> dict: ) -> dict:
"""Build the unified dict for a single local event (or occurrence). """Build the unified dict for a single local event (or occurrence).
@@ -131,6 +135,11 @@ def build_local_event_dict(
"creator": creator, "creator": creator,
"private": bool(ev.is_private), "private": bool(ev.is_private),
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()], "reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
# Only a COUNT, never the list: the merged read loops over every event,
# so a lazy relationship here would be one query per event across a
# whole month, and a recurring event would repeat the same array on
# every occurrence. Clients fetch the list when a detail view opens.
"attachment_count": attachment_count,
} }
# Birthday calendars: the server owns the presentation. It flags the event so # Birthday calendars: the server owns the presentation. It flags the event so
# clients can show a cake icon, appends the age computed for THIS occurrence # clients can show a cake icon, appends the age computed for THIS occurrence
@@ -170,6 +179,7 @@ def expand_recurring_local(
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False, read_only: bool = False,
attachment_count: int = 0,
) -> list: ) -> list:
"""Expand a recurring LocalEvent into individual occurrences in the range.""" """Expand a recurring LocalEvent into individual occurrences in the range."""
results = [] results = []
@@ -201,7 +211,7 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only, read_only=read_only, attachment_count=attachment_count,
)) ))
else: else:
ev_start = dt_datetime.fromisoformat(ev_start_str) ev_start = dt_datetime.fromisoformat(ev_start_str)
@@ -228,13 +238,13 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only, read_only=read_only, attachment_count=attachment_count,
)) ))
except Exception as exc: except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to a single event. # Fall back to a single event.
results.append(build_local_event_dict( results.append(build_local_event_dict(
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only, read_only=read_only, attachment_count=attachment_count,
)) ))
return results return results
+8 -2
View File
@@ -16,8 +16,9 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
import scheduler
from database import Base, engine from database import Base, engine
from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_router, profile_router, settings_router, users_router from routers import admin_router, attachments_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -384,7 +385,8 @@ def _migrate():
_migrate() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None,
lifespan=scheduler.lifespan)
@app.middleware("http") @app.middleware("http")
@@ -426,6 +428,10 @@ app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"]) app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(attachments_router.router, prefix="/api/local", tags=["attachments"])
# Root scope: the capability URL carries its own /api/attach prefix so the
# SPA catch-all below does not swallow it.
app.include_router(attachments_router.public_router, tags=["attachments"])
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"]) app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"]) app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
+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):
+22
View File
@@ -82,6 +82,28 @@ def accessible_local_calendar(
return cal return cal
def readable_local_event(db: Session, user: models.User, uid: str) -> models.LocalEvent:
"""Return the event if the user may READ it, else raise 404.
The read-side mirror of local_router._writable_event. Beyond the calendar
check it enforces the private-event rule: the merge read hides or busy-masks
another user's private events, so anything hanging off such an event must
not be reachable by uid either — otherwise a share recipient could read the
attachments of events whose very title is deliberately withheld from them.
Always 404, never 403: a user who may not see the event may not learn that
it exists.
"""
ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
if not ev:
raise HTTPException(404, "Event not found")
cal = accessible_local_calendar(db, user, ev.calendar_id)
owner_id = ev.creator_id or cal.user_id
if ev.is_private and owner_id != user.id:
raise HTTPException(404, "Event not found")
return ev
def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar: def is_calendar_owner(db: Session, user: models.User, calendar_id: int) -> models.LocalCalendar:
"""Return the calendar only if the user owns it, else raise 404.""" """Return the calendar only if the user owns it, else raise 404."""
cal = ( cal = (
+273
View File
@@ -0,0 +1,273 @@
"""Upload, listing, download and deletion of event attachments.
Two routers live here:
``router`` mounted under /api/local, authenticated like the rest of the
API. Everything the web and mobile clients use.
``public_router`` mounted at root scope, NOT authenticated. Serves one
attachment by its capability token so external CalDAV clients
can follow the ATTACH URL in a generated ICS — they cannot
send a bearer token. See the README for the tradeoff.
"""
import os
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
import attachments_store
import dav_util
import models
import permissions
from auth import get_current_user
from database import get_db
router = APIRouter()
public_router = APIRouter()
# Deployments that would rather lose attachments in external calendar clients
# than hand out unauthenticated URLs can set this to 0: the capability endpoint
# then 404s and no ATTACH line is emitted into any ICS.
PUBLIC_LINKS_ENABLED = os.environ.get("ATTACHMENT_PUBLIC_LINKS", "1") != "0"
def _to_dict(att: models.EventAttachment) -> dict:
uploader = None
if att.uploader is not None:
uploader = {
"id": att.uploader.id,
"display_name": att.uploader.display_name or att.uploader.username,
}
return {
"id": att.id,
"filename": att.filename,
"content_type": att.content_type,
"size_bytes": att.size_bytes,
"has_thumb": bool(att.has_thumb),
"created_at": att.created_at.isoformat() if att.created_at else None,
"uploaded_by": uploader,
"url": f"/api/local/attachments/{att.id}",
"thumb_url": f"/api/local/attachments/{att.id}/thumb" if att.has_thumb else None,
}
def _writable_event(db: Session, user: models.User, uid: str) -> models.LocalEvent:
"""Attaching a file is editing the event, so it needs write on its calendar."""
ev = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first()
if not ev:
raise HTTPException(404, "Event not found")
permissions.accessible_local_calendar(db, user, ev.calendar_id, require_write=True)
return ev
def _attachment_for_read(db: Session, user: models.User, att_id: int) -> models.EventAttachment:
att = (
db.query(models.EventAttachment)
.filter(models.EventAttachment.id == att_id)
.first()
)
if not att:
raise HTTPException(404, "Attachment not found")
permissions.readable_local_event(db, user, att.event_uid)
return att
def _attachment_for_write(db: Session, user: models.User, att_id: int) -> models.EventAttachment:
att = (
db.query(models.EventAttachment)
.filter(models.EventAttachment.id == att_id)
.first()
)
if not att:
raise HTTPException(404, "Attachment not found")
_writable_event(db, user, att.event_uid)
return att
def _download_response(att: models.EventAttachment) -> FileResponse:
path = attachments_store.path_for(att.stored_name)
if not path.exists():
# Row without bytes: the sweep will reconcile it on its next run.
raise HTTPException(404, "Attachment not found")
# RFC 6266: an ASCII fallback plus the real UTF-8 name, so umlauts survive.
ascii_name = "".join(
c for c in att.filename if c.isalnum() or c in " -_."
).strip() or "anhang"
disposition = (
f'attachment; filename="{ascii_name}"; '
f"filename*=UTF-8''{quote(att.filename)}"
)
return FileResponse(
str(path),
media_type=att.content_type,
headers={
# Always "attachment", never "inline": these are user-supplied bytes
# served from the app's own origin, where the session token lives.
"Content-Disposition": disposition,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
"Cache-Control": "private, max-age=0, must-revalidate",
},
)
# ── Authenticated API ─────────────────────────────────────
@router.get("/events/{uid}/attachments")
def list_attachments(
uid: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ev = permissions.readable_local_event(db, current_user, uid)
rows = (
db.query(models.EventAttachment)
.filter(models.EventAttachment.event_id == ev.id)
.order_by(models.EventAttachment.id)
.all()
)
return [_to_dict(a) for a in rows]
@router.post("/events/{uid}/attachments", status_code=201)
async def upload_attachment(
uid: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Read one byte past the cap so an oversized upload is refused without ever
# buffering it whole.
raw = await file.read(attachments_store.MAX_ATTACHMENT_BYTES + 1)
if len(raw) > attachments_store.MAX_ATTACHMENT_BYTES:
raise HTTPException(413, "Datei zu groß (max. 10 MB)")
if not raw:
raise HTTPException(400, "Leere Datei")
content_type, ext = attachments_store.sniff(raw, file.filename or "")
ev = _writable_event(db, current_user, uid)
stored_name = attachments_store.new_stored_name(ext)
att = models.EventAttachment(
source="local",
event_uid=ev.uid,
event_id=ev.id,
filename=attachments_store.safe_filename(file.filename or "", ext),
stored_name=stored_name,
content_type=content_type,
size_bytes=len(raw),
has_thumb=False,
token=attachments_store.new_token(),
uploaded_by=current_user.id,
)
db.add(att)
# Insert first, then count inside the same transaction. A count-then-insert
# would be a TOCTOU: two uploads both see 9 and you end up with 11. SQLite
# takes a write lock on the first INSERT, so this count is serialised and
# includes our own row.
db.flush()
n = (
db.query(func.count(models.EventAttachment.id))
.filter(models.EventAttachment.event_id == ev.id)
.scalar()
)
if n > attachments_store.MAX_ATTACHMENTS_PER_EVENT:
db.rollback()
raise HTTPException(
409,
f"Maximal {attachments_store.MAX_ATTACHMENTS_PER_EVENT} Anhänge pro Termin",
)
try:
attachments_store.write_file(stored_name, raw)
# Pillow on a 10 MB image is CPU-bound; keep it off the event loop.
att.has_thumb = await run_in_threadpool(
attachments_store.make_thumb, raw, content_type, stored_name
)
# The generated ICS gains an ATTACH line, so CalDAV clients must be told
# the event changed or they will never re-fetch it.
dav_util.bump_dav(ev.calendar, ev)
db.commit()
except Exception:
db.rollback()
attachments_store.unlink_all([
attachments_store.path_for(stored_name),
attachments_store.thumb_path_for(stored_name),
])
raise
db.refresh(att)
return _to_dict(att)
@router.get("/attachments/{att_id}")
def download_attachment(
att_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
return _download_response(_attachment_for_read(db, current_user, att_id))
@router.get("/attachments/{att_id}/thumb")
def attachment_thumbnail(
att_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
att = _attachment_for_read(db, current_user, att_id)
path = attachments_store.thumb_path_for(att.stored_name)
if not att.has_thumb or not path.exists():
raise HTTPException(404, "No thumbnail")
return FileResponse(
str(path),
media_type="image/jpeg",
headers={
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=0, must-revalidate",
},
)
@router.delete("/attachments/{att_id}")
def delete_attachment(
att_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
att = _attachment_for_write(db, current_user, att_id)
ev = db.query(models.LocalEvent).filter(models.LocalEvent.id == att.event_id).first()
stale = attachments_store.files_of(att)
db.delete(att)
if ev is not None:
dav_util.bump_dav(ev.calendar, ev)
db.commit()
attachments_store.unlink_all(stale)
return {"ok": True}
# ── Capability URL for external CalDAV clients ────────────
@public_router.get("/api/attach/{token}")
@public_router.get("/api/attach/{token}/{name}")
def public_attachment(token: str, name: str = "", db: Session = Depends(get_db)):
"""Serve one attachment by its unguessable token, without authentication.
Apple Calendar, Thunderbird and DAVx5 fetch an ICS ATTACH URL with a plain
GET and no credentials, so this is the only shape that works for them. The
trailing name is cosmetic — clients show it, the lookup ignores it.
Never distinguish "wrong token" from "deleted": both are 404.
"""
if not PUBLIC_LINKS_ENABLED:
raise HTTPException(404, "Not found")
att = (
db.query(models.EventAttachment)
.filter(models.EventAttachment.token == token)
.first()
)
if not att:
raise HTTPException(404, "Not found")
return _download_response(att)
+12 -2
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
import attachments_store
import caldav_client import caldav_client
import models import models
import permissions import permissions
@@ -400,6 +401,9 @@ def get_events(
) )
.all() .all()
) )
# One grouped query for the whole calendar: a per-event lookup here
# would be N+1 across a month view.
att_counts = attachments_store.counts_for_events(db, [e.id for e in local_events])
for ev in local_events: for ev in local_events:
creator = resolve_creator(ev, name_cache=name_cache) creator = resolve_creator(ev, name_cache=name_cache)
# A private event belonging to someone else (shared calendar or group # A private event belonging to someone else (shared calendar or group
@@ -413,9 +417,15 @@ def get_events(
if foreign_private and visibility == "hidden": if foreign_private and visibility == "hidden":
continue continue
if ev.rrule: if ev.rrule:
built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator) built = expand_recurring_local(
ev, local_cal, start_dt, end_dt, creator=creator,
attachment_count=att_counts.get(ev.id, 0),
)
else: else:
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)] built = [build_local_event_dict(
ev, local_cal, rrule=None, creator=creator,
attachment_count=att_counts.get(ev.id, 0),
)]
for b in built: for b in built:
if shared_owner_name: if shared_owner_name:
b["calendar_name"] = shared_owner_name b["calendar_name"] = shared_owner_name
+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)
+7 -2
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}
@@ -394,6 +398,7 @@ def combined_events(
) )
.all() .all()
) )
att_counts = attachments_store.counts_for_events(db, [e.id for e in events])
for ev in events: for ev in events:
creator_owner_id = ev.creator_id or owner_id creator_owner_id = ev.creator_id or owner_id
# Private filtering for events that belong to someone else. # Private filtering for events that belong to someone else.
@@ -408,9 +413,9 @@ def combined_events(
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
if ev.rrule: if ev.rrule:
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only) built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only, attachment_count=att_counts.get(ev.id, 0))
else: else:
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)] built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only, attachment_count=att_counts.get(ev.id, 0))]
for b in built: for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
+12 -1
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
@@ -109,7 +110,11 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict: def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict:
return build_local_event_dict(ev, cal, creator=resolve_creator(ev)) counts = attachments_store.counts_for_events(db, [ev.id])
return build_local_event_dict(
ev, cal, creator=resolve_creator(ev),
attachment_count=counts.get(ev.id, 0),
)
# ── Calendar CRUD ───────────────────────────────────────── # ── Calendar CRUD ─────────────────────────────────────────
@@ -391,8 +396,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 +532,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}
+157
View File
@@ -0,0 +1,157 @@
"""A small in-process scheduler for periodic maintenance jobs.
The server runs as a single uvicorn process (``main.py`` calls ``uvicorn.run``
with no ``workers=``), so an in-process loop runs exactly once — no locking or
leader election needed. If the deployment ever grows to multiple workers this
has to be revisited, because every worker would run every job.
Jobs are plain synchronous callables taking a ``Session``. They run in a worker
thread with their own session: SQLAlchemy is synchronous here, and a job doing
file I/O or an HTTP fetch on the event loop would stall every request.
Failures are caught per job, so a broken iCal feed cannot stop the attachment
sweep and neither can stop the loop.
Environment:
SCHEDULER_ENABLED "0" disables the loop entirely (default on)
ATTACHMENT_SWEEP_HOURS how often orphaned attachments are cleaned (24)
ICAL_REFRESH_MINUTES how often subscriptions are checked (15)
"""
import asyncio
import contextlib
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Callable
from sqlalchemy.orm import Session
from database import SessionLocal
logger = logging.getLogger(__name__)
ENABLED = os.environ.get("SCHEDULER_ENABLED", "1") != "0"
# How often the loop wakes to see whether anything is due. Jobs have their own,
# much longer intervals; this only bounds how late a job can start.
TICK_SECONDS = 30
# Delay before the first run so a restart is not slowed by maintenance work.
FIRST_RUN_DELAY_SECONDS = 20
class _Job:
def __init__(self, name: str, interval: float, fn: Callable[[Session], None]):
self.name = name
self.interval = interval
self.fn = fn
self.next_run = time.monotonic() + FIRST_RUN_DELAY_SECONDS
_jobs: list[_Job] = []
def register(name: str, interval_seconds: float, fn: Callable[[Session], None]) -> None:
"""Add a job. Registering the same name twice replaces the first."""
_jobs[:] = [j for j in _jobs if j.name != name]
_jobs.append(_Job(name, interval_seconds, fn))
def _run_job(job: _Job) -> None:
"""Run one job in a worker thread, with its own session."""
db = SessionLocal()
try:
job.fn(db)
except Exception:
# Never let one job's failure escape: the loop must survive it, and so
# must the other jobs.
logger.exception("Scheduled job %s failed", job.name)
with contextlib.suppress(Exception):
db.rollback()
finally:
db.close()
async def _runner() -> None:
logger.info(
"Scheduler started with %d job(s): %s",
len(_jobs),
", ".join(j.name for j in _jobs),
)
while True:
await asyncio.sleep(TICK_SECONDS)
now = time.monotonic()
for job in list(_jobs):
if now < job.next_run:
continue
job.next_run = now + job.interval
await asyncio.to_thread(_run_job, job)
# ── The jobs ──────────────────────────────────────────────
def sweep_attachments(db: Session) -> None:
"""Remove attachment rows whose event is gone, and files with no row.
The explicit purge at each delete path is the normal mechanism; this is the
safety net for what slips past it — an external CalDAV client deleting an
event, a request that died between writing the file and committing the row,
or a delete path added later that forgets the helper.
"""
import attachments_store
attachments_store.sweep(db)
def refresh_ical_subscriptions(db: Session) -> None:
"""Re-fetch iCal subscriptions that are due.
``_refresh_if_needed`` already honours each subscription's own
``refresh_minutes`` and swallows network errors — except when a
subscription has no cached data at all, where it re-raises. So every
subscription is wrapped separately: one unreachable URL must not stop the
rest.
The lazy refresh in the merged read stays as it is. This only adds the case
nobody was covering: keeping subscriptions current while no one has the app
open.
"""
import models
from routers.ical_router import _refresh_if_needed
subs = (
db.query(models.ICalSubscription)
.filter(models.ICalSubscription.enabled == True) # noqa: E712
.all()
)
for sub in subs:
try:
_refresh_if_needed(sub, db)
except Exception:
logger.warning("Scheduled refresh of iCal subscription %s failed", sub.id)
with contextlib.suppress(Exception):
db.rollback()
def install_default_jobs() -> None:
sweep_hours = float(os.environ.get("ATTACHMENT_SWEEP_HOURS", "24"))
ical_minutes = float(os.environ.get("ICAL_REFRESH_MINUTES", "15"))
register("attachments.sweep", sweep_hours * 3600, sweep_attachments)
register("ical.refresh", ical_minutes * 60, refresh_ical_subscriptions)
@asynccontextmanager
async def lifespan(app):
task = None
if ENABLED:
install_default_jobs()
task = asyncio.create_task(_runner())
else:
logger.info("Scheduler disabled via SCHEDULER_ENABLED=0")
try:
yield
finally:
if task is not None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+518
View File
@@ -0,0 +1,518 @@
"""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)
# ── Upload / download through the API ─────────────────────
def _upload(client, token, uid, raw=PDF_BYTES, name="Rechnung.pdf", mime="application/pdf"):
return client.post(
f"/api/local/events/{uid}/attachments",
headers=auth(token),
files={"file": (name, raw, mime)},
)
def test_upload_list_and_download_roundtrip(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
r = _upload(client, token, ev["id"])
assert r.status_code == 201, r.text
body = r.json()
assert body["filename"] == "Rechnung.pdf"
assert body["content_type"] == "application/pdf"
assert body["has_thumb"] is False
assert body["uploaded_by"]["display_name"] == "admin"
listing = client.get(f"/api/local/events/{ev['id']}/attachments", headers=auth(token))
assert listing.status_code == 200
assert [a["id"] for a in listing.json()] == [body["id"]]
dl = client.get(f"/api/local/attachments/{body['id']}", headers=auth(token))
assert dl.status_code == 200
assert dl.content == PDF_BYTES
assert dl.headers["content-disposition"].startswith("attachment;")
assert dl.headers["x-content-type-options"] == "nosniff"
def test_image_upload_gets_a_thumbnail(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
r = _upload(client, token, ev["id"], png_bytes((640, 480)), "foto.png", "image/png")
assert r.status_code == 201, r.text
assert r.json()["has_thumb"] is True
thumb = client.get(f"/api/local/attachments/{r.json()['id']}/thumb", headers=auth(token))
assert thumb.status_code == 200
assert thumb.headers["content-type"] == "image/jpeg"
def test_declared_mime_is_ignored_in_favour_of_the_bytes(client):
"""A PNG announced as application/pdf is stored as a PNG."""
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
r = _upload(client, token, ev["id"], png_bytes(), "getarnt.pdf", "application/pdf")
assert r.status_code == 201, r.text
assert r.json()["content_type"] == "image/png"
def test_rejects_disallowed_type_and_writes_nothing(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
r = _upload(client, token, ev["id"], b"<svg/>", "x.svg", "image/svg+xml")
assert r.status_code == 400
assert _row_count() == 0
assert not any(p.is_file() for p in attachments_store.ATTACH_DIR.rglob("*"))
def test_rejects_oversized_file(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
big = b"%PDF-1.4\n" + b"x" * attachments_store.MAX_ATTACHMENT_BYTES
r = _upload(client, token, ev["id"], big, "gross.pdf")
assert r.status_code == 413
assert _row_count() == 0
def test_enforces_the_per_event_limit(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
for i in range(attachments_store.MAX_ATTACHMENTS_PER_EVENT):
assert _upload(client, token, ev["id"], name=f"a{i}.pdf").status_code == 201
r = _upload(client, token, ev["id"], name="zuviel.pdf")
assert r.status_code == 409
assert _row_count() == attachments_store.MAX_ATTACHMENTS_PER_EVENT
def test_deleting_an_attachment_removes_its_file(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
att_id = _upload(client, token, ev["id"]).json()["id"]
db = SessionLocal()
stored = db.query(models.EventAttachment).filter(
models.EventAttachment.id == att_id).first().stored_name
db.close()
r = client.delete(f"/api/local/attachments/{att_id}", headers=auth(token))
assert r.status_code == 200, r.text
assert _row_count() == 0
assert not _exists(stored)
# ── Permission matrix ─────────────────────────────────────
def _share(client, owner_token, cal_id, user_id, permission):
r = client.post(f"/api/local/calendars/{cal_id}/shares", headers=auth(owner_token),
json={"user_id": user_id, "permission": permission})
assert r.status_code == 200, r.text
def test_read_share_may_view_but_not_upload_or_delete(client):
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
ev = _make_event(client, admin, cal_id)
att_id = _upload(client, admin, ev["id"]).json()["id"]
_share(client, admin, cal_id, bob_id, "read")
assert client.get(f"/api/local/events/{ev['id']}/attachments",
headers=auth(bob)).status_code == 200
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(bob)).status_code == 200
assert _upload(client, bob, ev["id"], name="nope.pdf").status_code == 403
assert client.delete(f"/api/local/attachments/{att_id}",
headers=auth(bob)).status_code == 403
def test_read_write_share_may_upload_and_delete(client):
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
ev = _make_event(client, admin, cal_id)
att_id = _upload(client, admin, ev["id"]).json()["id"]
_share(client, admin, cal_id, bob_id, "read_write")
assert _upload(client, bob, ev["id"], name="bob.pdf").status_code == 201
# The stated rule: whoever may edit the event may remove anyone's attachment.
assert client.delete(f"/api/local/attachments/{att_id}",
headers=auth(bob)).status_code == 200
def test_unrelated_user_gets_404_not_403(client):
"""404 everywhere, so a stranger cannot even learn the event exists."""
admin = register_admin(client)
_, dave = create_user(client, admin, "dave")
ev = _make_event(client, admin, _make_calendar(client, admin))
att_id = _upload(client, admin, ev["id"]).json()["id"]
assert client.get(f"/api/local/events/{ev['id']}/attachments",
headers=auth(dave)).status_code == 404
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(dave)).status_code == 404
assert client.delete(f"/api/local/attachments/{att_id}",
headers=auth(dave)).status_code == 404
def test_private_event_hides_its_attachments_from_a_share_recipient(client):
"""The leak this feature could most easily introduce: the merge read masks
a foreign private event, so its attachments must be unreachable too."""
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
r = client.post("/api/local/events", headers=auth(admin), json={
"calendar_id": cal_id, "title": "Vertraulich", "private": True,
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
})
ev = r.json()
att_id = _upload(client, admin, ev["id"]).json()["id"]
_share(client, admin, cal_id, bob_id, "read_write")
assert client.get(f"/api/local/events/{ev['id']}/attachments",
headers=auth(bob)).status_code == 404
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(bob)).status_code == 404
# The owner still reaches their own private event's attachments.
assert client.get(f"/api/local/attachments/{att_id}", headers=auth(admin)).status_code == 200
# ── Capability URL ────────────────────────────────────────
def test_capability_url_serves_without_authentication(client):
token = register_admin(client)
ev = _make_event(client, token, _make_calendar(client, token))
att_id = _upload(client, token, ev["id"]).json()["id"]
db = SessionLocal()
cap = db.query(models.EventAttachment).filter(
models.EventAttachment.id == att_id).first().token
db.close()
r = client.get(f"/api/attach/{cap}/Rechnung.pdf") # no Authorization header
assert r.status_code == 200
assert r.content == PDF_BYTES
assert r.headers["content-disposition"].startswith("attachment;")
assert r.headers["x-content-type-options"] == "nosniff"
assert client.get("/api/attach/voellig-erfunden").status_code == 404
client.delete(f"/api/local/attachments/{att_id}", headers=auth(token))
assert client.get(f"/api/attach/{cap}").status_code == 404
# ── attachment_count in the merged event read ─────────────
def test_attachment_count_appears_on_the_merged_read(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
ev = _make_event(client, token, cal_id)
def count_for(uid):
events = client.get("/api/caldav/events", headers=auth(token),
params=RANGE).json()["events"]
return next(e["attachment_count"] for e in events if e["id"] == uid)
assert count_for(ev["id"]) == 0
_upload(client, token, ev["id"], name="a.pdf")
_upload(client, token, ev["id"], name="b.pdf")
assert count_for(ev["id"]) == 2
def test_recurring_event_carries_the_count_on_every_occurrence(client):
token = register_admin(client)
cal_id = _make_calendar(client, token)
r = client.post("/api/local/events", headers=auth(token), json={
"calendar_id": cal_id, "title": "Taeglich", "rrule": "FREQ=DAILY;COUNT=5",
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
})
ev = r.json()
_upload(client, token, ev["id"], name="a.pdf")
events = client.get("/api/caldav/events", headers=auth(token),
params=RANGE).json()["events"]
occurrences = [e for e in events if e["id"] == ev["id"]]
assert len(occurrences) == 5
assert all(e["attachment_count"] == 1 for e in occurrences)
def test_busy_masked_event_reports_no_attachments(client):
"""A count is metadata too: "this busy block has 3 files" would leak."""
admin = register_admin(client)
bob_id, bob = create_user(client, admin, "bob")
cal_id = _make_calendar(client, admin)
r = client.post("/api/local/events", headers=auth(admin), json={
"calendar_id": cal_id, "title": "Vertraulich", "private": True,
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
})
ev = r.json()
_upload(client, admin, ev["id"], name="geheim.pdf")
_share(client, admin, cal_id, bob_id, "read")
events = client.get("/api/caldav/events", headers=auth(bob),
params=RANGE).json()["events"]
masked = next(e for e in events if e["id"] == ev["id"])
assert masked["title"] == "Beschäftigt"
assert masked["attachment_count"] == 0
+120
View File
@@ -0,0 +1,120 @@
"""The in-process job scheduler and its two jobs."""
import time
import models
import scheduler
from conftest import auth, register_admin
from database import SessionLocal
def _run(fn):
db = SessionLocal()
try:
fn(db)
finally:
db.close()
def test_default_jobs_are_registered_with_sane_intervals():
scheduler.install_default_jobs()
names = {j.name: j for j in scheduler._jobs}
assert set(names) == {"attachments.sweep", "ical.refresh"}
assert names["attachments.sweep"].interval == 24 * 3600
assert names["ical.refresh"].interval == 15 * 60
# Nothing runs immediately: a restart must not be slowed by maintenance.
assert all(j.next_run > time.monotonic() for j in scheduler._jobs)
def test_registering_the_same_name_twice_replaces_it():
scheduler.register("demo", 60, lambda db: None)
scheduler.register("demo", 120, lambda db: None)
matching = [j for j in scheduler._jobs if j.name == "demo"]
assert len(matching) == 1 and matching[0].interval == 120
scheduler._jobs[:] = [j for j in scheduler._jobs if j.name != "demo"]
def test_a_failing_job_is_contained(caplog):
"""One broken job must not take down the loop or the other jobs."""
def boom(db):
raise RuntimeError("kaputt")
job = scheduler._Job("explodes", 60, boom)
scheduler._run_job(job) # must not raise
assert "explodes" in caplog.text
def test_sweep_job_cleans_an_orphaned_row(client):
"""End-to-end through the job function, not just the store helper."""
token = register_admin(client)
r = client.post("/api/local/calendars", headers=auth(token),
json={"name": "Privat", "color": "#4285f4"})
cal_id = r.json()["id"]
ev = client.post("/api/local/events", headers=auth(token), json={
"calendar_id": cal_id, "title": "Termin",
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
}).json()
client.post(f"/api/local/events/{ev['id']}/attachments", headers=auth(token),
files={"file": ("x.pdf", b"%PDF-1.4\ntrailer\n", "application/pdf")})
db = SessionLocal()
ev_row = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first()
db.query(models.LocalEvent).filter(models.LocalEvent.id == ev_row.id).delete()
db.commit()
db.close()
_run(scheduler.sweep_attachments)
db = SessionLocal()
assert db.query(models.EventAttachment).count() == 0
db.close()
def test_ical_job_survives_an_unreachable_subscription(client):
"""A subscription with no cached data makes _refresh_if_needed re-raise —
the job has to wrap each one separately or a single bad URL stops the rest."""
token = register_admin(client)
db = SessionLocal()
user = db.query(models.User).first()
db.add(models.ICalSubscription(
user_id=user.id, name="Kaputt",
url="http://127.0.0.1:9/nicht-erreichbar.ics",
color="#ea4335", enabled=True, refresh_minutes=60,
))
db.commit()
db.close()
_run(scheduler.refresh_ical_subscriptions) # must not raise
def test_the_loop_runs_due_jobs_and_stops_with_the_app(monkeypatch):
"""Drive the real lifespan with a fast tick and a probe job."""
from fastapi.testclient import TestClient
import main
monkeypatch.setattr(scheduler, "TICK_SECONDS", 0.02)
monkeypatch.setattr(scheduler, "FIRST_RUN_DELAY_SECONDS", 0)
ran = []
scheduler.register("probe", 3600, lambda db: ran.append(1))
try:
with TestClient(main.app): # entering runs the lifespan
deadline = time.time() + 3
while not ran and time.time() < deadline:
time.sleep(0.02)
assert ran, "the scheduler loop never ran the registered job"
finally:
scheduler._jobs[:] = [j for j in scheduler._jobs if j.name != "probe"]
def test_plain_test_client_starts_no_background_work():
"""conftest builds TestClient without a context manager, so the lifespan —
and with it the loop — never starts. Keeps the rest of the suite free of
background jobs touching the same database."""
from fastapi.testclient import TestClient
import main
before = len(scheduler._jobs)
TestClient(main.app)
assert len(scheduler._jobs) == before