From 4d697692e069de1c565aef0a0aafd328b38e25e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=89=E6=9C=AB?= Date: Tue, 14 Jul 2026 12:53:58 +0800 Subject: [PATCH] feat: burn-after-read and expiry cleaner Co-authored-by: Cursor --- backend/app/main.py | 26 +++++++++++- backend/app/services/cleaner.py | 35 ++++++++++++++++ backend/tests/test_items.py | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/cleaner.py diff --git a/backend/app/main.py b/backend/app/main.py index e38dede..34f7cc3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) - yield + task = asyncio.create_task(_cleaner_loop()) + try: + yield + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass app = FastAPI(title="MincedPad", lifespan=lifespan) diff --git a/backend/app/services/cleaner.py b/backend/app/services/cleaner.py new file mode 100644 index 0000000..edaba9d --- /dev/null +++ b/backend/app/services/cleaner.py @@ -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) diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py index c43eba0..88a52d3 100644 --- a/backend/tests/test_items.py +++ b/backend/tests/test_items.py @@ -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()