From 51c1900d8c4488d057c0e326fae5e6e4683bb8b6 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 9 Mar 2026 14:11:21 -0400 Subject: [PATCH] feat: add KOSync server - Add KOSync device management - Add API key auth middleware for devices to authenticate - Add KOSync-compatible progress sync endpoints - Add basic tests for KOSync compatible hashes --- backend/src/chitai/controllers/__init__.py | 2 + .../src/chitai/controllers/kosync_device.py | 54 ++++++++++ .../src/chitai/controllers/kosync_progress.py | 90 +++++++++++++++++ .../src/chitai/database/models/__init__.py | 2 + .../chitai/database/models/kosync_device.py | 15 +++ .../chitai/database/models/kosync_progress.py | 26 +++++ backend/src/chitai/middleware/kosync_auth.py | 37 +++++++ backend/src/chitai/schemas/kosync.py | 36 +++++++ backend/src/chitai/services/__init__.py | 2 + backend/src/chitai/services/dependencies.py | 11 +++ backend/src/chitai/services/filters/book.py | 13 ++- backend/src/chitai/services/kosync_device.py | 35 +++++++ .../src/chitai/services/kosync_progress.py | 51 ++++++++++ backend/tests/integration/test_file_hash.py | 98 +++++++++++++++++++ 14 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 backend/src/chitai/controllers/kosync_device.py create mode 100644 backend/src/chitai/controllers/kosync_progress.py create mode 100644 backend/src/chitai/database/models/kosync_device.py create mode 100644 backend/src/chitai/database/models/kosync_progress.py create mode 100644 backend/src/chitai/middleware/kosync_auth.py create mode 100644 backend/src/chitai/schemas/kosync.py create mode 100644 backend/src/chitai/services/kosync_device.py create mode 100644 backend/src/chitai/services/kosync_progress.py create mode 100644 backend/tests/integration/test_file_hash.py diff --git a/backend/src/chitai/controllers/__init__.py b/backend/src/chitai/controllers/__init__.py index b694cd1..b2af52e 100644 --- a/backend/src/chitai/controllers/__init__.py +++ b/backend/src/chitai/controllers/__init__.py @@ -6,3 +6,5 @@ from .author import AuthorController from .tag import TagController from .publisher import PublisherController from .opds import OpdsController +from .kosync_device import DeviceController +from .kosync_progress import KosyncController diff --git a/backend/src/chitai/controllers/kosync_device.py b/backend/src/chitai/controllers/kosync_device.py new file mode 100644 index 0000000..4f284bc --- /dev/null +++ b/backend/src/chitai/controllers/kosync_device.py @@ -0,0 +1,54 @@ +from advanced_alchemy.service.pagination import OffsetPagination + +from chitai.database.models.kosync_device import KosyncDevice +from chitai.database.models.user import User +from chitai.schemas.kosync import KosyncDeviceCreate, KosyncDeviceRead +from chitai.services.kosync_device import KosyncDeviceService +from litestar import Controller, post, get, delete +from litestar.di import Provide +from chitai.services import dependencies as deps + +class DeviceController(Controller): + """ Controller for managing KOReader devices.""" + + dependencies = { + "device_service": Provide(deps.provide_kosync_device_service) + } + + path = "/devices" + + + @get() + async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]: + """ Return a list of all the user's devices.""" + devices = await device_service.list( + KosyncDevice.user_id == current_user.id + ) + return device_service.to_schema(devices, schema_type=KosyncDeviceRead) + + @post() + async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: + device = await device_service.create({ + 'name': data.name, + 'user_id': current_user.id + }) + return device_service.to_schema(device, schema_type=KosyncDeviceRead) + + @delete("/{device_id:int}") + async def delete_device(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> None: + # Ensure the device exists and is owned by the user + device = await device_service.get_one( + KosyncDevice.id == device_id, + KosyncDevice.user_id == current_user.id + ) + await device_service.delete(device.id) + + @get("/{device_id:int}/regenerate") + async def regenerate_device_api_key(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead: + # Ensure the device exists and is owned by the user + device = await device_service.get_one( + KosyncDevice.id == device_id, + KosyncDevice.user_id == current_user.id + ) + updated_device = await device_service.regenerate_api_key(device.id) + return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead) \ No newline at end of file diff --git a/backend/src/chitai/controllers/kosync_progress.py b/backend/src/chitai/controllers/kosync_progress.py new file mode 100644 index 0000000..5f773f2 --- /dev/null +++ b/backend/src/chitai/controllers/kosync_progress.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Annotated + +from litestar import Controller, get, put +from litestar.exceptions import HTTPException +from litestar.status_codes import HTTP_403_FORBIDDEN +from litestar.response import Response +from litestar.params import Parameter +from litestar.di import Provide + +from chitai.database import models as m +from chitai.schemas.kosync import KosyncProgressUpdate, KosyncProgressRead +from chitai.services.book import BookService +from chitai.services.kosync_progress import KosyncProgressService +from chitai.services.filters.book import FileHashFilter +from chitai.services import dependencies as deps +from chitai.middleware.kosync_auth import kosync_api_key_auth + + +class KosyncController(Controller): + """Controller for syncing progress with KOReader devices.""" + + middleware = [kosync_api_key_auth] + + dependencies = { + "kosync_progress_service": Provide(deps.provide_kosync_progress_service), + "book_service": Provide(deps.provide_book_service), + "user": Provide(deps.provide_user_via_kosync_auth), + } + + @put("/syncs/progress") + async def upload_progress( + self, + data: KosyncProgressUpdate, + book_service: BookService, + kosync_progress_service: KosyncProgressService, + user: m.User, + ) -> None: + """Upload book progress from a KOReader device.""" + book = await book_service.get_one(FileHashFilter([data.document])) + + await kosync_progress_service.upsert_progress( + user_id=user.id, + book_id=book.id, + document=data.document, + progress=data.progress, + percentage=data.percentage, + device=data.device, + device_id=data.device_id, + ) + + @get("/syncs/progress/{document_id:str}") + async def get_progress( + self, + document_id: str, + kosync_progress_service: KosyncProgressService, + user: m.User, + ) -> KosyncProgressRead: + """Return the Kosync progress record associated with the given document.""" + progress = await kosync_progress_service.get_by_document_hash(user.id, document_id) + + if not progress: + raise HTTPException(status_code=404, detail="No progress found for document") + + return KosyncProgressRead( + document=progress.document, + progress=progress.progress, + percentage=progress.percentage, + device=progress.device, + device_id=progress.device_id, + ) + + @get("/users/auth") + async def authorize( + self, _api_key: Annotated[str, Parameter(header="X-AUTH-USER")] + ) -> Response[dict[str, str]]: + """Verify authentication (handled by middleware).""" + return Response(status_code=200, content={"authorized": "OK"}) + + @get("/users/register") + async def register(self) -> None: + """User registration endpoint - disabled.""" + raise HTTPException( + detail="User accounts must be created via the main application", + status_code=HTTP_403_FORBIDDEN, + ) + + + diff --git a/backend/src/chitai/database/models/__init__.py b/backend/src/chitai/database/models/__init__.py index 6ef8f15..16df64f 100644 --- a/backend/src/chitai/database/models/__init__.py +++ b/backend/src/chitai/database/models/__init__.py @@ -3,6 +3,8 @@ from .book import Book, Identifier, FileMetadata from .book_list import BookList, BookListLink from .book_progress import BookProgress from .book_series import BookSeries +from .kosync_device import KosyncDevice +from .kosync_progress import KosyncProgress from .library import Library from .publisher import Publisher from .tag import Tag, BookTagLink diff --git a/backend/src/chitai/database/models/kosync_device.py b/backend/src/chitai/database/models/kosync_device.py new file mode 100644 index 0000000..25a52f9 --- /dev/null +++ b/backend/src/chitai/database/models/kosync_device.py @@ -0,0 +1,15 @@ +from sqlalchemy import ColumnElement, ForeignKey +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column + +from advanced_alchemy.base import BigIntAuditBase + +class KosyncDevice(BigIntAuditBase): + __tablename__ = "devices" + + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + api_key: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] + + def __repr__(self) -> str: + return f"KosyncDevice({self.name!r})" diff --git a/backend/src/chitai/database/models/kosync_progress.py b/backend/src/chitai/database/models/kosync_progress.py new file mode 100644 index 0000000..cb27130 --- /dev/null +++ b/backend/src/chitai/database/models/kosync_progress.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from advanced_alchemy.base import BigIntAuditBase + + +class KosyncProgress(BigIntAuditBase): + """Progress tracking for KOReader devices, keyed by document hash.""" + + __tablename__ = "kosync_progress" + + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="cascade"), nullable=False + ) + book_id: Mapped[int] = mapped_column( + ForeignKey("books.id", ondelete="cascade"), nullable=False + ) + document: Mapped[str] = mapped_column(nullable=False) + progress: Mapped[Optional[str]] + percentage: Mapped[Optional[float]] + device: Mapped[Optional[str]] + device_id: Mapped[Optional[str]] diff --git a/backend/src/chitai/middleware/kosync_auth.py b/backend/src/chitai/middleware/kosync_auth.py new file mode 100644 index 0000000..2e98de6 --- /dev/null +++ b/backend/src/chitai/middleware/kosync_auth.py @@ -0,0 +1,37 @@ +from chitai.services.user import UserService +from chitai.services.kosync_device import KosyncDeviceService +from litestar.middleware import ( + AbstractAuthenticationMiddleware, + AuthenticationResult, + DefineMiddleware +) +from litestar.connection import ASGIConnection +from litestar.exceptions import NotAuthorizedException, PermissionDeniedException +from chitai.config import settings + + +class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware): + async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult: + """Given a request, parse the header for Base64 encoded basic auth credentials. """ + + # retrieve the auth header + api_key = connection.headers.get("X-AUTH-USER", None) + if not api_key: + raise NotAuthorizedException() + + try: + db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope) + user_service = UserService(db_session) + device_service = KosyncDeviceService(db_session) + + device = await device_service.get_by_api_key(api_key) + user = await user_service.get(device.user_id) + + return AuthenticationResult(user=user, auth=None) + + except PermissionDeniedException as exc: + print(exc) + raise NotAuthorizedException() + + +kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware) \ No newline at end of file diff --git a/backend/src/chitai/schemas/kosync.py b/backend/src/chitai/schemas/kosync.py new file mode 100644 index 0000000..234ff94 --- /dev/null +++ b/backend/src/chitai/schemas/kosync.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel + + +class KosyncProgressUpdate(BaseModel): + """Schema for uploading progress from KOReader.""" + + document: str + progress: str | None = None + percentage: float + device: str | None = None + device_id: str | None = None + + +class KosyncProgressRead(BaseModel): + """Schema for reading progress to KOReader.""" + + document: str + progress: str | None = None + percentage: float | None = None + device: str | None = None + device_id: str | None = None + + +class KosyncDeviceRead(BaseModel): + """Schema for reading device information.""" + + id: int + user_id: int + api_key: str + name: str + + +class KosyncDeviceCreate(BaseModel): + """Schema for creating a new device.""" + + name: str diff --git a/backend/src/chitai/services/__init__.py b/backend/src/chitai/services/__init__.py index 9be8aad..57d83a7 100644 --- a/backend/src/chitai/services/__init__.py +++ b/backend/src/chitai/services/__init__.py @@ -6,3 +6,5 @@ from .author import AuthorService from .tag import TagService from .publisher import PublisherService from .book_progress import BookProgressService +from .kosync_device import KosyncDeviceService +from .kosync_progress import KosyncProgressService diff --git a/backend/src/chitai/services/dependencies.py b/backend/src/chitai/services/dependencies.py index 72d1e45..310fb9e 100644 --- a/backend/src/chitai/services/dependencies.py +++ b/backend/src/chitai/services/dependencies.py @@ -36,6 +36,8 @@ from chitai.services import ( TagService, AuthorService, PublisherService, + KosyncDeviceService, + KosyncProgressService, ) from chitai.config import settings from chitai.services.filters.book import ( @@ -339,4 +341,13 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User: return request.user + + +async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m.User: + return request.user + + +provide_kosync_device_service = create_service_provider(KosyncDeviceService) + +provide_kosync_progress_service = create_service_provider(KosyncProgressService) \ No newline at end of file diff --git a/backend/src/chitai/services/filters/book.py b/backend/src/chitai/services/filters/book.py index 9acaccc..172f2de 100644 --- a/backend/src/chitai/services/filters/book.py +++ b/backend/src/chitai/services/filters/book.py @@ -138,7 +138,7 @@ class ProgressFilter(StatementFilter): m.BookProgress.completed == False, m.BookProgress.completed.is_(None), ), - m.BookProgress.progress > 0, + m.BookProgress.percentage > 0, ) ) @@ -154,7 +154,6 @@ class ProgressFilter(StatementFilter): @dataclass class FileFilter(StatementFilter): """Filter books that are related to the given files.""" - file_ids: list[int] def append_to_statement( @@ -166,6 +165,16 @@ class FileFilter(StatementFilter): return super().append_to_statement(statement, model, *args, **kwargs) +@dataclass +class FileHashFilter(StatementFilter): + file_hashes: list[str] + + def append_to_statement(self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs) -> StatementTypeT: + statement = statement.where( + m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes)) + ) + + return super().append_to_statement(statement, model, *args, **kwargs) @dataclass class CustomOrderBy(StatementFilter): diff --git a/backend/src/chitai/services/kosync_device.py b/backend/src/chitai/services/kosync_device.py new file mode 100644 index 0000000..c77ac61 --- /dev/null +++ b/backend/src/chitai/services/kosync_device.py @@ -0,0 +1,35 @@ +from __future__ import annotations +import secrets +from chitai.database.models.kosync_device import KosyncDevice +from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, ModelDictT, schema_dump +from advanced_alchemy.repository import SQLAlchemyAsyncRepository + +class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]): + """Service for managing KOReader devices.""" + + API_KEY_LENGTH_IN_BYTES = 8 + + class Repo(SQLAlchemyAsyncRepository[KosyncDevice]): + """ Repository for KosyncDevice entities.""" + + model_type = KosyncDevice + + repository_type = Repo + + async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice: + data = schema_dump(data) + data['api_key'] = self._generate_api_key() + return await super().create(data, **kwargs) + + async def get_by_api_key(self, api_key: str) -> KosyncDevice: + return await self.get_one(KosyncDevice.api_key == api_key) + + async def regenerate_api_key(self, device_id: int) -> KosyncDevice: + device = await self.get(device_id) + api_key = self._generate_api_key() + device.api_key = api_key + return await self.update(device) + + + def _generate_api_key(self) -> str: + return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES) diff --git a/backend/src/chitai/services/kosync_progress.py b/backend/src/chitai/services/kosync_progress.py new file mode 100644 index 0000000..24ac1be --- /dev/null +++ b/backend/src/chitai/services/kosync_progress.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService +from advanced_alchemy.repository import SQLAlchemyAsyncRepository + +from chitai.database.models.kosync_progress import KosyncProgress + + +class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]): + """Service for managing KOReader sync progress.""" + + class Repo(SQLAlchemyAsyncRepository[KosyncProgress]): + """Repository for KosyncProgress entities.""" + + model_type = KosyncProgress + + repository_type = Repo + + async def get_by_document_hash(self, user_id: int, document: str) -> KosyncProgress | None: + """Get progress for a specific document and user.""" + return await self.get_one_or_none( + KosyncProgress.user_id == user_id, + KosyncProgress.document == document, + ) + + async def upsert_progress( + self, + user_id: int, + book_id: int, + document: str, + progress: str | None, + percentage: float, + device: str | None = None, + device_id: str | None = None, + ) -> KosyncProgress: + """Create or update progress for a document.""" + existing = await self.get_by_document_hash(user_id, document) + + data = { + "user_id": user_id, + "book_id": book_id, + "document": document, + "progress": progress, + "percentage": percentage, + "device": device, + "device_id": device_id, + } + + if existing: + return await self.update(data, item_id=existing.id) + return await self.create(data) diff --git a/backend/tests/integration/test_file_hash.py b/backend/tests/integration/test_file_hash.py new file mode 100644 index 0000000..9a32c6d --- /dev/null +++ b/backend/tests/integration/test_file_hash.py @@ -0,0 +1,98 @@ +"""Tests for KOReader-compatible file hash generation.""" + +import pytest +from httpx import AsyncClient +from pathlib import Path + + +# Known KOReader hashes for test files +TEST_FILES = { + "Moby Dick; Or, The Whale - Herman Melville.epub": { + "path": Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), + "hash": "ceeef909ec65653ba77e1380dff998fb", + "content_type": "application/epub+zip", + }, + "Calculus Made Easy - Silvanus Thompson.pdf": { + "path": Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"), + "hash": "ace67d512efd1efdea20f3c2436b6075", + "content_type": "application/pdf", + }, +} + + +@pytest.mark.parametrize( + ("book_name",), + [(name,) for name in TEST_FILES.keys()], +) +async def test_upload_book_generates_correct_hash( + authenticated_client: AsyncClient, + book_name: str, +) -> None: + """Test that uploading a book generates the correct KOReader-compatible hash.""" + book_info = TEST_FILES[book_name] + file_content = book_info["path"].read_bytes() + + files = [("files", (book_name, file_content, book_info["content_type"]))] + data = {"library_id": "1"} + + response = await authenticated_client.post( + "/books?library_id=1", + files=files, + data=data, + ) + + assert response.status_code == 201 + book_data = response.json() + + assert len(book_data["files"]) == 1 + file_metadata = book_data["files"][0] + + assert "hash" in file_metadata + assert file_metadata["hash"] == book_info["hash"] + + +async def test_add_file_to_book_generates_correct_hash( + authenticated_client: AsyncClient, +) -> None: + """Test that adding a file to an existing book generates the correct hash.""" + # Create a book with the first file + first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"] + first_content = first_book["path"].read_bytes() + + files = [("files", (first_book["path"].name, first_content, first_book["content_type"]))] + data = {"library_id": "1"} + + create_response = await authenticated_client.post( + "/books?library_id=1", + files=files, + data=data, + ) + + assert create_response.status_code == 201 + book_id = create_response.json()["id"] + + # Add the second file to the book + second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"] + second_content = second_book["path"].read_bytes() + + add_files = [("data", (second_book["path"].name, second_content, second_book["content_type"]))] + + add_response = await authenticated_client.post( + f"/books/{book_id}/files", + files=add_files, + ) + + assert add_response.status_code == 201 + updated_book = add_response.json() + + # Verify both files have correct hashes + assert len(updated_book["files"]) == 2 + + for file_metadata in updated_book["files"]: + assert "hash" in file_metadata + + epub_file = next(f for f in updated_book["files"] if f["path"].endswith(".epub")) + pdf_file = next(f for f in updated_book["files"] if f["path"].endswith(".pdf")) + + assert epub_file["hash"] == first_book["hash"] + assert pdf_file["hash"] == second_book["hash"]