Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/config.docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ komga_api:
enabled: true
prefix: komga # URL prefix: /{prefix}/api/v1/...


# Rate Limiting (enabled by default)
# ==================================
# Protects API endpoints from abuse using token bucket algorithm
Expand All @@ -153,3 +152,6 @@ komga_api:
# - /api/v1/books/*/thumbnail # Exempt book thumbnails
# cleanup_interval_secs: 60 # How often to clean up stale buckets
# bucket_ttl_secs: 300 # Time before a bucket is considered stale

koreader_api:
enabled: true
21 changes: 21 additions & 0 deletions docs/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -17307,6 +17307,13 @@
"description": "Book unique identifier",
"example": "550e8400-e29b-41d4-a716-446655440001"
},
"koreaderHash": {
"type": [
"string",
"null"
],
"description": "KOReader-compatible partial MD5 hash for sync"
},
"libraryId": {
"type": "string",
"format": "uuid",
Expand Down Expand Up @@ -22701,6 +22708,13 @@
"description": "Book unique identifier",
"example": "550e8400-e29b-41d4-a716-446655440001"
},
"koreaderHash": {
"type": [
"string",
"null"
],
"description": "KOReader-compatible partial MD5 hash for sync"
},
"libraryId": {
"type": "string",
"format": "uuid",
Expand Down Expand Up @@ -27014,6 +27028,13 @@
"description": "Book unique identifier",
"example": "550e8400-e29b-41d4-a716-446655440001"
},
"koreaderHash": {
"type": [
"string",
"null"
],
"description": "KOReader-compatible partial MD5 hash for sync"
},
"libraryId": {
"type": "string",
"format": "uuid",
Expand Down
70 changes: 70 additions & 0 deletions docs/docs/third-party-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,76 @@ While Codex is primarily tested with Komic, other Komga-compatible apps may also
Compatibility with apps other than Komic is not officially tested. Your experience may vary.
:::

### KOReader

[KOReader](https://koreader.rocks/) is an open-source e-book reader for E Ink devices and other platforms. Codex supports the KOReader sync protocol, allowing you to sync reading progress between KOReader and Codex.

**Supported formats:** EPUB, PDF, CBZ, CBR

#### Prerequisites

1. **Enable the KOReader API** in your Codex configuration (see [Enabling the KOReader API](#enabling-the-koreader-api) below)
2. **Create an API key** in Codex (see [API Keys](./users/api-keys))
3. **Run a deep scan** so Codex computes KOReader-compatible hashes for your books (see [Deep Scan](./libraries#deep-scan))

#### Setup in KOReader

1. Open a book in KOReader
2. Go to **Top Menu** > **Tools** (🔧) > **Progress sync**
3. Select **Custom sync server**
4. Enter the server settings:
- **Server URL**: `http://your-server:8080/koreader`
- **Username**: Your Codex **API key** (e.g., `codex_abc12345_secretpart123456789`)
- **Password**: Any value (ignored by Codex)
5. Tap **Login** to verify the connection

:::info
KOReader uses the `x-auth-user` header to send the username, which Codex treats as an API key. The password field (`x-auth-key`) is ignored because KOReader MD5-hashes the password before sending it, making direct password verification impossible.
:::

#### How It Works

KOReader identifies books by computing an MD5 hash of the first 4096 bytes of the file. When you enable the KOReader API and run a **deep scan**, Codex computes the same hash for each book and stores it. This allows KOReader to look up books and sync progress.

- **Progress sync is per-user**: Each user's reading progress is tracked independently
- **EPUB progress**: Codex converts between KOReader's DocFragment format and its internal position tracking
- **PDF/CBZ/CBR progress**: Page numbers are synced directly

#### Troubleshooting KOReader

**"Login failed" or 401 Unauthorized:**
- Make sure you're using a Codex **API key** as the username, not your regular username/password
- Verify the API key hasn't expired or been revoked
- Check that `koreader_api.enabled` is `true` in your config

**"Book not found" (404):**
- Run a **deep scan** on your library so Codex computes KOReader hashes
- The book must be in a Codex library; KOReader identifies books by file hash, not filename

**Progress not syncing:**
- Ensure both devices are using the same Codex server and user account
- Check that the book files are identical (same hash) across devices

## Enabling the KOReader API

The KOReader sync API is disabled by default. To enable it:

### Via Configuration File

```yaml
# codex.yaml
koreader_api:
enabled: true
```

### Via Environment Variables

```bash
CODEX_KOREADER_API_ENABLED=true
```

After enabling, restart Codex and run a **deep scan** on your libraries to compute KOReader-compatible file hashes.

## Enabling the Komga API

The Komga-compatible API is disabled by default for security. To enable it:
Expand Down
6 changes: 4 additions & 2 deletions docs/docs/users/api-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,17 @@ The prefix is stored in plaintext for lookup, but the secret is hashed - Codex c

### OPDS / Reader Apps

Minimal permissions for read-only access:
Permissions for e-reader apps, OPDS clients, and KOReader sync:

```json
{
"name": "OPDS Reader",
"permissions": ["LibrariesRead", "SeriesRead", "BooksRead", "PagesRead"]
"permissions": ["LibrariesRead", "SeriesRead", "BooksRead", "PagesRead", "ProgressRead", "ProgressWrite"]
}
```

`ProgressRead` and `ProgressWrite` are needed for apps that sync reading progress (e.g., KOReader).

### Automation Script

For scripts that trigger scans and monitor progress:
Expand Down
13 changes: 13 additions & 0 deletions migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ mod m20260222_000059_add_search_title;
// Add koreader_hash column for KOReader sync
mod m20260309_000060_add_koreader_hash;

// Add r2_progression column for Readium/OPDS 2.0 EPUB progress sync
mod m20260314_000061_add_r2_progression;

// Add epub_positions column for Readium positions list (cross-app sync)
mod m20260315_000062_add_epub_positions;
mod m20260316_000063_add_epub_spine_items;

pub struct Migrator;

#[async_trait::async_trait]
Expand Down Expand Up @@ -219,6 +226,12 @@ impl MigratorTrait for Migrator {
Box::new(m20260222_000059_add_search_title::Migration),
// Add koreader_hash for KOReader sync
Box::new(m20260309_000060_add_koreader_hash::Migration),
// Add r2_progression for Readium EPUB progress sync
Box::new(m20260314_000061_add_r2_progression::Migration),
// Add epub_positions for Readium positions list (cross-app sync)
Box::new(m20260315_000062_add_epub_positions::Migration),
// Add epub_spine_items for char/byte position normalization (cross-device sync)
Box::new(m20260316_000063_add_epub_spine_items::Migration),
]
}
}
29 changes: 29 additions & 0 deletions migration/src/m20260314_000061_add_r2_progression.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("read_progress"))
.add_column(ColumnDef::new(Alias::new("r2_progression")).text())
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("read_progress"))
.drop_column(Alias::new("r2_progression"))
.to_owned(),
)
.await
}
}
29 changes: 29 additions & 0 deletions migration/src/m20260315_000062_add_epub_positions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("books"))
.add_column(ColumnDef::new(Alias::new("epub_positions")).text())
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("books"))
.drop_column(Alias::new("epub_positions"))
.to_owned(),
)
.await
}
}
29 changes: 29 additions & 0 deletions migration/src/m20260316_000063_add_epub_spine_items.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("books"))
.add_column(ColumnDef::new(Alias::new("epub_spine_items")).text())
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("books"))
.drop_column(Alias::new("epub_spine_items"))
.to_owned(),
)
.await
}
}
30 changes: 30 additions & 0 deletions src/api/extractors/auth.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use tracing::debug;

