From bd8ee9b647dc8487bb570f84f11c9d3c27c430b8 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 21:56:04 -0400 Subject: [PATCH] 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) Signed-off-by: Justin Paul --- backend/app/api/v1/media.py | 41 ++++++++++++++++---- backend/app/integrations/objectstore/base.py | 3 ++ backend/app/integrations/objectstore/s3.py | 7 ++++ backend/app/services/media_service.py | 17 ++++++++ backend/tests/conftest.py | 3 ++ backend/tests/test_media.py | 7 +++- 6 files changed, 70 insertions(+), 8 deletions(-) diff --git a/backend/app/api/v1/media.py b/backend/app/api/v1/media.py index 614be77..4de355d 100644 --- a/backend/app/api/v1/media.py +++ b/backend/app/api/v1/media.py @@ -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) diff --git a/backend/app/integrations/objectstore/base.py b/backend/app/integrations/objectstore/base.py index 806d3aa..6bd0e1e 100644 --- a/backend/app/integrations/objectstore/base.py +++ b/backend/app/integrations/objectstore/base.py @@ -15,6 +15,9 @@ class ObjectStore(ABC): @abstractmethod async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ... + @abstractmethod + async def get_object(self, *, key: str) -> bytes: ... + @abstractmethod async def presigned_get_url(self, *, key: str) -> str: ... diff --git a/backend/app/integrations/objectstore/s3.py b/backend/app/integrations/objectstore/s3.py index 033bb02..039e163 100644 --- a/backend/app/integrations/objectstore/s3.py +++ b/backend/app/integrations/objectstore/s3.py @@ -44,6 +44,13 @@ class S3ObjectStore(ObjectStore): ContentType=content_type, ) + async def get_object(self, *, key: str) -> bytes: + def _get() -> bytes: + obj = self._client.get_object(Bucket=self.bucket, Key=key) + return obj["Body"].read() + + return await asyncio.to_thread(_get) + async def presigned_get_url(self, *, key: str) -> str: return await asyncio.to_thread( self._client.generate_presigned_url, diff --git a/backend/app/services/media_service.py b/backend/app/services/media_service.py index 6b03a9c..013f8f7 100644 --- a/backend/app/services/media_service.py +++ b/backend/app/services/media_service.py @@ -80,6 +80,23 @@ async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) return list((await session.execute(stmt)).scalars().all()) +async def get_media( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, media_id: uuid.UUID +) -> Media: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + media = ( + await session.execute( + select(Media).where( + Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None) + ) + ) + ).scalar_one_or_none() + if media is None: + raise NotFound("media not found") + return media + + async def delete_media( session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID ) -> None: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 78712d7..7a61802 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -46,6 +46,9 @@ class FakeObjectStore(ObjectStore): async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: self.objects[key] = (data, content_type) + async def get_object(self, *, key: str) -> bytes: + return self.objects[key][0] + async def presigned_get_url(self, *, key: str) -> str: return f"https://objects.test/{key}" diff --git a/backend/tests/test_media.py b/backend/tests/test_media.py index 0b488dd..224d5de 100644 --- a/backend/tests/test_media.py +++ b/backend/tests/test_media.py @@ -22,13 +22,18 @@ async def test_media_upload_list_delete(client): body = resp.json() assert body["original_filename"] == "scan.txt" assert body["byte_size"] == 11 - assert body["url"].startswith("https://objects.test/") + assert body["url"] == f"/api/v1/trees/{tree_id}/media/{body['id']}/content" media_id = body["id"] listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h) assert listed.status_code == 200 assert len(listed.json()) == 1 + # The content endpoint streams the bytes back. + content = await client.get(f"/api/v1/trees/{tree_id}/media/{media_id}/content", headers=h) + assert content.status_code == 200 + assert content.content == b"hello world" + resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h) assert resp.status_code == 204 assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0