feat(kobo): deliver Kobo store titles and fix store cover 404s

Kobo sync replaces the store API for the device but only ever answered for
BookOrbit's own books, so anything owned through Kobo fell through the gaps.

Library sync never queried Kobo, so Kobo Plus and purchased titles had no route
to the device at all. Add an opt-in per-user store sync that pulls the device's
upstream entitlements with the device's own Kobo credentials, which still reach
us because initialization leaves device_auth and device_refresh pointing at
Kobo, and appends them to BookOrbit's own. Both cursors ride in one composite
sync token, so no server-side state is needed and tokens minted before this
change keep working. The store is only consulted once BookOrbit has drained its
own pages, and paging stops if Kobo's cursor fails to advance.

Everything on the store path stays inside one guarantee: reading the setting, or
a store that is slow, down, unauthorized or answering with a body we cannot use,
costs the store page and never the BookOrbit half of the sync.

Initialization also rewrites the image templates to point at BookOrbit, so the
device asked us for store covers too. Those were forwarded to storeapi.kobo.com,
which does not serve images, and 404d. Redirect unresolved cover ids to Kobo's
image CDN instead. This is not gated on store sync, since browsing the store
shows those covers whether or not the setting is on.

Deliberately left alone: annotation pulls still 404 for content that is not a
BookOrbit book. The device reconciles annotations by omission, so answering with
an empty set would wipe its local highlights.

