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 .publisher import PublisherController
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_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

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 .publisher import PublisherService
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,
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)

View File

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

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