fix(kobo): hand the Kobo a sync token in a shape it recognises

Kobos on firmware 4.45 were reported failing every sync on BookOrbit's
prefixed PX tokens and passing on Calibre-Web's. Send Calibre-Web's base64
JSON token, or Kobo's own cursor verbatim when store titles carry one. The
token never located the snapshot, and PX and PX2 tokens still decode.

Fixes #1175
Fixes #435
This commit is contained in:
neon
2026-10-17 04:23:23 +00:00
parent edfa20e19f
commit 06cfc2a074
5 changed files with 118 additions and 86 deletions
@@ -1,5 +1,6 @@
import { BadRequestException, HttpStatus } from '@nestjs/common'; import { BadRequestException, HttpStatus } from '@nestjs/common';
import { encodeSyncToken } from './services/kobo-sync-token';
import { KoboSyncController } from './kobo-sync.controller'; import { KoboSyncController } from './kobo-sync.controller';
function makeReply() { function makeReply() {
@@ -231,7 +232,7 @@ describe('KoboSyncController', () => {
}); });
describe('store sync', () => { describe('store sync', () => {
const SNAPSHOT_TOKEN = `PX.${Buffer.from(JSON.stringify({ snapshotId: 4 })).toString('base64')}`; const SNAPSHOT_TOKEN = encodeSyncToken();
function syncRequest(incomingSyncToken?: string) { function syncRequest(incomingSyncToken?: string) {
return { return {
@@ -280,7 +281,7 @@ describe('KoboSyncController', () => {
await runSync(reply, 'kobo-native-token'); await runSync(reply, 'kobo-native-token');
expect(proxyService.request).not.toHaveBeenCalled(); expect(proxyService.request).not.toHaveBeenCalled();
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'PX2.4.kobo-native-token'); expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'kobo-native-token');
}); });
it('appends store entitlements and stamps Kobo’s cursor into the sync token', async () => { it('appends store entitlements and stamps Kobo’s cursor into the sync token', async () => {
@@ -295,7 +296,7 @@ describe('KoboSyncController', () => {
expect.objectContaining({ extraHeaders: { 'x-kobo-synctoken': 'kobo-cursor-1' } }), expect.objectContaining({ extraHeaders: { 'x-kobo-synctoken': 'kobo-cursor-1' } }),
); );
expect(reply.send).toHaveBeenCalledWith([{ Local: 1 }, { Store: 1 }, { Store: 2 }]); expect(reply.send).toHaveBeenCalledWith([{ Local: 1 }, { Store: 1 }, { Store: 2 }]);
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'PX2.4.kobo-cursor-2'); expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'kobo-cursor-2');
expect(historyService.recordSuccess).toHaveBeenCalledWith( expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({ counts: { entitlements: 3, hasMore: false, storeEntitlements: 2 } }), expect.objectContaining({ counts: { entitlements: 3, hasMore: false, storeEntitlements: 2 } }),
); );
@@ -414,7 +415,7 @@ describe('KoboSyncController', () => {
await runSync(reply, SNAPSHOT_TOKEN); await runSync(reply, SNAPSHOT_TOKEN);
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue'); expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue');
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', `PX2.4.${continuation}`); expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', continuation);
}); });
it('preserves a known Kobo cursor across a failed store call so paging resumes next sync', async () => { it('preserves a known Kobo cursor across a failed store call so paging resumes next sync', async () => {
@@ -423,7 +424,7 @@ describe('KoboSyncController', () => {
await runSync(reply, `PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`); await runSync(reply, `PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`);
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'PX2.4.kobo-cursor-1'); expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'kobo-cursor-1');
}); });
it('forwards the Kobo cursor from a compact token upstream', async () => { it('forwards the Kobo cursor from a compact token upstream', async () => {
@@ -1,42 +1,59 @@
import { decodeSyncToken, encodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './kobo-sync-token'; import { decodeSyncToken, encodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './kobo-sync-token';
function base64Json(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64');
}
describe('kobo sync token', () => { describe('kobo sync token', () => {
it('keeps the pre-store-sync token shape when there is no upstream cursor', () => { it('hands the device the token Calibre-Web sends when there is no upstream cursor (#1175)', () => {
const token = encodeSyncToken(7); const token = encodeSyncToken();
expect(token).toBe(`PX.${Buffer.from(JSON.stringify({ snapshotId: 7 })).toString('base64')}`); expect(JSON.parse(Buffer.from(token, 'base64').toString('utf8'))).toEqual({
expect(decodeSyncToken(token)).toEqual({ snapshotId: 7, koboSyncToken: undefined }); version: '1-1-0',
data: {
raw_kobo_store_token: '',
books_last_modified: -62135596800,
books_last_created: -62135596800,
archive_last_modified: -62135596800,
reading_state_last_modified: -62135596800,
tags_last_modified: -62135596800,
},
});
expect(token).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(decodeSyncToken(token)).toEqual({});
}); });
it('round-trips both cursors', () => { it('hands the device Kobo’s own cursor verbatim when there is one', () => {
expect(decodeSyncToken(encodeSyncToken(7, 'kobo-cursor'))).toEqual({ snapshotId: 7, koboSyncToken: 'kobo-cursor' });
});
it('carries Kobo’s cursor verbatim instead of re-encoding it', () => {
const cursor = 'eyJJbnRlcm5hbFN5bmNUb2tlbiI6ImV5SjAifQ.eyJ0eXAiOjF9'; const cursor = 'eyJJbnRlcm5hbFN5bmNUb2tlbiI6ImV5SjAifQ.eyJ0eXAiOjF9';
expect(encodeSyncToken(7, cursor)).toBe(`PX2.7.${cursor}`); expect(encodeSyncToken(cursor)).toBe(cursor);
expect(decodeSyncToken(`PX2.7.${cursor}`)).toEqual({ snapshotId: 7, koboSyncToken: cursor }); expect(decodeSyncToken(cursor)).toEqual({ koboSyncToken: cursor });
}); });
it('still reads a Kobo cursor from tokens minted in the JSON format', () => { it('reads the store cursor out of a Calibre-Web token, ours or one Calibre-Web left behind', () => {
const legacy = `PX.${Buffer.from(JSON.stringify({ snapshotId: 3, koboSyncToken: 'kobo-cursor' })).toString('base64')}`; const token = base64Json({ data: { raw_kobo_store_token: 'kobo-cursor', books_last_modified: 1700000000 }, version: '1-1-0' });
expect(decodeSyncToken(legacy)).toEqual({ snapshotId: 3, koboSyncToken: 'kobo-cursor' }); expect(decodeSyncToken(token)).toEqual({ koboSyncToken: 'kobo-cursor' });
});
it('still reads the cursor from compact PX2 tokens devices already hold', () => {
const cursor = 'eyJJbnRlcm5hbFN5bmNUb2tlbiI6ImV5SjAifQ.eyJ0eXAiOjF9';
expect(decodeSyncToken(`PX2.7.${cursor}`)).toEqual({ koboSyncToken: cursor });
expect(decodeSyncToken('PX2.7')).toEqual({});
});
it('still reads PX tokens, with and without a store cursor', () => {
expect(decodeSyncToken(`PX.${base64Json({ snapshotId: 3, koboSyncToken: 'kobo-cursor' })}`)).toEqual({ koboSyncToken: 'kobo-cursor' });
expect(decodeSyncToken(`PX.${base64Json({ snapshotId: 3 })}`)).toEqual({});
});
it('treats an unprefixed token it cannot parse as Kobo’s own, for devices that synced with Kobo first', () => {
expect(decodeSyncToken('kobo-opaque-token')).toEqual({ koboSyncToken: 'kobo-opaque-token' });
}); });
it('keeps the header within a proxy line limit for the largest cursor it carries', () => { it('keeps the header within a proxy line limit for the largest cursor it carries', () => {
expect(encodeSyncToken(2_147_483_647, 'x'.repeat(7000)).length).toBeLessThan(8000); expect(encodeSyncToken('x'.repeat(7000)).length).toBeLessThan(8000);
});
it('reads tokens minted before store sync existed', () => {
const legacy = `PX.${Buffer.from(JSON.stringify({ snapshotId: 3 })).toString('base64')}`;
expect(decodeSyncToken(legacy)).toEqual({ snapshotId: 3, koboSyncToken: undefined });
});
it('treats an unprefixed token as Kobo’s own, for devices that synced with Kobo first', () => {
expect(decodeSyncToken('kobo-opaque-token')).toEqual({ koboSyncToken: 'kobo-opaque-token' });
}); });
it.each([ it.each([
@@ -44,13 +61,13 @@ describe('kobo sync token', () => {
['empty', ''], ['empty', ''],
['whitespace', ' '], ['whitespace', ' '],
['malformed base64 payload', 'PX.!!!not-base64!!!'], ['malformed base64 payload', 'PX.!!!not-base64!!!'],
['a JSON array payload', `PX.${Buffer.from(JSON.stringify([1, 2])).toString('base64')}`], ['a JSON array payload', `PX.${base64Json([1, 2])}`],
['a non-object payload', `PX.${Buffer.from(JSON.stringify('nope')).toString('base64')}`], ['a non-object payload', `PX.${base64Json('nope')}`],
['a compact token with a non-numeric snapshot', 'PX2.abc.kobo-cursor'], ['a Calibre-Web token with no data', base64Json({ version: '1-1-0' })],
['a Calibre-Web token with a non-string cursor', base64Json({ data: { raw_kobo_store_token: 42 } })],
['a compact token with nothing after the prefix', 'PX2.'], ['a compact token with nothing after the prefix', 'PX2.'],
])('never throws on %s', (_label, raw) => { ])('never throws on %s', (_label, raw) => {
expect(() => decodeSyncToken(raw)).not.toThrow(); expect(() => decodeSyncToken(raw)).not.toThrow();
expect(decodeSyncToken(raw).snapshotId).toBeUndefined();
}); });
it('reports which upstream cursors can be carried, so callers can log the ones that cannot', () => { it('reports which upstream cursors can be carried, so callers can log the ones that cannot', () => {
@@ -64,20 +81,17 @@ describe('kobo sync token', () => {
const oversized = 'x'.repeat(7001); const oversized = 'x'.repeat(7001);
expect(decodeSyncToken(oversized)).toEqual({}); expect(decodeSyncToken(oversized)).toEqual({});
expect(decodeSyncToken(encodeSyncToken(7, oversized))).toEqual({ snapshotId: 7, koboSyncToken: undefined }); expect(encodeSyncToken(oversized)).toBe(encodeSyncToken());
expect(withKoboSyncToken(encodeSyncToken(), oversized)).toBe(encodeSyncToken());
}); });
it('leaves the token untouched when there is no upstream cursor to stamp', () => { it('leaves the token untouched when there is no upstream cursor to stamp', () => {
const token = encodeSyncToken(7); const token = encodeSyncToken();
expect(withKoboSyncToken(token, undefined)).toBe(token); expect(withKoboSyncToken(token, undefined)).toBe(token);
}); });
it('stamps the upstream cursor onto a token minted by the sync service', () => { it('replaces the token minted by the sync service with the upstream cursor', () => {
expect(withKoboSyncToken(encodeSyncToken(7), 'kobo-cursor')).toBe(encodeSyncToken(7, 'kobo-cursor')); expect(withKoboSyncToken(encodeSyncToken(), 'kobo-cursor')).toBe('kobo-cursor');
});
it('returns an unreadable token unchanged rather than inventing a snapshot', () => {
expect(withKoboSyncToken('PX.!!!', 'kobo-cursor')).toBe('PX.!!!');
}); });
}); });
@@ -1,17 +1,22 @@
// Older BookOrbit tokens, still sent back by devices that last synced before the Kobo-standard shape.
const TOKEN_PREFIX = 'PX.'; const TOKEN_PREFIX = 'PX.';
// Carries Kobo's cursor verbatim after the snapshot id: `PX2.<snapshotId>.<kobo token>`. Kobo's token
// already travels in a header, and wrapping it in JSON and base64 again grew it by a third.
const COMPACT_TOKEN_PREFIX = 'PX2.'; const COMPACT_TOKEN_PREFIX = 'PX2.';
// Kobo's own sync tokens are opaque and we round-trip them through the device inside ours, so a // A Kobo on firmware 4.45 was reported failing every sync on BookOrbit's prefixed token and passing
// hostile or corrupted upstream value cannot be allowed to grow the response header without bound. // on Calibre-Web's (#1175), so a device is only ever handed a shape Kobo devices are known to take:
// A small store library already produces a 3,300 character cursor and continuation pages carry more, // Kobo's own cursor verbatim, or the base64 JSON token Calibre-Web has always sent. The token
// so this leaves room for those while the whole header stays under the common 8 KB proxy line limit. // identifies no snapshot; the sync service finds that by device.
const CALIBRE_WEB_TOKEN_VERSION = '1-1-0';
const CALIBRE_WEB_EPOCH_FLOOR = -62135596800;
// Kobo's own sync tokens are opaque and we round-trip them through the device, so a hostile or
// corrupted upstream value cannot be allowed to grow the response header without bound. A small
// store library already produces a 3,300 character cursor and continuation pages carry more, so this
// leaves room for those while the whole header stays under the common 8 KB proxy line limit.
// Anything larger is dropped, and store paging for that sync stops instead of restarting. // Anything larger is dropped, and store paging for that sync stops instead of restarting.
const MAX_KOBO_SYNC_TOKEN_LENGTH = 7000; const MAX_KOBO_SYNC_TOKEN_LENGTH = 7000;
export type KoboSyncTokenParts = { export type KoboSyncTokenParts = {
snapshotId?: number;
koboSyncToken?: string; koboSyncToken?: string;
}; };
@@ -23,17 +28,28 @@ export function isUsableKoboSyncToken(koboSyncToken: string): boolean {
return koboSyncToken.length > 0 && koboSyncToken.length <= MAX_KOBO_SYNC_TOKEN_LENGTH; return koboSyncToken.length > 0 && koboSyncToken.length <= MAX_KOBO_SYNC_TOKEN_LENGTH;
} }
export function encodeSyncToken(snapshotId: number, koboSyncToken?: string): string { export function encodeSyncToken(koboSyncToken?: string): string {
const usableKoboToken = koboSyncToken && isUsableKoboSyncToken(koboSyncToken) ? koboSyncToken : undefined; if (koboSyncToken && isUsableKoboSyncToken(koboSyncToken)) return koboSyncToken;
if (usableKoboToken) return `${COMPACT_TOKEN_PREFIX}${snapshotId}.${usableKoboToken}`; return Buffer.from(
return TOKEN_PREFIX + Buffer.from(JSON.stringify({ snapshotId })).toString('base64'); JSON.stringify({
version: CALIBRE_WEB_TOKEN_VERSION,
data: {
raw_kobo_store_token: '',
books_last_modified: CALIBRE_WEB_EPOCH_FLOOR,
books_last_created: CALIBRE_WEB_EPOCH_FLOOR,
archive_last_modified: CALIBRE_WEB_EPOCH_FLOOR,
reading_state_last_modified: CALIBRE_WEB_EPOCH_FLOOR,
tags_last_modified: CALIBRE_WEB_EPOCH_FLOOR,
},
}),
).toString('base64');
} }
/** /**
* Tokens minted before store sync existed carry only {snapshotId}, and a device that synced against * Reads the upstream cursor out of whatever token the device sends back: Kobo's own, a Calibre-Web
* Kobo directly before BookOrbit was put in the path sends Kobo's own opaque token. Neither shape * shaped one (ours, or one left by Calibre-Web before BookOrbit was put in the path), or an older
* may throw: getDelta finds the snapshot by device rather than by token, so a token we cannot read * BookOrbit `PX.` / `PX2.` token. None of them may throw: a token we cannot read costs a store page,
* costs a store page, never a broken sync. * never a broken sync.
*/ */
export function decodeSyncToken(raw: string | undefined): KoboSyncTokenParts { export function decodeSyncToken(raw: string | undefined): KoboSyncTokenParts {
const value = raw?.trim(); const value = raw?.trim();
@@ -41,39 +57,39 @@ export function decodeSyncToken(raw: string | undefined): KoboSyncTokenParts {
if (value.startsWith(COMPACT_TOKEN_PREFIX)) { if (value.startsWith(COMPACT_TOKEN_PREFIX)) {
const rest = value.slice(COMPACT_TOKEN_PREFIX.length); const rest = value.slice(COMPACT_TOKEN_PREFIX.length);
const separator = rest.indexOf('.'); const separator = rest.indexOf('.');
const id = separator === -1 ? rest : rest.slice(0, separator); return usableCursor(separator === -1 ? '' : rest.slice(separator + 1));
const koboSyncToken = separator === -1 ? '' : rest.slice(separator + 1);
return {
snapshotId: /^\d{1,15}$/.test(id) ? Number(id) : undefined,
koboSyncToken: isUsableKoboSyncToken(koboSyncToken) ? koboSyncToken : undefined,
};
} }
if (!value.startsWith(TOKEN_PREFIX)) { if (value.startsWith(TOKEN_PREFIX)) {
return isUsableKoboSyncToken(value) ? { koboSyncToken: value } : {}; const decoded = parseBase64Json(value.slice(TOKEN_PREFIX.length));
return usableCursor(decoded?.koboSyncToken);
} }
const calibreWeb = parseBase64Json(value);
try { if (calibreWeb && typeof calibreWeb.data === 'object' && calibreWeb.data !== null && !Array.isArray(calibreWeb.data)) {
const decoded: unknown = JSON.parse(Buffer.from(value.slice(TOKEN_PREFIX.length), 'base64').toString('utf8')); return usableCursor((calibreWeb.data as Record<string, unknown>).raw_kobo_store_token);
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) return {};
const { snapshotId, koboSyncToken } = decoded as Record<string, unknown>;
const usableKoboToken = typeof koboSyncToken === 'string' && isUsableKoboSyncToken(koboSyncToken) ? koboSyncToken : undefined;
return {
snapshotId: typeof snapshotId === 'number' && Number.isFinite(snapshotId) ? snapshotId : undefined,
koboSyncToken: usableKoboToken,
};
} catch {
return {};
} }
return usableCursor(value);
} }
/** /**
* Re-stamps a token minted by KoboSyncService with the upstream cursor. Returns the token unchanged * Re-stamps a token minted by KoboSyncService with the upstream cursor, which then travels alone.
* when there is no upstream cursor to carry, so a sync with store sync off is byte-identical to one * Returns the token unchanged when there is no upstream cursor to carry.
* from before store sync existed.
*/ */
export function withKoboSyncToken(compositeToken: string, koboSyncToken: string | undefined): string { export function withKoboSyncToken(compositeToken: string, koboSyncToken: string | undefined): string {
if (!koboSyncToken) return compositeToken; if (!koboSyncToken || !isUsableKoboSyncToken(koboSyncToken)) return compositeToken;
const { snapshotId } = decodeSyncToken(compositeToken); return encodeSyncToken(koboSyncToken);
if (snapshotId === undefined) return compositeToken; }
return encodeSyncToken(snapshotId, koboSyncToken);
function usableCursor(value: unknown): KoboSyncTokenParts {
return typeof value === 'string' && isUsableKoboSyncToken(value) ? { koboSyncToken: value } : {};
}
// Kobo's own cursor is dotted base64url, which never parses to a JSON object here.
function parseBase64Json(value: string): Record<string, unknown> | null {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
try {
const decoded: unknown = JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
return decoded && typeof decoded === 'object' && !Array.isArray(decoded) ? (decoded as Record<string, unknown>) : null;
} catch {
return null;
}
} }
@@ -1,6 +1,7 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import * as schema from '../../../db/schema'; import * as schema from '../../../db/schema';
import { encodeSyncToken } from './kobo-sync-token';
import { KoboSyncService } from './kobo-sync.service'; import { KoboSyncService } from './kobo-sync.service';
// Recursively counts bound query params (drizzle-orm's `Param` chunks) in a built SQL tree, // Recursively counts bound query params (drizzle-orm's `Param` chunks) in a built SQL tree,
@@ -511,7 +512,7 @@ describe('KoboSyncService', () => {
expect(result).toEqual({ expect(result).toEqual({
entitlements: [{ ChangedTag: {} }], entitlements: [{ ChangedTag: {} }],
hasMore: false, hasMore: false,
syncToken: expect.stringMatching(/^PX\./), syncToken: encodeSyncToken(),
}); });
}); });
it('getPageFromSnapshot returns page entitlements for removed/new/changed books', async () => { it('getPageFromSnapshot returns page entitlements for removed/new/changed books', async () => {
@@ -620,7 +620,7 @@ export class KoboSyncService {
smartScopeMatchCache: SmartScopeMatchCache, smartScopeMatchCache: SmartScopeMatchCache,
resolveEligibleIds: EligibleIdsResolver, resolveEligibleIds: EligibleIdsResolver,
): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> { ): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
const syncToken = encodeSyncToken(snapshotId); const syncToken = encodeSyncToken();
const pending = await this.db const pending = await this.db
.select() .select()