feat(backend): storage trash with seven day retention, trashed files are not fetched again automatically

This commit is contained in:
hh committed 2026-10-14 02:52:21 +02:00
1 parent c20e132872
commit f2c245707a
11 files changed
+680 -6

No files matched your search

+9
View File
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
@@ -34,12 +35,15 @@ from api.routers import (
search,
shares,
social,
storage,
stories,
watches,
)
from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env
from utils.storage import ContentAddressedStorage
from utils.trash import purge_loop
if env.auth.token is None:
msg = "AUTH__TOKEN is required for the api process"
@@ -55,7 +59,11 @@ mcp_app = mcp.http_app(path="/")
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
pool = await container.get(asyncpg.Pool)
await hub.start(pool)
purger = asyncio.create_task(
purge_loop(pool, await container.get(ContentAddressedStorage))
)
yield
purger.cancel()
await hub.stop()
await app_.state.dishka_container.close()
@@ -96,6 +104,7 @@ app.include_router(discover.router)
app.include_router(annotations.router)
app.include_router(watches.router)
app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router)
app.mount("/mcp", mcp_app)
+3 -1
View File
@@ -54,7 +54,7 @@ async def serve_media_version(
download: Download = False,
) -> FileResponse:
version = await get_media_version(pool, version_id)
if version is None:
if version is None or not storage.exists(version.storage_key):
raise HTTPException(status_code=404, detail="media version not found")
headers = IMMUTABLE_HEADERS
if download:
@@ -96,6 +96,8 @@ async def serve_media(
status_code=409,
detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
)
if not storage.exists(media.storage_key):
raise HTTPException(status_code=404, detail="media file is missing")
headers = DAY_HEADERS
if download:
headers = _attachment(headers, media.file_name or f"media_{media_id}")
+107
View File
@@ -0,0 +1,107 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from utils import trash
from utils.cache import NO_STORE_HEADERS
from utils.files import resolve_mime
from utils.read import storage as storage_read
from utils.read.models import (
DEFAULT_LIMIT,
Page,
StorageFileView,
StorageSummaryView,
TrashItemView,
TrashOutcome,
)
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/storage", tags=["storage"], route_class=DishkaRoute)
class TrashRequest(BaseModel):
account_id: int
media_ids: list[int]
class RestoreRequest(BaseModel):
account_id: int
keys: list[str]
class PurgeRequest(BaseModel):
account_id: int
keys: list[str] | None = None
@router.get("/summary")
async def summary(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> StorageSummaryView:
return await storage_read.get_summary(pool, account_id)
@router.get("/files")
async def files(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StorageFileView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_files(pool, account_id, group, page)
@router.get("/trash")
async def trash_items(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[TrashItemView]:
return await storage_read.list_trash(pool, account_id)
@router.post("/trash")
async def move_to_trash(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: TrashRequest,
) -> TrashOutcome:
return await trash.move_to_trash(pool, storage, body.account_id, body.media_ids)
@router.post("/trash/restore")
async def restore(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: RestoreRequest,
) -> TrashOutcome:
return await trash.restore(pool, storage, body.account_id, body.keys)
@router.post("/trash/purge")
async def purge(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: PurgeRequest,
) -> TrashOutcome:
return await trash.purge(pool, storage, body.account_id, body.keys)
@router.get("/trash/{storage_key}/file")
async def serve_trashed(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
storage_key: str,
) -> FileResponse:
item = await storage_read.get_trash_item(pool, storage_key)
if item is None:
raise HTTPException(status_code=404, detail="file is not in trash")
return FileResponse(
storage.trash_url(storage_key),
media_type=resolve_mime(item["kind"], item["mime"], item["file_name"]),
headers=NO_STORE_HEADERS,
)