From 809300c02d7eda839b058b5676f3687a339de0a4 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 27 Jun 2026 18:28:00 +0300 Subject: [PATCH] Add authenticated media storage pipeline --- .../versions/c3f8b2a9d1e4_add_media_assets.py | 55 ++++++ papyrus/api/routes/__init__.py | 2 + papyrus/api/routes/media.py | 58 ++++++ papyrus/config.py | 1 + papyrus/models/__init__.py | 2 + papyrus/models/media.py | 32 +++ papyrus/models/sync.py | 2 + papyrus/schemas/media.py | 33 ++++ papyrus/schemas/sync.py | 2 + papyrus/services/media.py | 186 ++++++++++++++++++ papyrus/services/sync.py | 29 +++ tests/api/routes/test_media.py | 152 ++++++++++++++ tests/api/routes/test_sync.py | 73 +++++++ 13 files changed, 627 insertions(+) create mode 100644 alembic/versions/c3f8b2a9d1e4_add_media_assets.py create mode 100644 papyrus/api/routes/media.py create mode 100644 papyrus/models/media.py create mode 100644 papyrus/schemas/media.py create mode 100644 papyrus/services/media.py create mode 100644 tests/api/routes/test_media.py diff --git a/alembic/versions/c3f8b2a9d1e4_add_media_assets.py b/alembic/versions/c3f8b2a9d1e4_add_media_assets.py new file mode 100644 index 0000000..3fe3150 --- /dev/null +++ b/alembic/versions/c3f8b2a9d1e4_add_media_assets.py @@ -0,0 +1,55 @@ +"""add media assets + +Revision ID: c3f8b2a9d1e4 +Revises: a1d7c2f4e8b9 +Create Date: 2026-06-27 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c3f8b2a9d1e4" +down_revision: str | Sequence[str] | None = "a1d7c2f4e8b9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column("books", sa.Column("file_media_id", sa.Uuid(), nullable=True)) + op.add_column("books", sa.Column("cover_media_id", sa.Uuid(), nullable=True)) + + op.create_table( + "media_assets", + sa.Column("asset_id", sa.Uuid(), nullable=False), + sa.Column("owner_user_id", sa.Uuid(), nullable=False), + sa.Column("book_id", sa.Uuid(), nullable=False), + sa.Column("kind", sa.String(length=32), nullable=False), + sa.Column("original_filename", sa.String(length=512), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=False), + sa.Column("extension", sa.String(length=16), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("storage_path", sa.String(length=2048), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["book_id"], ["books.book_id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("asset_id"), + ) + op.create_index(op.f("ix_media_assets_book_id"), "media_assets", ["book_id"], unique=False) + op.create_index(op.f("ix_media_assets_owner_user_id"), "media_assets", ["owner_user_id"], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_media_assets_owner_user_id"), table_name="media_assets") + op.drop_index(op.f("ix_media_assets_book_id"), table_name="media_assets") + op.drop_table("media_assets") + op.drop_column("books", "cover_media_id") + op.drop_column("books", "file_media_id") diff --git a/papyrus/api/routes/__init__.py b/papyrus/api/routes/__init__.py index 8de590e..7835593 100644 --- a/papyrus/api/routes/__init__.py +++ b/papyrus/api/routes/__init__.py @@ -11,6 +11,7 @@ dev_powersync_sandbox, files, goals, + media, notes, progress, reading_profiles, @@ -37,6 +38,7 @@ api_router.include_router(progress.router, prefix="/progress", tags=["Progress"]) api_router.include_router(goals.router, prefix="/goals", tags=["Goals"]) api_router.include_router(sync.router, prefix="/sync", tags=["Sync"]) +api_router.include_router(media.router, prefix="/media", tags=["Media"]) api_router.include_router(storage.router, prefix="/storage", tags=["Storage"]) api_router.include_router(files.router, prefix="/files", tags=["Files"]) api_router.include_router(reading_profiles.router, prefix="/reading-profiles", tags=["Reading Profiles"]) diff --git a/papyrus/api/routes/media.py b/papyrus/api/routes/media.py new file mode 100644 index 0000000..093496a --- /dev/null +++ b/papyrus/api/routes/media.py @@ -0,0 +1,58 @@ +"""Authenticated private media routes.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.api.deps import CurrentUserId +from papyrus.config import get_settings +from papyrus.core.database import get_db +from papyrus.core.exceptions import NotFoundError +from papyrus.core.rate_limit import limiter +from papyrus.schemas.media import MediaAssetResponse, MediaUsageResponse +from papyrus.services import media as media_service + +router = APIRouter() +DBSession = Annotated[AsyncSession, Depends(get_db)] + + +@router.post("", response_model=MediaAssetResponse, status_code=status.HTTP_201_CREATED, summary="Upload private media") +@limiter.limit(lambda: f"{get_settings().rate_limit_upload}/minute") +async def upload_media( + request: Request, + user_id: CurrentUserId, + db: DBSession, + book_id: Annotated[UUID, Form()], + kind: Annotated[str, Form()], + file: Annotated[UploadFile, File()], +) -> MediaAssetResponse: + """Upload a book file or cover image for the authenticated user.""" + asset = await media_service.upload_media(db, user_id, book_id=book_id, kind=kind, file=file) + return MediaAssetResponse.model_validate(asset) + + +@router.get("/usage", response_model=MediaUsageResponse, summary="Get media storage usage") +async def get_media_usage(user_id: CurrentUserId, db: DBSession) -> MediaUsageResponse: + """Return authenticated user's file storage usage.""" + used_bytes, quota_bytes, available_bytes = await media_service.usage(db, user_id) + return MediaUsageResponse(used_bytes=used_bytes, quota_bytes=quota_bytes, available_bytes=available_bytes) + + +@router.get("/{asset_id}", summary="Download private media") +async def download_media(user_id: CurrentUserId, db: DBSession, asset_id: UUID) -> FileResponse: + """Download an owned media asset.""" + asset = await media_service.get_owned_asset(db, user_id, asset_id) + path = media_service.asset_path(asset) + if not path.exists(): + raise NotFoundError("Media asset was not found") + return FileResponse(path, media_type=asset.content_type, filename=asset.original_filename) + + +@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete private media") +async def delete_media(user_id: CurrentUserId, db: DBSession, asset_id: UUID) -> Response: + """Delete an owned media asset.""" + await media_service.delete_media(db, user_id, asset_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/papyrus/config.py b/papyrus/config.py index e9c73b7..da6389f 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -61,6 +61,7 @@ class Settings(BaseSettings): powersync_service_url: str = "http://localhost:8081" powersync_service_port: int = 8081 file_storage_quota_bytes: int = 1_073_741_824 + media_storage_root: str = ".papyrus-media" powersync_jwks_uri: str | None = None powersync_source_role: str | None = None powersync_source_password: str | None = None diff --git a/papyrus/models/__init__.py b/papyrus/models/__init__.py index 1f92bfb..be17f15 100644 --- a/papyrus/models/__init__.py +++ b/papyrus/models/__init__.py @@ -1,5 +1,6 @@ from papyrus.core.database import Base from papyrus.models.auth import AuthExchangeCode, AuthSession, EmailActionToken, PasswordCredential, UserIdentity +from papyrus.models.media import MediaAsset from papyrus.models.powersync_demo import PowerSyncDemoItem from papyrus.models.sync import SyncBook from papyrus.models.user import User @@ -9,6 +10,7 @@ "AuthSession", "Base", "EmailActionToken", + "MediaAsset", "PasswordCredential", "PowerSyncDemoItem", "SyncBook", diff --git a/papyrus/models/media.py b/papyrus/models/media.py new file mode 100644 index 0000000..206d7fe --- /dev/null +++ b/papyrus/models/media.py @@ -0,0 +1,32 @@ +"""Uploaded private media assets.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Uuid, func +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class MediaAsset(Base): + """A private uploaded book file or cover image owned by one user.""" + + __tablename__ = "media_assets" + + asset_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + owner_user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True + ) + book_id: Mapped[UUID] = mapped_column(ForeignKey("books.book_id", ondelete="CASCADE"), nullable=False, index=True) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + original_filename: Mapped[str] = mapped_column(String(512), nullable=False) + content_type: Mapped[str] = mapped_column(String(255), nullable=False) + extension: Mapped[str] = mapped_column(String(16), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + sha256: Mapped[str] = mapped_column(String(64), nullable=False) + storage_path: Mapped[str] = mapped_column(String(2048), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/papyrus/models/sync.py b/papyrus/models/sync.py index 8fb8de5..63872f9 100644 --- a/papyrus/models/sync.py +++ b/papyrus/models/sync.py @@ -32,6 +32,8 @@ class SyncBook(Base): page_count: Mapped[int | None] = mapped_column(Integer, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) cover_image_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) + file_media_id: Mapped[UUID | None] = mapped_column(Uuid, nullable=True) + cover_media_id: Mapped[UUID | None] = mapped_column(Uuid, nullable=True) reading_status: Mapped[str | None] = mapped_column(String(32), nullable=True) current_page: Mapped[int | None] = mapped_column(Integer, nullable=True) current_position: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/papyrus/schemas/media.py b/papyrus/schemas/media.py new file mode 100644 index 0000000..b6bb6d0 --- /dev/null +++ b/papyrus/schemas/media.py @@ -0,0 +1,33 @@ +"""Media asset schemas.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class MediaAssetResponse(BaseModel): + """Uploaded media asset metadata.""" + + model_config = ConfigDict(from_attributes=True) + + asset_id: UUID + owner_user_id: UUID + book_id: UUID + kind: str + original_filename: str + content_type: str + extension: str + size_bytes: int + sha256: str + storage_path: str + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MediaUsageResponse(BaseModel): + """User media storage quota usage.""" + + used_bytes: int + quota_bytes: int + available_bytes: int diff --git a/papyrus/schemas/sync.py b/papyrus/schemas/sync.py index 7f38509..295de0d 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -17,6 +17,8 @@ "page_count", "description", "cover_image_url", + "file_media_id", + "cover_media_id", "reading_status", "current_page", "current_position", diff --git a/papyrus/services/media.py b/papyrus/services/media.py new file mode 100644 index 0000000..7b022cf --- /dev/null +++ b/papyrus/services/media.py @@ -0,0 +1,186 @@ +"""Private media storage service.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from uuid import UUID, uuid4 + +from fastapi import UploadFile +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.config import get_settings +from papyrus.core.exceptions import ConflictError, ForbiddenError, NotFoundError, ValidationError +from papyrus.models import MediaAsset, SyncBook + +BOOK_EXTENSIONS = {"epub", "pdf", "mobi", "azw3", "txt", "cbr", "cbz"} +COVER_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "gif"} +MEDIA_KINDS = {"book_file", "cover_image"} + + +def media_root() -> Path: + return Path(get_settings().media_storage_root) + + +async def usage(session: AsyncSession, user_id: UUID) -> tuple[int, int, int]: + """Return used, quota, and available bytes for a user.""" + used = await _used_bytes(session, user_id) + quota = get_settings().file_storage_quota_bytes + return used, quota, max(quota - used, 0) + + +async def upload_media( + session: AsyncSession, + user_id: UUID, + *, + book_id: UUID, + kind: str, + file: UploadFile, +) -> MediaAsset: + """Validate and persist an uploaded media asset.""" + if kind not in MEDIA_KINDS: + raise ValidationError("Unsupported media kind") + + book = await session.get(SyncBook, book_id) + if book is None: + raise NotFoundError("Book was not found") + if book.owner_user_id != user_id: + raise ForbiddenError("Cannot access another user's book") + + filename = file.filename or "upload" + extension = _extension(filename) + content_type = file.content_type or "application/octet-stream" + _validate_media_type(kind, extension, content_type) + + content = await file.read() + if not content: + raise ValidationError("Uploaded file is empty") + + existing = await _existing_asset_for_kind(session, book, kind) + used = await _used_bytes(session, user_id) + used_without_existing = used - (existing.size_bytes if existing is not None else 0) + quota = get_settings().file_storage_quota_bytes + if used_without_existing + len(content) > quota: + raise ConflictError("Storage quota exceeded") + + asset_id = uuid4() + storage_path = f"{user_id}/{book_id}/{asset_id}.{extension}" + absolute_path = media_root() / storage_path + absolute_path.parent.mkdir(parents=True, exist_ok=True) + absolute_path.write_bytes(content) + + asset = MediaAsset( + asset_id=asset_id, + owner_user_id=user_id, + book_id=book_id, + kind=kind, + original_filename=filename, + content_type=content_type, + extension=extension, + size_bytes=len(content), + sha256=hashlib.sha256(content).hexdigest(), + storage_path=storage_path, + ) + session.add(asset) + + if kind == "book_file": + book.file_media_id = asset.asset_id + else: + book.cover_media_id = asset.asset_id + + if existing is not None: + await session.delete(existing) + _delete_physical_file(existing) + + await session.commit() + await session.refresh(asset) + return asset + + +async def get_owned_asset(session: AsyncSession, user_id: UUID, asset_id: UUID) -> MediaAsset: + asset = await session.get(MediaAsset, asset_id) + if asset is None: + raise NotFoundError("Media asset was not found") + if asset.owner_user_id != user_id: + raise NotFoundError("Media asset was not found") + return asset + + +async def delete_media(session: AsyncSession, user_id: UUID, asset_id: UUID) -> None: + asset = await get_owned_asset(session, user_id, asset_id) + book = await session.get(SyncBook, asset.book_id) + if book is not None: + if book.file_media_id == asset.asset_id: + book.file_media_id = None + if book.cover_media_id == asset.asset_id: + book.cover_media_id = None + await session.delete(asset) + _delete_physical_file(asset) + await session.commit() + + +async def delete_book_media(session: AsyncSession, user_id: UUID, book_id: UUID) -> None: + result = await session.execute( + select(MediaAsset).where(MediaAsset.owner_user_id == user_id, MediaAsset.book_id == book_id) + ) + for asset in result.scalars(): + await session.delete(asset) + _delete_physical_file(asset) + + +async def validate_media_reference( + session: AsyncSession, + user_id: UUID, + book_id: UUID, + asset_id: UUID | None, + *, + field_name: str, + expected_kind: str, +) -> UUID | None: + if asset_id is None: + return None + asset = await session.get(MediaAsset, asset_id) + if asset is None: + raise ValidationError(f"{field_name} was not found") + if asset.owner_user_id != user_id or asset.book_id != book_id: + raise ForbiddenError(f"{field_name} does not belong to this book") + if asset.kind != expected_kind: + raise ValidationError(f"{field_name} has the wrong media kind") + return asset.asset_id + + +def asset_path(asset: MediaAsset) -> Path: + return media_root() / asset.storage_path + + +async def _used_bytes(session: AsyncSession, user_id: UUID) -> int: + result = await session.execute(select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(MediaAsset.owner_user_id == user_id)) + return int(result.scalar_one()) + + +async def _existing_asset_for_kind(session: AsyncSession, book: SyncBook, kind: str) -> MediaAsset | None: + asset_id = book.file_media_id if kind == "book_file" else book.cover_media_id + if asset_id is None: + return None + return await session.get(MediaAsset, asset_id) + + +def _extension(filename: str) -> str: + extension = Path(filename).suffix.lower().lstrip(".") + if not extension: + raise ValidationError("Uploaded file must include a file extension") + return extension + + +def _validate_media_type(kind: str, extension: str, content_type: str) -> None: + if kind == "book_file" and extension not in BOOK_EXTENSIONS: + raise ValidationError("Unsupported book file type") + if kind == "cover_image" and (extension not in COVER_EXTENSIONS or not content_type.startswith("image/")): + raise ValidationError("Unsupported cover image type") + + +def _delete_physical_file(asset: MediaAsset) -> None: + path = asset_path(asset) + if path.exists(): + path.unlink() diff --git a/papyrus/services/sync.py b/papyrus/services/sync.py index 4876532..4cedf23 100644 --- a/papyrus/services/sync.py +++ b/papyrus/services/sync.py @@ -10,6 +10,7 @@ from papyrus.core.exceptions import ForbiddenError, ValidationError from papyrus.models import SyncBook from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.services import media as media_service BOOK_FIELDS = frozenset( { @@ -24,6 +25,8 @@ "page_count", "description", "cover_image_url", + "file_media_id", + "cover_media_id", "reading_status", "current_page", "current_position", @@ -64,6 +67,15 @@ def _optional_text(payload: dict[str, object], key: str, default: str | None = N return None if value is None else str(value) +def _optional_uuid(payload: dict[str, object], key: str, default: UUID | None = None) -> UUID | None: + if key not in payload: + return default + value = payload[key] + if value is None: + return None + return _uuid(value, key) + + def _required_text(payload: dict[str, object], key: str, default: str | None = None) -> str: value = _optional_text(payload, key, default) if value is None or not value: @@ -182,6 +194,7 @@ async def _apply_book_mutation( book = await _get_owned_book(session, user_id, book_id) if book is None: return 0 + await media_service.delete_book_media(session, user_id, book_id) await session.delete(book) return 1 @@ -210,6 +223,22 @@ async def _apply_book_mutation( book.page_count = _optional_int(payload, "page_count", book.page_count) book.description = _optional_text(payload, "description", book.description) book.cover_image_url = _optional_text(payload, "cover_image_url", book.cover_image_url) + book.file_media_id = await media_service.validate_media_reference( + session, + user_id, + book.book_id, + _optional_uuid(payload, "file_media_id", book.file_media_id), + field_name="file_media_id", + expected_kind="book_file", + ) + book.cover_media_id = await media_service.validate_media_reference( + session, + user_id, + book.book_id, + _optional_uuid(payload, "cover_media_id", book.cover_media_id), + field_name="cover_media_id", + expected_kind="cover_image", + ) book.reading_status = _optional_text(payload, "reading_status", book.reading_status) book.current_page = _optional_int(payload, "current_page", book.current_page) book.current_position = _optional_float(payload, "current_position", book.current_position) diff --git a/tests/api/routes/test_media.py b/tests/api/routes/test_media.py new file mode 100644 index 0000000..f8c7a9d --- /dev/null +++ b/tests/api/routes/test_media.py @@ -0,0 +1,152 @@ +"""Tests for authenticated media storage routes.""" + +from datetime import UTC, datetime +from pathlib import Path +from uuid import UUID, uuid4 + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.models import SyncBook, User + + +async def _create_owned_book(db_session: AsyncSession, user_id: str) -> SyncBook: + book = SyncBook( + book_id=uuid4(), + owner_user_id=UUID(user_id), + title="Media Book", + author="Reader", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db_session.add(book) + await db_session.commit() + return book + + +async def test_upload_media_persists_file_and_updates_usage( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, + monkeypatch, + tmp_path: Path, +): + monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) + monkeypatch.setattr("papyrus.main.settings.file_storage_quota_bytes", 1_073_741_824) + book = await _create_owned_book(db_session, auth_user["user_id"]) + + response = await client.post( + "/v1/media", + headers=auth_headers, + data={"book_id": str(book.book_id), "kind": "book_file"}, + files={"file": ("example.epub", b"epub bytes", "application/epub+zip")}, + ) + + assert response.status_code == 201 + body = response.json() + assert body["book_id"] == str(book.book_id) + assert body["kind"] == "book_file" + assert body["original_filename"] == "example.epub" + assert body["content_type"] == "application/epub+zip" + assert body["size_bytes"] == len(b"epub bytes") + assert body["sha256"] == "227dae38658f29c3a8494e65302e70b406162c2f581845339dfa19cbfad839d4" + assert (tmp_path / body["storage_path"]).read_bytes() == b"epub bytes" + + usage = await client.get("/v1/media/usage", headers=auth_headers) + assert usage.status_code == 200 + assert usage.json() == { + "used_bytes": len(b"epub bytes"), + "quota_bytes": 1_073_741_824, + "available_bytes": 1_073_741_824 - len(b"epub bytes"), + } + + +async def test_download_and_delete_owned_media( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, + monkeypatch, + tmp_path: Path, +): + monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) + book = await _create_owned_book(db_session, auth_user["user_id"]) + upload = await client.post( + "/v1/media", + headers=auth_headers, + data={"book_id": str(book.book_id), "kind": "cover_image"}, + files={"file": ("cover.jpg", b"jpeg bytes", "image/jpeg")}, + ) + assert upload.status_code == 201 + asset_id = upload.json()["asset_id"] + + download = await client.get(f"/v1/media/{asset_id}", headers=auth_headers) + assert download.status_code == 200 + assert download.content == b"jpeg bytes" + assert download.headers["content-type"] == "image/jpeg" + + delete = await client.delete(f"/v1/media/{asset_id}", headers=auth_headers) + assert delete.status_code == 204 + assert not (tmp_path / upload.json()["storage_path"]).exists() + assert (await client.get(f"/v1/media/{asset_id}", headers=auth_headers)).status_code == 404 + + +async def test_upload_rejects_quota_overflow_without_persisting_file( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, + monkeypatch, + tmp_path: Path, +): + monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) + monkeypatch.setattr("papyrus.main.settings.file_storage_quota_bytes", 4) + book = await _create_owned_book(db_session, auth_user["user_id"]) + + response = await client.post( + "/v1/media", + headers=auth_headers, + data={"book_id": str(book.book_id), "kind": "book_file"}, + files={"file": ("too-big.epub", b"12345", "application/epub+zip")}, + ) + + assert response.status_code == 409 + assert response.json()["error"]["message"] == "Storage quota exceeded" + assert list(tmp_path.rglob("*")) == [] + + +async def test_upload_rejects_cross_user_book( + client: AsyncClient, + auth_headers: dict[str, str], + db_session: AsyncSession, + monkeypatch, + tmp_path: Path, +): + monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) + other_user = User( + display_name="Other User", + primary_email="other-media@example.com", + primary_email_verified=True, + last_login_at=datetime.now(UTC), + ) + db_session.add(other_user) + await db_session.flush() + foreign_book = SyncBook( + book_id=uuid4(), + owner_user_id=other_user.user_id, + title="Foreign Book", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db_session.add(foreign_book) + await db_session.commit() + + response = await client.post( + "/v1/media", + headers=auth_headers, + data={"book_id": str(foreign_book.book_id), "kind": "book_file"}, + files={"file": ("foreign.epub", b"bytes", "application/epub+zip")}, + ) + + assert response.status_code == 403 diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index 164c42c..1b7477d 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -269,3 +269,76 @@ async def test_powersync_upload_rejects_cross_user_book_mutation( }, ) assert response.status_code == 403 + + +async def test_powersync_upload_accepts_owned_media_references( + client: AsyncClient, + auth_headers: dict[str, str], + auth_user: dict[str, str], + db_session: AsyncSession, + monkeypatch, + tmp_path, +): + monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) + book_id = uuid4() + book = SyncBook( + book_id=book_id, + owner_user_id=UUID(auth_user["user_id"]), + title="Media Book", + added_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db_session.add(book) + await db_session.commit() + upload = await client.post( + "/v1/media", + headers=auth_headers, + data={"book_id": str(book_id), "kind": "cover_image"}, + files={"file": ("cover.png", b"png bytes", "image/png")}, + ) + assert upload.status_code == 201 + asset_id = upload.json()["asset_id"] + + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + { + "type": "books", + "op": "PATCH", + "id": str(book_id), + "data": {"cover_media_id": asset_id}, + } + ] + }, + ) + + assert response.status_code == 200 + db_session.expire_all() + synced_book = await db_session.get(SyncBook, book_id) + assert synced_book is not None + assert str(synced_book.cover_media_id) == asset_id + + +async def test_powersync_upload_rejects_unknown_media_reference( + client: AsyncClient, + auth_headers: dict[str, str], +): + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + { + "type": "books", + "op": "PUT", + "id": str(uuid4()), + "data": {"title": "Bad Media", "file_media_id": str(uuid4())}, + } + ] + }, + ) + + assert response.status_code == 400 + assert response.json()["error"]["message"] == "file_media_id was not found"