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:
@@ -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
|
||||
|
||||
54
backend/src/chitai/controllers/kosync_device.py
Normal file
54
backend/src/chitai/controllers/kosync_device.py
Normal 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)
|
||||
90
backend/src/chitai/controllers/kosync_progress.py
Normal file
90
backend/src/chitai/controllers/kosync_progress.py
Normal 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
15
backend/src/chitai/database/models/kosync_device.py
Normal file
15
backend/src/chitai/database/models/kosync_device.py
Normal 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})"
|
||||
26
backend/src/chitai/database/models/kosync_progress.py
Normal file
26
backend/src/chitai/database/models/kosync_progress.py
Normal 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]]
|
||||
37
backend/src/chitai/middleware/kosync_auth.py
Normal file
37
backend/src/chitai/middleware/kosync_auth.py
Normal 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)
|
||||
36
backend/src/chitai/schemas/kosync.py
Normal file
36
backend/src/chitai/schemas/kosync.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
35
backend/src/chitai/services/kosync_device.py
Normal file
35
backend/src/chitai/services/kosync_device.py
Normal 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)
|
||||
51
backend/src/chitai/services/kosync_progress.py
Normal file
51
backend/src/chitai/services/kosync_progress.py
Normal 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)
|
||||
98
backend/tests/integration/test_file_hash.py
Normal file
98
backend/tests/integration/test_file_hash.py
Normal 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"]
|
||||
Reference in New Issue
Block a user