From f2a1f3a1d6ec2f762ba83c3e5de149cff2b30719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=89=E6=9C=AB?= Date: Tue, 14 Jul 2026 12:48:42 +0800 Subject: [PATCH] fix: burn file items after download, not metadata fetch Co-authored-by: Cursor --- backend/app/api/public.py | 17 +++++++++++++++++ backend/app/services/items.py | 21 ++++++++++++++------- backend/tests/test_items.py | 20 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 7ff8dc7..5d260d4 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -1,8 +1,11 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse from sqlalchemy.orm import Session +from starlette.background import BackgroundTask +from app import db as db_module from app.db import get_db +from app.models import Item from app.schemas import ItemCreate, ItemOut, TTLOption, WallResponse from app.services import items as items_service from app.services import storage as storage_service @@ -72,6 +75,16 @@ def wall( ) +def _burn_file_after_response(item_id: int) -> None: + db = db_module.SessionLocal() + try: + item = db.get(Item, item_id) + if item is not None and item.burn_after_read and not item.burned: + items_service.burn_file_item(db, item) + finally: + db.close() + + @router.get("/{slug}/file") def download_file(slug: str, db: Session = Depends(get_db)): item = items_service.get_file_item(db, slug) @@ -84,11 +97,15 @@ def download_file(slug: str, db: Session = Depends(get_db)): media_type = item.mime or "application/octet-stream" filename = item.file_name or path.name inline = media_type.startswith(_INLINE_IMAGE_PREFIXES) + background = None + if item.burn_after_read: + background = BackgroundTask(_burn_file_after_response, item.id) return FileResponse( path, media_type=media_type, filename=filename, content_disposition_type="inline" if inline else "attachment", + background=background, ) diff --git a/backend/app/services/items.py b/backend/app/services/items.py index c0f6843..eff3793 100644 --- a/backend/app/services/items.py +++ b/backend/app/services/items.py @@ -134,20 +134,27 @@ def get_file_item(db: Session, slug: str) -> Item | None: return item +def burn_file_item(db: Session, item: Item) -> None: + """Mark a file item burned and remove its bytes from disk.""" + item.burned = True + path = resolve_file_path(item) + db.commit() + if path is not None: + try: + path.unlink(missing_ok=True) + except OSError: + pass + + def get_item(db: Session, slug: str) -> Item | None: item = db.scalar(select(Item).where(Item.slug == slug)) if item is None or _is_unavailable(item): return None item.view_count += 1 - if item.burn_after_read: + # File items burn on download, not metadata fetch. + if item.burn_after_read and item.kind != "file": item.burned = True - path = resolve_file_path(item) - if path is not None: - try: - path.unlink(missing_ok=True) - except OSError: - pass db.commit() db.refresh(item) return item diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py index 19fff0d..c83f42a 100644 --- a/backend/tests/test_items.py +++ b/backend/tests/test_items.py @@ -99,3 +99,23 @@ def test_reject_disallowed_extension(client): data={"is_public": "true", "ttl": "24h"}, ) assert r.status_code == 400 + + +def test_file_burn_after_download_not_metadata(client): + files = {"file": ("secret.txt", b"top-secret", "text/plain")} + data = {"is_public": "true", "ttl": "24h", "burn_after_read": "true"} + r = client.post("/api/items/upload", files=files, data=data) + assert r.status_code == 200 + slug = r.json()["slug"] + + meta = client.get(f"/api/items/{slug}") + assert meta.status_code == 200 + assert meta.json()["file_name"] == "secret.txt" + assert meta.json()["burn_after_read"] is True + + first = client.get(f"/api/items/{slug}/file") + assert first.status_code == 200 + assert first.content == b"top-secret" + + assert client.get(f"/api/items/{slug}/file").status_code == 404 + assert client.get(f"/api/items/{slug}").status_code == 404