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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
22a469d449
commit
0e8a1a6a3d
+5
-1
@@ -17,7 +17,7 @@ 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))
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -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(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"])
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -268,3 +268,196 @@ def test_sweep_leaves_live_attachments_alone(client):
|
|||||||
db.close()
|
db.close()
|
||||||
assert (rows, files) == (0, 0)
|
assert (rows, files) == (0, 0)
|
||||||
assert _row_count() == 1 and _exists(stored)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user