Skip to content

Commit 1981b53

Browse files
committed
feat(koreader): add EPUB progress sync, permission model, and API key editing
Convert between KOReader's DocFragment format and Codex's R2Progression so EPUB reading progress is shared across all clients (web reader, KOReader, OPDS apps). Previously, KOReader sync only stored raw page numbers, breaking cross-client EPUB progress. Fix the KOReader partial hash algorithm to match LuaJIT's bit.lshift 32-bit wrapping semantics (i=-1 offset is 0, not 256). Recompute koreader_hash during both scan and analysis, and expose it in the book API/DTOs. Add ProgressRead/ProgressWrite permissions so API keys can be scoped for progress sync without granting broader access. Include these in all role presets (reader, maintainer, admin) and the OPDS preset. Add x-auth-user header authentication for KOReader's login flow, which sends the API key as a username rather than a Bearer token. Add API key permission editing UI with an edit modal on the profile settings page. Update OPDS preset to include progress permissions. Add KOReader setup documentation with troubleshooting guide.
1 parent 5169033 commit 1981b53

19 files changed

Lines changed: 683 additions & 77 deletions

File tree

config/config.docker.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,6 @@ komga_api:
136136
enabled: true
137137
prefix: komga # URL prefix: /{prefix}/api/v1/...
138138

139-
140139
# Rate Limiting (enabled by default)
141140
# ==================================
142141
# Protects API endpoints from abuse using token bucket algorithm
@@ -153,3 +152,6 @@ komga_api:
153152
# - /api/v1/books/*/thumbnail # Exempt book thumbnails
154153
# cleanup_interval_secs: 60 # How often to clean up stale buckets
155154
# bucket_ttl_secs: 300 # Time before a bucket is considered stale
155+
156+
koreader_api:
157+
enabled: true

