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 import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session 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.db import get_db
from app.models import Item
from app.schemas import ItemCreate, ItemOut, TTLOption, WallResponse from app.schemas import ItemCreate, ItemOut, TTLOption, WallResponse
from app.services import items as items_service from app.services import items as items_service
from app.services import storage as storage_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") @router.get("/{slug}/file")
def download_file(slug: str, db: Session = Depends(get_db)): def download_file(slug: str, db: Session = Depends(get_db)):
item = items_service.get_file_item(db, slug) 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" media_type = item.mime or "application/octet-stream"
filename = item.file_name or path.name filename = item.file_name or path.name
inline = media_type.startswith(_INLINE_IMAGE_PREFIXES) inline = media_type.startswith(_INLINE_IMAGE_PREFIXES)
background = None
if item.burn_after_read:
background = BackgroundTask(_burn_file_after_response, item.id)
return FileResponse( return FileResponse(
path, path,
media_type=media_type, media_type=media_type,
filename=filename, filename=filename,
content_disposition_type="inline" if inline else "attachment", 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 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: def get_item(db: Session, slug: str) -> Item | None:
item = db.scalar(select(Item).where(Item.slug == slug)) item = db.scalar(select(Item).where(Item.slug == slug))
if item is None or _is_unavailable(item): if item is None or _is_unavailable(item):
return None return None
item.view_count += 1 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 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.commit()
db.refresh(item) db.refresh(item)
return item return item
+20
View File
@@ -99,3 +99,23 @@ def test_reject_disallowed_extension(client):
data={"is_public": "true", "ttl": "24h"}, data={"is_public": "true", "ttl": "24h"},
) )
assert r.status_code == 400 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