feat: burn-after-read and expiry cleaner
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+23
-1
@@ -1,11 +1,25 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.public import router as public_router
|
||||
from app.config import settings
|
||||
from app.db import Base, engine
|
||||
from app.db import Base, SessionLocal, engine
|
||||
from app import models # noqa: F401 — register models with Base.metadata
|
||||
from app.services import cleaner as cleaner_service
|
||||
|
||||
|
||||
async def _cleaner_loop() -> None:
|
||||
while True:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cleaner_service.run_once(db)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -13,7 +27,15 @@ async def lifespan(_app: FastAPI):
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
task = asyncio.create_task(_cleaner_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="MincedPad", lifespan=lifespan)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Periodic cleanup of expired and burned items."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Item
|
||||
from app.services import items as items_service
|
||||
|
||||
|
||||
def run_once(db: Session, *, now: datetime | None = None) -> int:
|
||||
"""Delete expired rows (+ files) and burned rows (leftover files).
|
||||
|
||||
Returns the number of item rows removed.
|
||||
"""
|
||||
now = now or datetime.utcnow()
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(Item).where(
|
||||
or_(
|
||||
Item.burned.is_(True),
|
||||
(Item.expires_at.is_not(None) & (Item.expires_at <= now)),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for item in rows:
|
||||
items_service.delete_item_file(item)
|
||||
db.delete(item)
|
||||
if rows:
|
||||
db.commit()
|
||||
return len(rows)
|
||||
@@ -199,3 +199,75 @@ def test_banned_ip_cannot_create(client):
|
||||
|
||||
r = client.post("/api/items", json={"body": "x"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_burn_after_read(client):
|
||||
r = client.post(
|
||||
"/api/items",
|
||||
json={"body": "once", "burn_after_read": True, "ttl": "never"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
slug = r.json()["slug"]
|
||||
assert client.get(f"/api/items/{slug}").status_code == 200
|
||||
assert client.get(f"/api/items/{slug}").status_code == 404
|
||||
|
||||
|
||||
def test_cleaner_removes_expired(client):
|
||||
import app.db as db_module
|
||||
from app.models import Item
|
||||
from app.services import cleaner
|
||||
from sqlalchemy import select
|
||||
|
||||
r = client.post("/api/items", json={"body": "old", "ttl": "never"})
|
||||
assert r.status_code == 200
|
||||
slug = r.json()["slug"]
|
||||
|
||||
db = db_module.SessionLocal()
|
||||
try:
|
||||
item = db.scalar(select(Item).where(Item.slug == slug))
|
||||
assert item is not None
|
||||
item.expires_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
n = cleaner.run_once(db)
|
||||
assert n >= 1
|
||||
assert db.scalar(select(Item).where(Item.slug == slug)) is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert client.get(f"/api/items/{slug}").status_code == 404
|
||||
|
||||
|
||||
def test_cleaner_removes_burned_leftover_files(client, monkeypatch):
|
||||
import app.db as db_module
|
||||
import app.api.public as public_api
|
||||
from app.models import Item
|
||||
from app.services import cleaner
|
||||
from app.services import items as items_service
|
||||
from sqlalchemy import select
|
||||
|
||||
monkeypatch.setattr(public_api, "_delete_file_path", lambda *_a, **_k: None)
|
||||
|
||||
files = {"file": ("gone.txt", b"bytes", "text/plain")}
|
||||
r = client.post(
|
||||
"/api/items/upload",
|
||||
files=files,
|
||||
data={"is_public": "true", "ttl": "never", "burn_after_read": "true"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
slug = r.json()["slug"]
|
||||
assert client.get(f"/api/items/{slug}/file").status_code == 200
|
||||
|
||||
db = db_module.SessionLocal()
|
||||
try:
|
||||
item = db.scalar(select(Item).where(Item.slug == slug))
|
||||
assert item is not None
|
||||
path = items_service.resolve_file_path(item)
|
||||
assert path is not None and path.is_file()
|
||||
assert item.burned is True
|
||||
|
||||
cleaner.run_once(db)
|
||||
assert not path.exists()
|
||||
assert db.scalar(select(Item).where(Item.slug == slug)) is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user