fix: burn file items after download, not metadata fetch
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user