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:
@@ -1,20 +1,27 @@
|
|||||||
import uuid
|
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.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
from app.schemas.media import MediaRead
|
from app.schemas.media import MediaRead
|
||||||
from app.services import media_service, tree_service
|
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 = 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def upload_media(
|
async def upload_media(
|
||||||
tree_id: uuid.UUID,
|
tree_id: uuid.UUID,
|
||||||
@@ -42,16 +49,36 @@ async def upload_media(
|
|||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
source_id=source_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])
|
@router.get("/{tree_id}/media", response_model=list[MediaRead])
|
||||||
async def list_media(
|
async def list_media(
|
||||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, store: ObjectStoreDep
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
) -> list[MediaRead]:
|
) -> list[MediaRead]:
|
||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
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)
|
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)
|
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class ObjectStore(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_object(self, *, key: str) -> bytes: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def presigned_get_url(self, *, key: str) -> str: ...
|
async def presigned_get_url(self, *, key: str) -> str: ...
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ class S3ObjectStore(ObjectStore):
|
|||||||
ContentType=content_type,
|
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:
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
self._client.generate_presigned_url,
|
self._client.generate_presigned_url,
|
||||||
|
|||||||
@@ -80,6 +80,23 @@ async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree)
|
|||||||
return list((await session.execute(stmt)).scalars().all())
|
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(
|
async def delete_media(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
|
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class FakeObjectStore(ObjectStore):
|
|||||||
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||||
self.objects[key] = (data, content_type)
|
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:
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
return f"https://objects.test/{key}"
|
return f"https://objects.test/{key}"
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,18 @@ async def test_media_upload_list_delete(client):
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["original_filename"] == "scan.txt"
|
assert body["original_filename"] == "scan.txt"
|
||||||
assert body["byte_size"] == 11
|
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"]
|
media_id = body["id"]
|
||||||
|
|
||||||
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
|
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
|
||||||
assert listed.status_code == 200
|
assert listed.status_code == 200
|
||||||
assert len(listed.json()) == 1
|
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)
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user