feat(backend): storage trash with seven day retention, trashed files are not fetched again automatically
This commit is contained in:
1 parent
c20e132872
commit
f2c245707a
11 files changed
+680
-6
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user