Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9c09df74 | ||
|
|
b885e731ee | ||
|
|
0e8a1a6a3d | ||
|
|
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
|
||||
@@ -71,6 +71,9 @@ def mask_busy_event(event: dict) -> dict:
|
||||
masked["rrule"] = None
|
||||
masked["exdate"] = None
|
||||
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
|
||||
|
||||
|
||||
@@ -103,6 +106,7 @@ def build_local_event_dict(
|
||||
owner: Optional[dict] = None,
|
||||
is_group_event: bool = False,
|
||||
read_only: bool = False,
|
||||
attachment_count: int = 0,
|
||||
) -> dict:
|
||||
"""Build the unified dict for a single local event (or occurrence).
|
||||
|
||||
@@ -131,6 +135,11 @@ def build_local_event_dict(
|
||||
"creator": creator,
|
||||
"private": bool(ev.is_private),
|
||||
"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
|
||||
# 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,
|
||||
is_group_event: bool = False,
|
||||
read_only: bool = False,
|
||||
attachment_count: int = 0,
|
||||
) -> list:
|
||||
"""Expand a recurring LocalEvent into individual occurrences in the range."""
|
||||
results = []
|
||||
@@ -201,7 +211,7 @@ def expand_recurring_local(
|
||||
ev, local_cal,
|
||||
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
|
||||
creator=creator, owner=owner, is_group_event=is_group_event,
|
||||
read_only=read_only,
|
||||
read_only=read_only, attachment_count=attachment_count,
|
||||
))
|
||||
else:
|
||||
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
||||
@@ -228,13 +238,13 @@ def expand_recurring_local(
|
||||
ev, local_cal,
|
||||
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
|
||||
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:
|
||||
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
||||
# Fall back to a single event.
|
||||
results.append(build_local_event_dict(
|
||||
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
|
||||
|
||||
+8
-2
@@ -16,8 +16,9 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import scheduler
|
||||
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)
|
||||
|
||||
@@ -384,7 +385,8 @@ def _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")
|
||||
@@ -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(profile_router.router, prefix="/api/profile", tags=["profile"])
|
||||
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(groups_router.router, prefix="/api/groups", tags=["groups"])
|
||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -82,6 +82,28 @@ def accessible_local_calendar(
|
||||
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:
|
||||
"""Return the calendar only if the user owns it, else raise 404."""
|
||||
cal = (
|
||||
|
||||
@@ -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)
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
import attachments_store
|
||||
import caldav_client
|
||||
import models
|
||||
import permissions
|
||||
@@ -400,6 +401,9 @@ def get_events(
|
||||
)
|
||||
.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:
|
||||
creator = resolve_creator(ev, name_cache=name_cache)
|
||||
# A private event belonging to someone else (shared calendar or group
|
||||
@@ -413,9 +417,15 @@ def get_events(
|
||||
if foreign_private and visibility == "hidden":
|
||||
continue
|
||||
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:
|
||||
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:
|
||||
if shared_owner_name:
|
||||
b["calendar_name"] = shared_owner_name
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -394,6 +398,7 @@ def combined_events(
|
||||
)
|
||||
.all()
|
||||
)
|
||||
att_counts = attachments_store.counts_for_events(db, [e.id for e in events])
|
||||
for ev in events:
|
||||
creator_owner_id = ev.creator_id or owner_id
|
||||
# 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)"}
|
||||
|
||||
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:
|
||||
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:
|
||||
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
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 ─────────────────────────────────────────
|
||||
@@ -391,8 +396,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 +532,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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user