fix: burn file items after download, not metadata fetch

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
肉末
2026-07-14 12:48:42 +08:00
parent eda7010cae
commit f2a1f3a1d6
3 changed files with 51 additions and 7 deletions
+17
View File
@@ -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,
)
+14 -7
View File
@@ -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
+20
View File
@@ -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