Stream media through the backend (browser-reachable, privacy-checked)

Presigned URLs point at the internal minio:9000 host a browser can't reach. Add ObjectStore.get_object and a GET /media/{id}/content endpoint that resolves visibility and streams the bytes; MediaRead.url now points there. Keeps the object store private and downloads behind the privacy engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-06 21:56:04 -04:00
parent 660130f007
commit bd8ee9b647
6 changed files with 70 additions and 8 deletions
+34 -7
View File
@@ -1,20 +1,27 @@
import uuid
from fastapi import APIRouter, File, Form, UploadFile, status
from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead
from app.services import media_service, tree_service
router = APIRouter(prefix="/trees", tags=["media"])
def _content_url(media) -> str:
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
def _with_url(media, url: str) -> MediaRead:
def _read(media) -> MediaRead:
out = MediaRead.model_validate(media)
out.url = url
# Stream through the backend (privacy-checked, browser-reachable) rather
# than expose the internal object store directly.
out.url = _content_url(media)
return out
router = APIRouter(prefix="/trees", tags=["media"])
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
async def upload_media(
tree_id: uuid.UUID,
@@ -42,16 +49,36 @@ async def upload_media(
event_id=event_id,
source_id=source_id,
)
return _with_url(media, await store.presigned_get_url(key=media.storage_key))
return _read(media)
@router.get("/{tree_id}/media", response_model=list[MediaRead])
async def list_media(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, store: ObjectStoreDep
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[MediaRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
return [_with_url(m, await store.presigned_get_url(key=m.storage_key)) for m in items]
return [_read(m) for m in items]
@router.get("/{tree_id}/media/{media_id}/content")
async def media_content(
tree_id: uuid.UUID,
media_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.get_media(
session, viewer_id=current.id, tree=tree, media_id=media_id
)
data = await store.get_object(key=media.storage_key)
return Response(
content=data,
media_type=media.content_type,
headers={"Content-Disposition": f'inline; filename="{media.original_filename}"'},
)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)