docs/api/openapi.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17307,6 +17307,13 @@
1730717307
"description": "Book unique identifier",
1730817308
"example": "550e8400-e29b-41d4-a716-446655440001"
1730917309
},
17310+
"koreaderHash": {
17311+
"type": [
17312+
"string",
17313+
"null"
17314+
],
17315+
"description": "KOReader-compatible partial MD5 hash for sync"
17316+
},
1731017317
"libraryId": {
1731117318
"type": "string",
1731217319
"format": "uuid",
@@ -22701,6 +22708,13 @@
2270122708
"description": "Book unique identifier",
2270222709
"example": "550e8400-e29b-41d4-a716-446655440001"
2270322710
},
22711+
"koreaderHash": {
22712+
"type": [
22713+
"string",
22714+
"null"
22715+
],
22716+
"description": "KOReader-compatible partial MD5 hash for sync"
22717+
},
2270422718
"libraryId": {
2270522719
"type": "string",
2270622720
"format": "uuid",
@@ -27014,6 +27028,13 @@
2701427028
"description": "Book unique identifier",
2701527029
"example": "550e8400-e29b-41d4-a716-446655440001"
2701627030
},
27031+
"koreaderHash": {
27032+
"type": [
27033+
"string",
27034+
"null"
27035+
],
27036+
"description": "KOReader-compatible partial MD5 hash for sync"
27037+
},
2701727038
"libraryId": {
2701827039
"type": "string",
2701927040
"format": "uuid",

docs/docs/third-party-apps.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,76 @@ While Codex is primarily tested with Komic, other Komga-compatible apps may also
3838
Compatibility with apps other than Komic is not officially tested. Your experience may vary.
3939
:::
4040

41+
### KOReader
42+
43+
[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.
44+
45+
**Supported formats:** EPUB, PDF, CBZ, CBR
46+
47+
#### Prerequisites
48+
49+
1. **Enable the KOReader API** in your Codex configuration (see [Enabling the KOReader API](#enabling-the-koreader-api) below)
50+
2. **Create an API key** in Codex (see [API Keys](./users/api-keys))
51+
3. **Run a deep scan** so Codex computes KOReader-compatible hashes for your books (see [Deep Scan](./libraries#deep-scan))
52+
53+
#### Setup in KOReader
54+
55+
1. Open a book in KOReader
56+
2. Go to **Top Menu** > **Tools** (🔧) > **Progress sync**
57+
3. Select **Custom sync server**
58+
4. Enter the server settings:
59+
- **Server URL**: `http://your-server:8080/koreader`
60+
- **Username**: Your Codex **API key** (e.g., `codex_abc12345_secretpart123456789`)
61+
- **Password**: Any value (ignored by Codex)
62+
5. Tap **Login** to verify the connection
63+
64+
:::info
65+
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.
66+
:::
67+
68+
#### How It Works
69+
70+
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.
71+
72+
- **Progress sync is per-user**: Each user's reading progress is tracked independently
73+
- **EPUB progress**: Codex converts between KOReader's DocFragment format and its internal position tracking
74+
- **PDF/CBZ/CBR progress**: Page numbers are synced directly
75+
76+
#### Troubleshooting KOReader
77+
78+
**"Login failed" or 401 Unauthorized:**
79+
- Make sure you're using a Codex **API key** as the username, not your regular username/password
80+
- Verify the API key hasn't expired or been revoked
81+
- Check that `koreader_api.enabled` is `true` in your config
82+
83+
**"Book not found" (404):**
84+
- Run a **deep scan** on your library so Codex computes KOReader hashes
85+
- The book must be in a Codex library; KOReader identifies books by file hash, not filename
86+
87+
**Progress not syncing:**
88+
- Ensure both devices are using the same Codex server and user account
89+
- Check that the book files are identical (same hash) across devices
90+
91+
## Enabling the KOReader API
92+
93+
The KOReader sync API is disabled by default. To enable it:
94+
95+
### Via Configuration File
96+
97+
```yaml
98+
# codex.yaml
99+
koreader_api:
100+
enabled: true
101+
```
102+
103+
### Via Environment Variables
104+
105+
```bash
106+
CODEX_KOREADER_API_ENABLED=true
107+
```
108+
109+
After enabling, restart Codex and run a **deep scan** on your libraries to compute KOReader-compatible file hashes.
110+
41111
## Enabling the Komga API
42112

43113
The Komga-compatible API is disabled by default for security. To enable it:

docs/docs/users/api-keys.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,17 @@ The prefix is stored in plaintext for lookup, but the secret is hashed - Codex c
141141

142142
### OPDS / Reader Apps
143143

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

146146
```json
147147
{
148148
"name": "OPDS Reader",
149-
"permissions": ["LibrariesRead", "SeriesRead", "BooksRead", "PagesRead"]
149+
"permissions": ["LibrariesRead", "SeriesRead", "BooksRead", "PagesRead", "ProgressRead", "ProgressWrite"]
150150
}
151151
```
152152

153+
`ProgressRead` and `ProgressWrite` are needed for apps that sync reading progress (e.g., KOReader).
154+
153155
### Automation Script
154156

155157
For scripts that trigger scans and monitor progress:

src/api/extractors/auth.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use tracing::debug;
2+
13
use crate::api::error::ApiError;
24
use crate::api::permissions::{Permission, UserRole};
35
use crate::db::repositories::{ApiKeyRepository, UserRepository};
@@ -256,6 +258,14 @@ impl FromRequestParts<Arc<AppState>> for AuthContext {
256258
return extract_from_api_key(api_key, state).await;
257259
}
258260

261+
// Try KOReader-style x-auth-user header (value is an API key, x-auth-key is ignored)
262+
if let Some(api_key_header) = parts.headers.get("x-auth-user")
263+
&& let Ok(api_key) = api_key_header.to_str()
264+
{
265+
debug!("Attempting KOReader x-auth-user API key authentication");
266+
return extract_from_api_key(api_key, state).await;
267+
}
268+
259269
Err(ApiError::Unauthorized(
260270
"Missing or invalid authentication credentials".to_string(),
261271
))
@@ -433,6 +443,17 @@ async fn extract_from_basic_auth(
433443
let username = parts[0];
434444
let password = parts[1];
435445

446+
extract_from_credentials(username, password, state).await
447+
}
448+
449+
/// Extract auth context from username/password credentials
450+
///
451+
/// Shared by Basic Auth and KOReader x-auth-user/x-auth-key header authentication.
452+
async fn extract_from_credentials(
453+
username: &str,
454+
password: &str,
455+
state: &AppState,
456+
) -> Result<AuthContext, ApiError> {
436457
// Look up user by username
437458
let user = UserRepository::get_by_username(&state.db, username)
438459
.await
@@ -516,6 +537,15 @@ impl FromRequestParts<Arc<AppState>> for FlexibleAuthContext {
516537
.map(FlexibleAuthContext);
517538
}
518539

540+
// Try KOReader-style x-auth-user header (API key)
541+
if let Some(api_key_header) = parts.headers.get("x-auth-user")
542+
&& let Ok(api_key) = api_key_header.to_str()
543+
{
544+
return extract_from_api_key(api_key, state)
545+
.await
546+
.map(FlexibleAuthContext);
547+
}
548+
519549
// Try cookie as fallback
520550
if let Some(cookie_header) = parts.headers.get(COOKIE)
521551
&& let Ok(cookie_str) = cookie_header.to_str()

src/api/permissions.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ pub enum Permission {
9494
// Pages (image serving)
9595
PagesRead,
9696

97+
// Progress (reading progress tracking)
98+
ProgressRead,
99+
ProgressWrite,
100+
97101
// Users (admin only)
98102
UsersRead,
99103
UsersWrite,
@@ -131,6 +135,8 @@ impl Permission {
131135
Permission::BooksWrite => "books:write",
132136
Permission::BooksDelete => "books:delete",
133137
Permission::PagesRead => "pages:read",
138+
Permission::ProgressRead => "progress:read",
139+
Permission::ProgressWrite => "progress:write",
134140
Permission::UsersRead => "users:read",
135141
Permission::UsersWrite => "users:write",
136142
Permission::UsersDelete => "users:delete",
@@ -161,6 +167,8 @@ impl FromStr for Permission {
161167
"books:write" => Ok(Permission::BooksWrite),
162168
"books:delete" => Ok(Permission::BooksDelete),
163169
"pages:read" => Ok(Permission::PagesRead),
170+
"progress:read" => Ok(Permission::ProgressRead),
171+
"progress:write" => Ok(Permission::ProgressWrite),
164172
"users:read" => Ok(Permission::UsersRead),
165173
"users:write" => Ok(Permission::UsersWrite),
166174
"users:delete" => Ok(Permission::UsersDelete),
@@ -199,6 +207,8 @@ lazy_static::lazy_static! {
199207
set.insert(Permission::SeriesRead);
200208
set.insert(Permission::BooksRead);
201209
set.insert(Permission::PagesRead);
210+
set.insert(Permission::ProgressRead);
211+
set.insert(Permission::ProgressWrite);
202212
set.insert(Permission::SystemHealth);
203213
set
204214
};
@@ -217,6 +227,9 @@ lazy_static::lazy_static! {
217227
set.insert(Permission::SeriesRead);
218228
set.insert(Permission::BooksRead);
219229
set.insert(Permission::PagesRead);
230+
// Progress tracking
231+
set.insert(Permission::ProgressRead);
232+
set.insert(Permission::ProgressWrite);
220233
// Own API keys
221234
set.insert(Permission::ApiKeysRead);
222235
set.insert(Permission::ApiKeysWrite);
@@ -333,7 +346,7 @@ mod tests {
333346
assert!(READONLY_PERMISSIONS.contains(&Permission::LibrariesRead));
334347
assert!(READONLY_PERMISSIONS.contains(&Permission::BooksRead));
335348
assert!(!READONLY_PERMISSIONS.contains(&Permission::LibrariesWrite));
336-
assert_eq!(READONLY_PERMISSIONS.len(), 5);
349+
assert_eq!(READONLY_PERMISSIONS.len(), 7);
337350
}
338351

339352
// ============== Role permission preset tests ==============
@@ -354,6 +367,9 @@ mod tests {
354367
// Reader cannot view or manage tasks
355368
assert!(!READER_PERMISSIONS.contains(&Permission::TasksRead));
356369
assert!(!READER_PERMISSIONS.contains(&Permission::TasksWrite));
370+
// Reader can track reading progress
371+
assert!(READER_PERMISSIONS.contains(&Permission::ProgressRead));
372+
assert!(READER_PERMISSIONS.contains(&Permission::ProgressWrite));
357373
// Reader cannot modify content
358374
assert!(!READER_PERMISSIONS.contains(&Permission::BooksWrite));
359375
assert!(!READER_PERMISSIONS.contains(&Permission::SeriesWrite));
@@ -362,7 +378,7 @@ mod tests {
362378
assert!(!READER_PERMISSIONS.contains(&Permission::UsersRead));
363379
assert!(!READER_PERMISSIONS.contains(&Permission::SystemAdmin));
364380

365-
assert_eq!(READER_PERMISSIONS.len(), 8);
381+
assert_eq!(READER_PERMISSIONS.len(), 10);
366382
}
367383

368384
#[test]
@@ -389,7 +405,7 @@ mod tests {
389405
assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::UsersRead));
390406
assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::SystemAdmin));
391407

392-
assert_eq!(MAINTAINER_PERMISSIONS.len(), 15);
408+
assert_eq!(MAINTAINER_PERMISSIONS.len(), 17);
393409
}
394410

395411
#[test]
@@ -413,7 +429,7 @@ mod tests {
413429
// Admin has system admin
414430
assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin));
415431

416-
assert_eq!(ADMIN_PERMISSIONS.len(), 21); // All permissions
432+
assert_eq!(ADMIN_PERMISSIONS.len(), 23); // All permissions
417433
}
418434

419435
// ============== UserRole tests ==============

0 commit comments

Comments
 (0)