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
This commit is contained in:
2026-03-09 14:11:21 -04:00
parent 20a69de968
commit 51c1900d8c
14 changed files with 470 additions and 2 deletions

View File

@@ -6,3 +6,5 @@ from .author import AuthorController
from .tag import TagController from .tag import TagController
from .publisher import PublisherController from .publisher import PublisherController
from .opds import OpdsController from .opds import OpdsController
from .kosync_device import DeviceController
from .kosync_progress import KosyncController

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -3,6 +3,8 @@ from .book import Book, Identifier, FileMetadata
from .book_list import BookList, BookListLink from .book_list import BookList, BookListLink
from .book_progress import BookProgress from .book_progress import BookProgress
from .book_series import BookSeries from .book_series import BookSeries
from .kosync_device import KosyncDevice
from .kosync_progress import KosyncProgress
from .library import Library from .library import Library
from .publisher import Publisher from .publisher import Publisher
from .tag import Tag, BookTagLink from .tag import Tag, BookTagLink

View File

@@ -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})"

View File

@@ -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]]

View File

@@ -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)

View File

@@ -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

View File

@@ -6,3 +6,5 @@ from .author import AuthorService
from .tag import TagService from .tag import TagService
from .publisher import PublisherService from .publisher import PublisherService
from .book_progress import BookProgressService from .book_progress import BookProgressService
from .kosync_device import KosyncDeviceService
from .kosync_progress import KosyncProgressService

View File

@@ -36,6 +36,8 @@ from chitai.services import (
TagService, TagService,
AuthorService, AuthorService,
PublisherService, PublisherService,
KosyncDeviceService,
KosyncProgressService,
) )
from chitai.config import settings from chitai.config import settings
from chitai.services.filters.book import ( from chitai.services.filters.book import (
@@ -340,3 +342,12 @@ 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: async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
return request.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)

View File

@@ -138,7 +138,7 @@ class ProgressFilter(StatementFilter):
m.BookProgress.completed == False, m.BookProgress.completed == False,
m.BookProgress.completed.is_(None), m.BookProgress.completed.is_(None),
), ),
m.BookProgress.progress > 0, m.BookProgress.percentage > 0,
) )
) )
@@ -154,7 +154,6 @@ class ProgressFilter(StatementFilter):
@dataclass @dataclass
class FileFilter(StatementFilter): class FileFilter(StatementFilter):
"""Filter books that are related to the given files.""" """Filter books that are related to the given files."""
file_ids: list[int] file_ids: list[int]
def append_to_statement( def append_to_statement(
@@ -166,6 +165,16 @@ class FileFilter(StatementFilter):
return super().append_to_statement(statement, model, *args, **kwargs) 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 @dataclass
class CustomOrderBy(StatementFilter): class CustomOrderBy(StatementFilter):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]