diff --git a/backend/main.py b/backend/main.py index 2c57298..df49359 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" sys.path.insert(0, str(Path(__file__).parent)) 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) @@ -426,6 +426,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"]) diff --git a/backend/permissions.py b/backend/permissions.py index 270ba7e..d7aba3b 100644 --- a/backend/permissions.py +++ b/backend/permissions.py @@ -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 = ( diff --git a/backend/routers/attachments_router.py b/backend/routers/attachments_router.py new file mode 100644 index 0000000..cfc2590 --- /dev/null +++ b/backend/routers/attachments_router.py @@ -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) diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py index 41404a2..420090b 100644 --- a/backend/tests/test_attachments.py +++ b/backend/tests/test_attachments.py @@ -268,3 +268,196 @@ def test_sweep_leaves_live_attachments_alone(client): 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"", "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