use crate::api::error::ApiError;
use crate::api::permissions::{Permission, UserRole};
use crate::db::repositories::{ApiKeyRepository, UserRepository};
Expand Down Expand Up @@ -256,6 +258,14 @@ impl FromRequestParts<Arc<AppState>> for AuthContext {
return extract_from_api_key(api_key, state).await;
}

// Try KOReader-style x-auth-user header (value is an API key, x-auth-key is ignored)
if let Some(api_key_header) = parts.headers.get("x-auth-user")
&& let Ok(api_key) = api_key_header.to_str()
{
debug!("Attempting KOReader x-auth-user API key authentication");
return extract_from_api_key(api_key, state).await;
}

Err(ApiError::Unauthorized(
"Missing or invalid authentication credentials".to_string(),
))
Expand Down Expand Up @@ -433,6 +443,17 @@ async fn extract_from_basic_auth(
let username = parts[0];
let password = parts[1];

extract_from_credentials(username, password, state).await
}

/// Extract auth context from username/password credentials
///
/// Shared by Basic Auth and KOReader x-auth-user/x-auth-key header authentication.
async fn extract_from_credentials(
username: &str,
password: &str,
state: &AppState,
) -> Result<AuthContext, ApiError> {
// Look up user by username
let user = UserRepository::get_by_username(&state.db, username)
.await
Expand Down Expand Up @@ -516,6 +537,15 @@ impl FromRequestParts<Arc<AppState>> for FlexibleAuthContext {
.map(FlexibleAuthContext);
}

// Try KOReader-style x-auth-user header (API key)
if let Some(api_key_header) = parts.headers.get("x-auth-user")
&& let Ok(api_key) = api_key_header.to_str()
{
return extract_from_api_key(api_key, state)
.await
.map(FlexibleAuthContext);
}

// Try cookie as fallback
if let Some(cookie_header) = parts.headers.get(COOKIE)
&& let Ok(cookie_str) = cookie_header.to_str()
Expand Down
Loading
Loading