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 contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.api.public import router as public_router
|
from app.api.public import router as public_router
|
||||||
from app.config import settings
|
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 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
|
@asynccontextmanager
|
||||||
@@ -13,7 +27,15 @@ async def lifespan(_app: FastAPI):
|
|||||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.uploads_dir.mkdir(parents=True, exist_ok=True)
|
settings.uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
task = asyncio.create_task(_cleaner_loop())
|
||||||
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="MincedPad", lifespan=lifespan)
|
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"})
|
r = client.post("/api/items", json={"body": "x"})
|
||||||
assert r.status_code == 403
|
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