Closes #789
This commit is contained in:
neon
2026-08-10 23:45:56 +00:00
parent 80223b5e67
commit 2d09c37806
23 changed files with 16845 additions and 82 deletions
@@ -0,0 +1,76 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { KoboSyncSettings } from '@bookorbit/types'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
vi.mock('@/lib/api', () => ({
api: apiMock,
}))
// The composable keeps settings in a module-level ref, so each test takes a fresh module
// instance rather than inheriting whatever the previous test left behind.
async function freshComposable() {
vi.resetModules()
const { useKoboSettings } = await import('../useKoboSettings')
return useKoboSettings()
}
function makeSettings(overrides: Partial<KoboSyncSettings> = {}): KoboSyncSettings {
return {
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
syncBookOrbitAnnotationsToKobo: false,
storeSync: false,
...overrides,
}
}
function makeResponse(data: unknown, ok = true): Response {
return { ok, status: ok ? 200 : 500, json: async () => data } as Response
}
describe('useKoboSettings', () => {
beforeEach(() => {
apiMock.mockReset()
})
it('defaults store sync to off before any fetch resolves', async () => {
const { settings } = await freshComposable()
expect(settings.value.storeSync).toBe(false)
})
it('adopts the store sync flag the server reports', async () => {
apiMock.mockResolvedValue(makeResponse(makeSettings({ storeSync: true })))
const { settings, fetchSettings } = await freshComposable()
await fetchSettings()
expect(settings.value.storeSync).toBe(true)
})
it('sends the store sync flag in the patch body and adopts the response', async () => {
apiMock.mockResolvedValue(makeResponse(makeSettings({ storeSync: true })))
const { settings, updateSettings } = await freshComposable()
await updateSettings({ storeSync: true })
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/kobo/settings',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ storeSync: true }) }),
)
expect(settings.value.storeSync).toBe(true)
})
it('surfaces the server message when an update is rejected', async () => {
apiMock.mockResolvedValue(makeResponse({ message: 'Reading threshold must be less than finished threshold' }, false))
const { updateSettings } = await freshComposable()
await expect(updateSettings({ storeSync: true })).rejects.toThrow('Reading threshold must be less than finished threshold')
})
})
@@ -10,6 +10,7 @@ const settings = ref<KoboSyncSettings>({
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
syncBookOrbitAnnotationsToKobo: false,
storeSync: false,
})
let fetchPromise: Promise<void> | null = null
@@ -46,6 +46,7 @@ const forceEnableHyphenation = ref(false)
const kepubConversionLimitMb = ref(100)
const twoWayProgressSync = ref(false)
const syncBookOrbitAnnotationsToKobo = ref(false)
const storeSync = ref(false)
const savingSettings = ref(false)
const settingsError = ref<string | null>(null)
const refreshingHistory = ref(false)
@@ -118,6 +119,7 @@ function applySettingsToLocal() {
kepubConversionLimitMb.value = settings.value.kepubConversionLimitMb
twoWayProgressSync.value = settings.value.twoWayProgressSync
syncBookOrbitAnnotationsToKobo.value = settings.value.syncBookOrbitAnnotationsToKobo
storeSync.value = settings.value.storeSync
}
function formatLastSeen(date: string | null): string {
@@ -615,6 +617,7 @@ async function saveSettings() {
kepubConversionLimitMb: kepubConversionLimitMb.value,
twoWayProgressSync: twoWayProgressSync.value,
syncBookOrbitAnnotationsToKobo: syncBookOrbitAnnotationsToKobo.value,
storeSync: storeSync.value,
})
applySettingsToLocal()
toast.success(t('settings.reader.kobo.settingsSaved'))
@@ -999,6 +1002,16 @@ async function saveSettings() {
<ToggleSwitch v-model="syncBookOrbitAnnotationsToKobo" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div class="pr-8">
<p class="settings-label">{{ t('settings.reader.kobo.storeSync') }}</p>
<p class="settings-hint">
{{ t('settings.reader.kobo.storeSyncHint') }}
</p>
</div>
<ToggleSwitch v-model="storeSync" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div class="pr-8">
<p class="settings-label">{{ t('settings.reader.kobo.convertKepub') }}</p>
+2
View File
@@ -1876,6 +1876,8 @@
"twoWaySyncHint": "Sync reading position between BookOrbit and Kobo. Requires KEPUB delivery for precise locations and reliable page restore.",
"syncHighlights": "Sync BookOrbit highlights to Kobo",
"syncHighlightsHint": "Send highlights made in BookOrbit or KOReader to your Kobo. Kobo highlights are imported into BookOrbit even when this is off. Linked annotations sync edits and deletes both ways. Requires KEPUB delivery; the book on Kobo must be the KEPUB downloaded from BookOrbit.",
"storeSync": "Include Kobo store titles",
"storeSyncHint": "Also deliver books from your Kobo account, including Kobo Plus and purchases, alongside your BookOrbit library. Without this, only BookOrbit books reach the device. Store titles are downloaded from Kobo directly and are not added to your BookOrbit library.",
"convertKepub": "Convert to KEPUB",
"convertKepubHint": "Send eligible EPUBs as KEPUB. This stays on when progress sync or BookOrbit-to-Kobo highlights are enabled. On the next Kobo sync, affected books are offered again as KEPUB downloads; remove any old EPUB copy if Kobo keeps opening it.",
"forceHyphenation": "Force hyphenation",
+2
View File
@@ -17,6 +17,7 @@ export interface KoboSyncSettings {
kepubConversionLimitMb: number;
twoWayProgressSync: boolean;
syncBookOrbitAnnotationsToKobo: boolean;
storeSync: boolean;
}
export type KoboSyncHistoryEvent = "library_sync" | "book_download" | "progress_update" | "annotations_pull" | "annotations_push";
@@ -54,4 +55,5 @@ export interface UpdateKoboSyncSettingsRequest {
kepubConversionLimitMb?: number;
twoWayProgressSync?: boolean;
syncBookOrbitAnnotationsToKobo?: boolean;
storeSync?: boolean;
}
@@ -0,0 +1 @@
ALTER TABLE "kobo_sync_settings" ADD COLUMN "store_sync" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -449,6 +449,13 @@
"when": 1785964221973,
"tag": "0063_add_dashboard_random_lookup_index",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1786401421899,
"tag": "0064_add_kobo_store_sync_setting",
"breakpoints": true
}
]
}
+1
View File
@@ -54,6 +54,7 @@ export const koboSyncSettings = pgTable(
kepubConversionLimitMb: integer('kepub_conversion_limit_mb').notNull().default(100),
twoWayProgressSync: boolean('two_way_progress_sync').notNull().default(false),
syncBookOrbitAnnotationsToKobo: boolean('sync_bookorbit_annotations_to_kobo').notNull().default(false),
storeSync: boolean('store_sync').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
@@ -29,6 +29,10 @@ export class UpdateSettingsDto {
@IsBoolean()
syncBookOrbitAnnotationsToKobo?: boolean;
@IsOptional()
@IsBoolean()
storeSync?: boolean;
@IsOptional()
@IsInt()
@Min(1)
@@ -9,6 +9,7 @@ function makeReply() {
send: vi.fn().mockReturnThis(),
header: vi.fn().mockReturnThis(),
type: vi.fn().mockReturnThis(),
redirect: vi.fn().mockReturnThis(),
};
}
@@ -59,55 +60,79 @@ describe('KoboDeviceController', () => {
});
it('resolves legacy compound CoverImageId format (bookId-timestamp) for thumbnails', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/42-1778450221186/thumbnail/355/530/80/False/image.jpg' };
const reply = makeReply();
await controller.thumbnailFull(
'42-1778450221186',
undefined,
{ id: 5 } as never,
{ deviceToken: 'token' } as never,
req as never,
reply as never,
);
await controller.thumbnailSimple(
'42-1778450221186',
undefined,
{ id: 5 } as never,
{ deviceToken: 'token' } as never,
req as never,
reply as never,
);
await controller.thumbnailFull('42-1778450221186', '355', '530', '80', 'False', undefined, { id: 5 } as never, reply as never);
await controller.thumbnailSimple('42-1778450221186', '355', '530', undefined, { id: 5 } as never, reply as never);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(1, 5, 42, undefined, reply);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(2, 5, 42, undefined, reply);
expect(reply.redirect).not.toHaveBeenCalled();
});
it('serves thumbnail for valid book ids across all thumbnail endpoints', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/12/thumbnail/300/300/false/image.jpg' };
const reply = makeReply();
await controller.thumbnailSimple('12', undefined, { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never);
await controller.thumbnailFull('12', '"etag"', { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never);
await controller.thumbnailVersioned('12', undefined, { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never);
await controller.thumbnailSimple('12', '300', '300', undefined, { id: 5 } as never, reply as never);
await controller.thumbnailFull('12', '300', '300', '80', 'false', '"etag"', { id: 5 } as never, reply as never);
await controller.thumbnailVersioned('12', '300', '300', undefined, { id: 5 } as never, reply as never);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(1, 5, 12, undefined, reply);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(2, 5, 12, '"etag"', reply);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(3, 5, 12, undefined, reply);
expect(proxyService.forward).not.toHaveBeenCalled();
expect(reply.redirect).not.toHaveBeenCalled();
});
it('proxies thumbnail and download requests for non-numeric ids', async () => {
it('redirects unresolved cover ids to the Kobo image CDN instead of storeapi', async () => {
const reply = makeReply();
bookIdentityService.resolveBookIdByCoverImageId.mockResolvedValue(null);
await controller.thumbnailFull(
'4e0b1d1a-9d3e-4a6f-9c1a-1f2b3c4d5e6f',
'355',
'530',
'80',
'False',
undefined,
{ id: 9 } as never,
reply as never,
);
expect(reply.redirect).toHaveBeenCalledWith(
'https://cdn.kobo.com/book-images/4e0b1d1a-9d3e-4a6f-9c1a-1f2b3c4d5e6f/355/530/80/False/image.jpg',
302,
);
expect(thumbnailService.serveThumbnail).not.toHaveBeenCalled();
expect(proxyService.forward).not.toHaveBeenCalled();
});
it('redirects unresolved cover ids on the greyscale-free template without a quality segment', async () => {
const reply = makeReply();
bookIdentityService.resolveBookIdByCoverImageId.mockResolvedValue(null);
await controller.thumbnailSimple('store-cover', '355', '530', undefined, { id: 9 } as never, reply as never);
expect(reply.redirect).toHaveBeenCalledWith('https://cdn.kobo.com/book-images/store-cover/355/530/false/image.jpg', 302);
});
it('404s rather than redirecting when a cover request cannot be mapped to a CDN path', async () => {
const reply = makeReply();
bookIdentityService.resolveBookIdByCoverImageId.mockResolvedValue(null);
await expect(controller.thumbnailSimple(' ', '355', '530', undefined, { id: 9 } as never, reply as never)).rejects.toThrow('No cover image');
expect(reply.redirect).not.toHaveBeenCalled();
});
it('proxies download requests for non-numeric ids', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/not-a-number/download' };
const reply = makeReply();
proxyService.forward.mockResolvedValue(undefined);
bookIdentityService.resolveBookIdByEntitlementId.mockResolvedValue(null);
bookIdentityService.resolveBookIdByCoverImageId.mockResolvedValue(null);
await controller.thumbnailSimple('abc', undefined, { id: 9 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
await controller.download('abc', { id: 9 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
expect(proxyService.forward).toHaveBeenCalledTimes(2);
expect(proxyService.forward).toHaveBeenCalledTimes(1);
expect(proxyService.forward).toHaveBeenCalledWith(req, reply, 'dev-token');
expect(downloadService.streamBook).not.toHaveBeenCalled();
});
@@ -150,14 +175,7 @@ describe('KoboDeviceController', () => {
bookIdentityService.resolveBookIdByCoverImageId.mockResolvedValue(420);
await controller.download('kobo-entitlement-id', { id: 11 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never);
await controller.thumbnailSimple(
'kobo-cover-id',
undefined,
{ id: 11 } as never,
{ deviceToken: 'dev-token' } as never,
req as never,
reply as never,
);
await controller.thumbnailSimple('kobo-cover-id', '355', '530', undefined, { id: 11 } as never, reply as never);
expect(downloadService.streamBook).toHaveBeenCalledWith(11, 420, reply);
expect(thumbnailService.serveThumbnail).toHaveBeenCalledWith(11, 420, undefined, reply);
@@ -1,4 +1,19 @@
import { All, Body, Controller, Get, Headers, HttpCode, HttpStatus, Logger, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
import {
All,
Body,
Controller,
Get,
Headers,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { FastifyReply, FastifyRequest } from 'fastify';
@@ -15,6 +30,7 @@ import { KoboProxyService } from './services/kobo-proxy.service';
import type { KoboAnalyticsBody } from './kobo-analytics.types';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
import { buildKoboStoreImageUrl } from './kobo-store-images';
@Controller('kobo/:deviceToken')
@Public()
@@ -34,37 +50,39 @@ export class KoboDeviceController {
@Get('v1/books/:bookId/thumbnail/:width/:height/:quality/:isGreyscale/image.jpg')
async thumbnailFull(
@Param('bookId') bookId: string,
@Param('width') width: string,
@Param('height') height: string,
@Param('quality') quality: string,
@Param('isGreyscale') isGreyscale: string,
@Headers('if-none-match') ifNoneMatch: string | undefined,
@CurrentUser() user: RequestUser,
@KoboDevice() device: KoboDeviceContext,
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
await this.serveThumbnailOrProxy(bookId, ifNoneMatch, user, device, req, reply);
await this.serveThumbnailOrRedirect(bookId, [width, height, quality, isGreyscale], ifNoneMatch, user, reply);
}
@Get('v1/books/:bookId/thumbnail/:width/:height/false/image.jpg')
async thumbnailSimple(
@Param('bookId') bookId: string,
@Param('width') width: string,
@Param('height') height: string,
@Headers('if-none-match') ifNoneMatch: string | undefined,
@CurrentUser() user: RequestUser,
@KoboDevice() device: KoboDeviceContext,
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
await this.serveThumbnailOrProxy(bookId, ifNoneMatch, user, device, req, reply);
await this.serveThumbnailOrRedirect(bookId, [width, height, 'false'], ifNoneMatch, user, reply);
}
@Get('v1/books/:bookId/:version/thumbnail/:width/:height/false/image.jpg')
async thumbnailVersioned(
@Param('bookId') bookId: string,
@Param('width') width: string,
@Param('height') height: string,
@Headers('if-none-match') ifNoneMatch: string | undefined,
@CurrentUser() user: RequestUser,
@KoboDevice() device: KoboDeviceContext,
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
await this.serveThumbnailOrProxy(bookId, ifNoneMatch, user, device, req, reply);
await this.serveThumbnailOrRedirect(bookId, [width, height, 'false'], ifNoneMatch, user, reply);
}
@Get('v1/books/:bookId/download')
@@ -153,16 +171,23 @@ export class KoboDeviceController {
return { Result: 'Success', TestKey: randomUUID(), Tests: {} };
}
private async serveThumbnailOrProxy(
private async serveThumbnailOrRedirect(
bookId: string,
imageSegments: string[],
ifNoneMatch: string | undefined,
user: RequestUser,
device: KoboDeviceContext,
req: FastifyRequest,
reply: FastifyReply,
) {
const id = await this.bookIdentityService.resolveBookIdByCoverImageId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken);
await this.thumbnailService.serveThumbnail(user.id, id, ifNoneMatch, reply);
if (id !== null) {
await this.thumbnailService.serveThumbnail(user.id, id, ifNoneMatch, reply);
return;
}
const storeImageUrl = buildKoboStoreImageUrl(bookId, imageSegments);
if (!storeImageUrl) throw new NotFoundException('No cover image');
// Found, not Moved Permanently: a store title can later be imported into BookOrbit, and a
// cached permanent redirect would keep sending the device to Kobo for a cover we now own.
reply.redirect(storeImageUrl, HttpStatus.FOUND);
}
}
@@ -0,0 +1,28 @@
import { buildKoboStoreImageUrl } from './kobo-store-images';
describe('buildKoboStoreImageUrl', () => {
it('builds the quality template Kobo advertises as image_url_quality_template', () => {
expect(buildKoboStoreImageUrl('cover-id', ['355', '530', '80', 'False'])).toBe(
'https://cdn.kobo.com/book-images/cover-id/355/530/80/False/image.jpg',
);
});
it('builds the plain template Kobo advertises as image_url_template', () => {
expect(buildKoboStoreImageUrl('cover-id', ['355', '530', 'false'])).toBe('https://cdn.kobo.com/book-images/cover-id/355/530/false/image.jpg');
});
it('rejects empty and blank segments', () => {
expect(buildKoboStoreImageUrl('', ['355', '530', 'false'])).toBeNull();
expect(buildKoboStoreImageUrl('cover-id', ['355', ' ', 'false'])).toBeNull();
});
it('keeps traversal and host injection attempts inside the CDN path', () => {
expect(buildKoboStoreImageUrl('../../etc/passwd', ['355', '530', 'false'])).toBe(
'https://cdn.kobo.com/book-images/..%2F..%2Fetc%2Fpasswd/355/530/false/image.jpg',
);
expect(buildKoboStoreImageUrl('evil.example.com', ['355', '530', 'false'])).toBe(
'https://cdn.kobo.com/book-images/evil.example.com/355/530/false/image.jpg',
);
expect(new URL(buildKoboStoreImageUrl('//evil.example.com', ['355', '530', 'false'])!).hostname).toBe('cdn.kobo.com');
});
});
@@ -0,0 +1,25 @@
const KOBO_IMAGE_CDN_ORIGIN = 'https://cdn.kobo.com';
const KOBO_IMAGE_CDN_HOSTNAME = 'cdn.kobo.com';
const KOBO_IMAGE_CDN_PATH = '/book-images';
/**
* Builds the Kobo CDN URL for a cover BookOrbit does not own.
*
* Initialization rewrites image_url_template to point at BookOrbit so local covers resolve, which
* means the device also asks us for store-owned covers we have never heard of. Those live on the
* image CDN advertised as image_host in KOBO_STORE_RESOURCES, not on storeapi.kobo.com, so the
* generic proxy cannot reach them and every store cover 404s.
*
* Returns null when the request cannot be mapped onto a CDN path, so callers 404 rather than
* redirect somewhere unintended.
*/
export function buildKoboStoreImageUrl(imageId: string, segments: readonly string[]): string | null {
const parts = [imageId, ...segments];
if (parts.some((part) => part.trim().length === 0)) return null;
const path = parts.map((part) => encodeURIComponent(part)).join('/');
const url = new URL(`${KOBO_IMAGE_CDN_PATH}/${path}/image.jpg`, KOBO_IMAGE_CDN_ORIGIN);
if (url.hostname !== KOBO_IMAGE_CDN_HOSTNAME) return null;
return url.toString();
}
@@ -25,6 +25,7 @@ describe('KoboSyncController', () => {
};
const proxyService = {
forward: vi.fn(),
request: vi.fn(),
};
const bookIdentityService = {
resolveBookIdByEntitlementId: vi.fn(),
@@ -215,6 +216,187 @@ describe('KoboSyncController', () => {
expect(reply.send).toHaveBeenCalledWith([{ NewEntitlement: { BookEntitlement: { Id: '12' } } }]);
});
describe('store sync', () => {
const SNAPSHOT_TOKEN = `PX.${Buffer.from(JSON.stringify({ snapshotId: 4 })).toString('base64')}`;
function syncRequest(incomingSyncToken?: string) {
return {
headers: { host: 'kobo.local', ...(incomingSyncToken ? { 'x-kobo-synctoken': incomingSyncToken } : {}) },
protocol: 'http',
hostname: 'kobo.local',
socket: { localPort: 3000 },
};
}
function storeResponse(entitlements: unknown[], headers: Record<string, string> = {}) {
return { status: 200, headers, body: Buffer.from(JSON.stringify(entitlements), 'utf8') };
}
async function runSync(reply: ReturnType<typeof makeReply>, incomingSyncToken?: string) {
await controller.librarySync(
{ deviceId: 9, deviceToken: 'token-9' } as never,
{ id: 21 } as never,
syncRequest(incomingSyncToken) as never,
reply as never,
);
}
beforeEach(() => {
syncService.getDelta.mockResolvedValue({ entitlements: [{ Local: 1 }], hasMore: false, syncToken: SNAPSHOT_TOKEN });
settingsService.getSettings.mockResolvedValue({ storeSync: true });
});
it('does not touch the store when the user has store sync disabled', async () => {
settingsService.getSettings.mockResolvedValue({ storeSync: false });
const reply = makeReply();
await runSync(reply);
expect(proxyService.request).not.toHaveBeenCalled();
expect(reply.send).toHaveBeenCalledWith([{ Local: 1 }]);
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', '');
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', SNAPSHOT_TOKEN);
expect(historyService.recordSuccess).toHaveBeenCalledWith(expect.objectContaining({ counts: { entitlements: 1, hasMore: false } }));
});
it('parks an unused Kobo cursor in the token while store sync is off, so enabling it later resumes', async () => {
settingsService.getSettings.mockResolvedValue({ storeSync: false });
const reply = makeReply();
await runSync(reply, 'kobo-native-token');
expect(proxyService.request).not.toHaveBeenCalled();
expect(reply.header).toHaveBeenCalledWith(
'x-kobo-synctoken',
`PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-native-token' })).toString('base64')}`,
);
});
it('appends store entitlements and stamps Kobo’s cursor into the sync token', async () => {
proxyService.request.mockResolvedValue(storeResponse([{ Store: 1 }, { Store: 2 }], { 'x-kobo-synctoken': 'kobo-cursor-2' }));
const reply = makeReply();
await runSync(reply, `PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`);
expect(proxyService.request).toHaveBeenCalledWith(
expect.anything(),
'token-9',
expect.objectContaining({ extraHeaders: { 'x-kobo-synctoken': 'kobo-cursor-1' } }),
);
expect(reply.send).toHaveBeenCalledWith([{ Local: 1 }, { Store: 1 }, { Store: 2 }]);
expect(reply.header).toHaveBeenCalledWith(
'x-kobo-synctoken',
`PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-2' })).toString('base64')}`,
);
expect(historyService.recordSuccess).toHaveBeenCalledWith(
expect.objectContaining({ counts: { entitlements: 3, hasMore: false, storeEntitlements: 2 } }),
);
});
it('never forwards our own composite token upstream when Kobo’s cursor is unknown', async () => {
proxyService.request.mockResolvedValue(storeResponse([]));
const reply = makeReply();
await runSync(reply, SNAPSHOT_TOKEN);
expect(proxyService.request).toHaveBeenCalledWith(expect.anything(), 'token-9', expect.objectContaining({ omitHeaders: ['x-kobo-synctoken'] }));
expect(proxyService.request.mock.calls[0][2]).not.toHaveProperty('extraHeaders');
});
it('forwards a raw Kobo token from a device that synced with Kobo before BookOrbit', async () => {
proxyService.request.mockResolvedValue(storeResponse([]));
await runSync(makeReply(), 'kobo-native-token');
expect(proxyService.request).toHaveBeenCalledWith(
expect.anything(),
'token-9',
expect.objectContaining({ extraHeaders: { 'x-kobo-synctoken': 'kobo-native-token' } }),
);
});
it('keeps paging while the store cursor advances', async () => {
proxyService.request.mockResolvedValue(storeResponse([{ Store: 1 }], { 'x-kobo-sync': 'continue', 'x-kobo-synctoken': 'kobo-cursor-2' }));
const reply = makeReply();
await runSync(reply, `PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`);
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue');
});
it('stops paging when the store says continue without advancing its cursor', async () => {
proxyService.request.mockResolvedValue(storeResponse([{ Store: 1 }], { 'x-kobo-sync': 'continue', 'x-kobo-synctoken': 'kobo-cursor-1' }));
const reply = makeReply();
await runSync(reply, `PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`);
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', '');
expect(reply.send).toHaveBeenCalledWith([{ Local: 1 }, { Store: 1 }]);
});
it.each([
['the store rejects the credential', () => proxyService.request.mockResolvedValue({ status: 401, headers: {}, body: Buffer.from('nope') })],
['the store times out', () => proxyService.request.mockRejectedValue(new Error('The operation was aborted due to timeout'))],
['the store returns a non-array body', () => proxyService.request.mockResolvedValue(storeResponse({ error: 'nope' } as never))],
['the store returns unparseable JSON', () => proxyService.request.mockResolvedValue({ status: 200, headers: {}, body: Buffer.from('<html>') })],
['the store returns an empty body', () => proxyService.request.mockResolvedValue({ status: 200, headers: {}, body: Buffer.from(' ') })],
['reading the store sync setting fails', () => settingsService.getSettings.mockRejectedValue(new Error('settings row race'))],
])('still serves BookOrbit entitlements when %s', async (_label, arrange) => {
arrange();
const reply = makeReply();
await runSync(reply);
expect(reply.send).toHaveBeenCalledWith([{ Local: 1 }]);
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', '');
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', SNAPSHOT_TOKEN);
expect(historyService.recordSuccess).toHaveBeenCalledWith(expect.objectContaining({ counts: { entitlements: 1, hasMore: false } }));
});
it('records the sync as a success rather than a 500 when the store sync setting cannot be read', async () => {
settingsService.getSettings.mockRejectedValue(new Error('settings row race'));
const reply = makeReply();
await expect(runSync(reply)).resolves.toBeUndefined();
expect(proxyService.request).not.toHaveBeenCalled();
expect(historyService.recordFailure).not.toHaveBeenCalled();
expect(historyService.recordSuccess).toHaveBeenCalledWith(expect.objectContaining({ event: 'library_sync' }));
});
it('treats a capitalised continue header as more pages', async () => {
proxyService.request.mockResolvedValue(storeResponse([{ Store: 1 }], { 'x-kobo-sync': 'Continue', 'x-kobo-synctoken': 'kobo-cursor-2' }));
const reply = makeReply();
await runSync(reply, `PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`);
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue');
});
it('drops an upstream cursor too large to carry and leaves the token without one', async () => {
const oversized = 'x'.repeat(4097);
proxyService.request.mockResolvedValue(storeResponse([{ Store: 1 }], { 'x-kobo-synctoken': oversized }));
const reply = makeReply();
await runSync(reply, SNAPSHOT_TOKEN);
expect(reply.send).toHaveBeenCalledWith([{ Local: 1 }, { Store: 1 }]);
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', SNAPSHOT_TOKEN);
});
it('preserves a known Kobo cursor across a failed store call so paging resumes next sync', async () => {
proxyService.request.mockRejectedValue(new Error('upstream down'));
const reply = makeReply();
await runSync(reply, `PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`);
expect(reply.header).toHaveBeenCalledWith(
'x-kobo-synctoken',
`PX.${Buffer.from(JSON.stringify({ snapshotId: 4, koboSyncToken: 'kobo-cursor-1' })).toString('base64')}`,
);
});
});
it('records failed library sync operations before rethrowing', async () => {
const error = new Error('snapshot failed');
syncService.getDelta.mockRejectedValue(error);
+115 -5
View File
@@ -32,6 +32,16 @@ import { KoboProxyService } from './services/kobo-proxy.service';
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
import { decodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './services/kobo-sync-token';
const STORE_SYNC_TIMEOUT_MS = 8_000;
const KOBO_SYNC_TOKEN_HEADER = 'x-kobo-synctoken';
type StoreSyncResult = {
entitlements: unknown[];
hasMore: boolean;
syncToken: string | undefined;
};
function readHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
@@ -145,19 +155,119 @@ export class KoboSyncController {
throw error;
}
const { entitlements, hasMore, syncToken } = result;
// Reach for the store only once BookOrbit has drained its own pages, so the device is never
// asked to reconcile two independent cursors out of a single response.
const incomingKoboSyncToken = decodeSyncToken(readHeaderValue(req.headers[KOBO_SYNC_TOKEN_HEADER])).koboSyncToken;
const store = hasMore ? null : await this.fetchStoreEntitlements(req, device, user.id, incomingKoboSyncToken);
const merged = store ? [...entitlements, ...store.entitlements] : entitlements;
// Kobo's cursor rides inside our token, so one too large to carry means the next store sync
// starts over. Say so rather than letting the store silently resend its whole library.
if (store?.syncToken !== undefined && !isUsableKoboSyncToken(store.syncToken)) {
this.logger.warn(
`[kobo.store_sync] [fail] userId=${user.id} deviceId=${device.deviceId} errorClass=StoreCursorTooLarge tokenLength=${store.syncToken.length} - upstream cursor dropped, the next store sync starts from the beginning`,
);
}
// Replaying an unchanged cursor returns the same page forever, so keep paging the store only
// while its token actually advances. The page just fetched is still delivered either way.
const storeCursorAdvanced = store?.syncToken !== undefined && store.syncToken !== incomingKoboSyncToken;
if (store?.hasMore === true && !storeCursorAdvanced) {
this.logger.warn(
`[kobo.store_sync] [fail] userId=${user.id} deviceId=${device.deviceId} errorClass=StoreCursorStalled error="continue without an advanced sync token" - store paging stopped to avoid a sync loop`,
);
}
const combinedHasMore = hasMore || (store?.hasMore === true && storeCursorAdvanced);
this.logger.debug(
`[kobo.library_sync] [end] userId=${user.id} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} entitlementCount=${entitlements.length} hasMore=${hasMore} - library sync completed`,
`[kobo.library_sync] [end] userId=${user.id} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} entitlementCount=${entitlements.length} storeEntitlementCount=${store?.entitlements.length ?? 0} hasMore=${combinedHasMore} - library sync completed`,
);
await this.historyService.recordSuccess({
userId: user.id,
deviceId: device.deviceId,
event: 'library_sync',
durationMs: Date.now() - startedAt,
counts: { entitlements: entitlements.length, hasMore },
counts: {
entitlements: merged.length,
hasMore: combinedHasMore,
...(store ? { storeEntitlements: store.entitlements.length } : {}),
},
});
reply.header('x-kobo-sync', hasMore ? 'continue' : '');
reply.header('x-kobo-synctoken', syncToken);
reply.send(entitlements);
reply.header('x-kobo-sync', combinedHasMore ? 'continue' : '');
reply.header('x-kobo-synctoken', withKoboSyncToken(syncToken, store?.syncToken ?? incomingKoboSyncToken));
reply.send(merged);
}
/**
* Pulls the device's Kobo Plus and Kobo store entitlements using the device's own Kobo
* credentials, which reach us because initialization leaves device_auth and device_refresh
* pointing at Kobo. Returns null when the user has store sync switched off.
*
* Never throws: reading the setting, or a store that is slow, down or unauthorized, costs the
* store page and never the BookOrbit half of the sync.
*/
private async fetchStoreEntitlements(
req: FastifyRequest,
device: KoboDeviceContext,
userId: number,
koboSyncToken: string | undefined,
): Promise<StoreSyncResult | null> {
const startedAt = Date.now();
try {
const { storeSync } = await this.settingsService.getSettings(userId);
if (!storeSync) return null;
this.logger.debug(
`[kobo.store_sync] [start] userId=${userId} deviceId=${device.deviceId} hasKoboToken=${Boolean(koboSyncToken)} - upstream store sync started`,
);
// Our own composite token means nothing to Kobo, so it is replaced when we hold Kobo's
// cursor and dropped entirely when we do not, which asks the store for a full sync.
const response = await this.proxyService.request(req, device.deviceToken, {
...(koboSyncToken ? { extraHeaders: { [KOBO_SYNC_TOKEN_HEADER]: koboSyncToken } } : { omitHeaders: [KOBO_SYNC_TOKEN_HEADER] }),
timeoutMs: STORE_SYNC_TIMEOUT_MS,
});
if (response.status < 200 || response.status >= 300) {
this.logger.warn(
`[kobo.store_sync] [fail] userId=${userId} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} status=${response.status} - upstream store sync returned an error status`,
);
return null;
}
// An empty body carries no cursor we can trust, so treat it as no store data this round
// rather than advancing past a page Kobo may still owe us.
const rawBody = response.body.toString('utf8').trim();
if (rawBody.length === 0) {
this.logger.warn(
`[kobo.store_sync] [fail] userId=${userId} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} status=${response.status} - upstream store sync returned an empty body`,
);
return null;
}
const parsed: unknown = JSON.parse(rawBody);
if (!Array.isArray(parsed)) {
this.logger.warn(
`[kobo.store_sync] [fail] userId=${userId} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} status=${response.status} - upstream store sync returned a non-array body`,
);
return null;
}
const hasMore = response.headers['x-kobo-sync']?.trim().toLowerCase() === 'continue';
this.logger.debug(
`[kobo.store_sync] [end] userId=${userId} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} storeEntitlementCount=${parsed.length} hasMore=${hasMore} - upstream store sync completed`,
);
return { entitlements: parsed, hasMore, syncToken: response.headers[KOBO_SYNC_TOKEN_HEADER] };
} catch (error: unknown) {
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : 'unknown error');
this.logger.warn(
`[kobo.store_sync] [fail] userId=${userId} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - upstream store sync failed, serving BookOrbit entitlements only`,
);
return null;
}
}
@Post('v1/library/tags/:tagId/items/delete')
@@ -154,6 +154,70 @@ describe('KoboProxyService', () => {
expect(reply.send).toHaveBeenCalledWith({ message: 'Upstream Kobo API unavailable' });
});
describe('request', () => {
function stubUpstream(headers: Record<string, string>, body = '[]') {
const fetchMock = vi.fn().mockResolvedValue({
status: 200,
headers: new Headers(headers),
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode(body).buffer),
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
const syncRequest = {
method: 'GET',
url: '/api/v1/kobo/dev/v1/library/sync?Filter=ALL',
headers: { authorization: 'Bearer kobo-jwt', 'x-kobo-synctoken': 'PX.composite' },
};
it('returns the upstream response with lowercased headers instead of piping it', async () => {
stubUpstream({ 'X-Kobo-Sync': 'continue', 'X-Kobo-SyncToken': 'kobo-cursor' }, '[{"a":1}]');
const response = await new KoboProxyService().request(syncRequest as never, 'dev');
expect(response.status).toBe(200);
expect(response.headers['x-kobo-sync']).toBe('continue');
expect(response.headers['x-kobo-synctoken']).toBe('kobo-cursor');
expect(JSON.parse(response.body.toString('utf8'))).toEqual([{ a: 1 }]);
});
it('overrides a forwarded header with extraHeaders', async () => {
const fetchMock = stubUpstream({});
await new KoboProxyService().request(syncRequest as never, 'dev', { extraHeaders: { 'x-kobo-synctoken': 'kobo-cursor' } });
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ authorization: 'Bearer kobo-jwt', 'x-kobo-synctoken': 'kobo-cursor' });
});
it('drops a forwarded header listed in omitHeaders while keeping the device credential', async () => {
const fetchMock = stubUpstream({});
await new KoboProxyService().request(syncRequest as never, 'dev', { omitHeaders: ['X-Kobo-SyncToken'] });
const sentHeaders = fetchMock.mock.calls[0][1].headers;
expect(sentHeaders).not.toHaveProperty('x-kobo-synctoken');
expect(sentHeaders.authorization).toBe('Bearer kobo-jwt');
});
it('attaches an abort signal only when a timeout is requested', async () => {
const fetchMock = stubUpstream({});
const service = new KoboProxyService();
await service.request(syncRequest as never, 'dev');
expect(fetchMock.mock.calls[0][1].signal).toBeUndefined();
await service.request(syncRequest as never, 'dev', { timeoutMs: 8000 });
expect(fetchMock.mock.calls[1][1].signal).toBeInstanceOf(AbortSignal);
});
it('propagates upstream failures to the caller rather than swallowing them', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
await expect(new KoboProxyService().request(syncRequest as never, 'dev')).rejects.toThrow('network down');
});
});
describe('buildTargetUrl', () => {
let service: KoboProxyService;
@@ -20,51 +20,88 @@ const FORWARD_HEADERS = [
'x-kobo-synctokenversion',
];
const HOP_BY_HOP_HEADERS = ['transfer-encoding', 'connection', 'content-encoding', 'content-length'];
export type KoboProxyResponse = {
status: number;
headers: Record<string, string>;
body: Buffer;
};
export type KoboProxyRequestOptions = {
extraHeaders?: Record<string, string>;
/** Drops headers FORWARD_HEADERS would otherwise copy from the device request. */
omitHeaders?: string[];
timeoutMs?: number;
};
@Injectable()
export class KoboProxyService {
private readonly logger = new Logger(KoboProxyService.name);
async forward(req: FastifyRequest, reply: FastifyReply, deviceToken: string) {
const rawUrl = req.url;
const prefix = `/api/v1/kobo/${deviceToken}`;
const koboPath = rawUrl.startsWith(prefix) ? rawUrl.slice(prefix.length) : rawUrl;
const targetUrl = this.buildTargetUrl(koboPath);
/** Calls Kobo with the device's own credentials and hands the response back instead of piping it. */
async request(req: FastifyRequest, deviceToken: string, options: KoboProxyRequestOptions = {}): Promise<KoboProxyResponse> {
const targetUrl = this.resolveTargetUrl(req, deviceToken);
const headers: Record<string, string> = {};
for (const key of FORWARD_HEADERS) {
const val = req.headers[key];
if (val) headers[key] = Array.isArray(val) ? val[0] : val;
}
for (const key of options.omitHeaders ?? []) {
delete headers[key.toLowerCase()];
}
for (const [key, value] of Object.entries(options.extraHeaders ?? {})) {
headers[key.toLowerCase()] = value;
}
let body: string | undefined;
if (!['GET', 'HEAD'].includes(req.method) && req.body != null) {
body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
}
// codeql[js/request-forgery] - hostname validated by buildTargetUrl() to KOBO_API_HOSTNAME
const upstream = await fetch(targetUrl, {
method: req.method,
headers,
body,
...(options.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}),
});
const responseHeaders: Record<string, string> = {};
upstream.headers.forEach((value, key) => {
responseHeaders[key.toLowerCase()] = value;
});
return { status: upstream.status, headers: responseHeaders, body: Buffer.from(await upstream.arrayBuffer()) };
}
async forward(req: FastifyRequest, reply: FastifyReply, deviceToken: string) {
const targetUrl = this.resolveTargetUrl(req, deviceToken);
try {
let body: string | undefined;
if (!['GET', 'HEAD'].includes(req.method) && req.body != null) {
body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
}
const { status, headers, body } = await this.request(req, deviceToken);
// codeql[js/request-forgery] - hostname validated by buildTargetUrl() to KOBO_API_HOSTNAME
const upstream = await fetch(targetUrl, {
method: req.method,
headers,
body,
});
reply.status(upstream.status);
upstream.headers.forEach((value, key) => {
if (!['transfer-encoding', 'connection', 'content-encoding', 'content-length'].includes(key.toLowerCase())) {
reply.status(status);
for (const [key, value] of Object.entries(headers)) {
if (!HOP_BY_HOP_HEADERS.includes(key)) {
reply.header(key, value);
}
});
const responseBody = await upstream.arrayBuffer();
reply.send(Buffer.from(responseBody));
}
reply.send(body);
} catch (err) {
this.logger.warn(`Proxy failed for ${targetUrl}: ${(err as Error).message}`);
reply.status(502).send({ message: 'Upstream Kobo API unavailable' });
}
}
private resolveTargetUrl(req: FastifyRequest, deviceToken: string): string {
const rawUrl = req.url;
const prefix = `/api/v1/kobo/${deviceToken}`;
const koboPath = rawUrl.startsWith(prefix) ? rawUrl.slice(prefix.length) : rawUrl;
return this.buildTargetUrl(koboPath);
}
private buildTargetUrl(koboPath: string): string {
let parsed: URL;
try {
@@ -11,6 +11,7 @@ function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
...overrides,
};
}
@@ -64,6 +65,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
});
expect(db.insert).not.toHaveBeenCalled();
});
@@ -81,6 +83,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 150,
twoWayProgressSync: false,
storeSync: false,
});
expect(db.insertValues).toHaveBeenCalledWith({ userId: 7 });
});
@@ -98,6 +101,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: true,
storeSync: false,
});
expect(db.updateSet).toHaveBeenCalledWith(expect.objectContaining({ convertToKepub: true }));
expect(db.execute).toHaveBeenCalledTimes(1);
@@ -122,6 +126,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
});
db.updateReturning.mockResolvedValue([
makeRow({
@@ -129,6 +134,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: true,
kepubConversionLimitMb: 200,
twoWayProgressSync: true,
storeSync: false,
}),
]);
@@ -139,11 +145,13 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: true,
kepubConversionLimitMb: 200,
twoWayProgressSync: true,
storeSync: false,
});
expect(db.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
convertToKepub: true,
twoWayProgressSync: true,
storeSync: false,
}),
);
expect(db.execute).toHaveBeenCalledTimes(1);
@@ -159,6 +167,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
});
db.updateReturning.mockResolvedValue([makeRow({ readingThreshold: 5, finishedThreshold: 95 })]);
@@ -169,10 +178,30 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
});
expect(db.execute).not.toHaveBeenCalled();
});
it('toggles store sync without forcing every Kobo book to be re-delivered', async () => {
const db = makeDb();
const service = new KoboSettingsService(db as never);
vi.spyOn(service, 'getSettings').mockResolvedValue({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
});
db.updateReturning.mockResolvedValue([makeRow({ storeSync: true })]);
await expect(service.updateSettings(9, { storeSync: true })).resolves.toMatchObject({ storeSync: true });
expect(db.updateSet).toHaveBeenCalledWith(expect.objectContaining({ storeSync: true }));
expect(db.execute).not.toHaveBeenCalled();
});
it('throws when update does not return a row', async () => {
const db = makeDb();
const service = new KoboSettingsService(db as never);
@@ -183,6 +212,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
});
db.updateReturning.mockResolvedValue([]);
@@ -199,6 +229,7 @@ describe('KoboSettingsService', () => {
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
storeSync: false,
});
await expect(service.updateSettings(9, { readingThreshold: 99, finishedThreshold: 99 })).rejects.toThrow(
@@ -15,6 +15,7 @@ export interface KoboSettings {
kepubConversionLimitMb: number;
twoWayProgressSync: boolean;
syncBookOrbitAnnotationsToKobo: boolean;
storeSync: boolean;
}
@Injectable()
@@ -50,6 +51,7 @@ export class KoboSettingsService {
kepubConversionLimitMb: row.kepubConversionLimitMb,
twoWayProgressSync: row.twoWayProgressSync,
syncBookOrbitAnnotationsToKobo: row.syncBookOrbitAnnotationsToKobo,
storeSync: row.storeSync,
};
}
@@ -76,6 +78,7 @@ export class KoboSettingsService {
kepubConversionLimitMb: patch.kepubConversionLimitMb ?? current.kepubConversionLimitMb,
twoWayProgressSync: newTwoWayProgressSync,
syncBookOrbitAnnotationsToKobo: newAnnotationSync,
storeSync: patch.storeSync ?? current.storeSync,
updatedAt: sql`now()`,
})
.where(eq(schema.koboSyncSettings.userId, userId))
@@ -97,6 +100,7 @@ export class KoboSettingsService {
kepubConversionLimitMb: updated.kepubConversionLimitMb,
twoWayProgressSync: updated.twoWayProgressSync,
syncBookOrbitAnnotationsToKobo: updated.syncBookOrbitAnnotationsToKobo,
storeSync: updated.storeSync,
};
}
@@ -0,0 +1,64 @@
import { decodeSyncToken, encodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './kobo-sync-token';
describe('kobo sync token', () => {
it('keeps the pre-store-sync token shape when there is no upstream cursor', () => {
const token = encodeSyncToken(7);
expect(token).toBe(`PX.${Buffer.from(JSON.stringify({ snapshotId: 7 })).toString('base64')}`);
expect(decodeSyncToken(token)).toEqual({ snapshotId: 7, koboSyncToken: undefined });
});
it('round-trips both cursors', () => {
expect(decodeSyncToken(encodeSyncToken(7, 'kobo-cursor'))).toEqual({ snapshotId: 7, koboSyncToken: 'kobo-cursor' });
});
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([
['undefined', undefined],
['empty', ''],
['whitespace', ' '],
['malformed base64 payload', 'PX.!!!not-base64!!!'],
['a JSON array payload', `PX.${Buffer.from(JSON.stringify([1, 2])).toString('base64')}`],
['a non-object payload', `PX.${Buffer.from(JSON.stringify('nope')).toString('base64')}`],
])('never throws on %s', (_label, raw) => {
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', () => {
expect(isUsableKoboSyncToken('kobo-cursor')).toBe(true);
expect(isUsableKoboSyncToken('x'.repeat(4096))).toBe(true);
expect(isUsableKoboSyncToken('x'.repeat(4097))).toBe(false);
expect(isUsableKoboSyncToken('')).toBe(false);
});
it('drops an oversized upstream cursor rather than growing the response header without bound', () => {
const oversized = 'x'.repeat(4097);
expect(decodeSyncToken(oversized)).toEqual({});
expect(decodeSyncToken(encodeSyncToken(7, oversized))).toEqual({ snapshotId: 7, koboSyncToken: undefined });
});
it('leaves the token untouched when there is no upstream cursor to stamp', () => {
const token = encodeSyncToken(7);
expect(withKoboSyncToken(token, undefined)).toBe(token);
});
it('stamps the upstream cursor onto a token minted by the sync service', () => {
expect(withKoboSyncToken(encodeSyncToken(7), 'kobo-cursor')).toBe(encodeSyncToken(7, 'kobo-cursor'));
});
it('returns an unreadable token unchanged rather than inventing a snapshot', () => {
expect(withKoboSyncToken('PX.!!!', 'kobo-cursor')).toBe('PX.!!!');
});
});
@@ -0,0 +1,64 @@
const TOKEN_PREFIX = 'PX.';
// Kobo's own sync tokens are opaque and we round-trip them through the device inside ours, so a
// hostile or corrupted upstream value cannot be allowed to grow the response header without bound.
// Kobo's real tokens sit far under this; anything larger is dropped and store paging restarts.
const MAX_KOBO_SYNC_TOKEN_LENGTH = 4096;
export type KoboSyncTokenParts = {
snapshotId?: number;
koboSyncToken?: string;
};
/**
* Whether an upstream cursor is small enough to carry. Callers holding a logger should check this
* before handing a cursor over, because dropping one silently restarts store paging from scratch.
*/
export function isUsableKoboSyncToken(koboSyncToken: string): boolean {
return koboSyncToken.length > 0 && koboSyncToken.length <= MAX_KOBO_SYNC_TOKEN_LENGTH;
}
export function encodeSyncToken(snapshotId: number, koboSyncToken?: string): string {
const usableKoboToken = koboSyncToken && isUsableKoboSyncToken(koboSyncToken) ? koboSyncToken : undefined;
const payload: KoboSyncTokenParts = usableKoboToken ? { snapshotId, koboSyncToken: usableKoboToken } : { snapshotId };
return TOKEN_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64');
}
/**
* Tokens minted before store sync existed carry only {snapshotId}, and a device that synced against
* Kobo directly before BookOrbit was put in the path sends Kobo's own opaque token. Neither shape
* may throw: getDelta finds the snapshot by device rather than by token, so a token we cannot read
* costs a store page, never a broken sync.
*/
export function decodeSyncToken(raw: string | undefined): KoboSyncTokenParts {
const value = raw?.trim();
if (!value) return {};
if (!value.startsWith(TOKEN_PREFIX)) {
return isUsableKoboSyncToken(value) ? { koboSyncToken: value } : {};
}
try {
const decoded: unknown = JSON.parse(Buffer.from(value.slice(TOKEN_PREFIX.length), 'base64').toString('utf8'));
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 {};
}
}
/**
* Re-stamps a token minted by KoboSyncService with the upstream cursor. Returns the token unchanged
* when there is no upstream cursor to carry, so a sync with store sync off is byte-identical to one
* from before store sync existed.
*/
export function withKoboSyncToken(compositeToken: string, koboSyncToken: string | undefined): string {
if (!koboSyncToken) return compositeToken;
const { snapshotId } = decodeSyncToken(compositeToken);
if (snapshotId === undefined) return compositeToken;
return encodeSyncToken(snapshotId, koboSyncToken);
}
@@ -14,13 +14,13 @@ import { KoboBookAccessService } from './kobo-book-access.service';
import { KoboBookIdentityService } from './kobo-book-identity.service';
import { normalizeKoboLanguage, selectKoboIsbn } from './kobo-metadata.utils';
import { KoboReadingStateService } from './kobo-reading-state.service';
import { encodeSyncToken } from './kobo-sync-token';
type Db = NodePgDatabase<typeof schema>;
const SYNC_PAGE_SIZE = 5;
const SNAPSHOT_RECONCILE_BATCH_SIZE = 5000;
const SNAPSHOT_CREATE_BATCH_SIZE = 5000;
const TOKEN_PREFIX = 'PX.';
const METADATA_SERIALIZER_VERSION = 2;
type EligibleSnapshotRow = {
@@ -92,10 +92,6 @@ export interface KoboBookEntry {
updatedAt: Date;
}
function encodeSyncToken(snapshotId: number): string {
return TOKEN_PREFIX + Buffer.from(JSON.stringify({ snapshotId })).toString('base64');
}
@Injectable()
export class KoboSyncService {
constructor(