fix(kobo): relay Kobo notebooks to Kobo when store titles are on

A Kobo syncs its notebooks through Kobo's reading services, which
BookOrbit answered with an empty listing, so notebooks never synced.
When Include Kobo store titles is on, relay every UserStorage call to
readingservices.kobo.com; otherwise keep the empty listing.

Fixes #1787
This commit is contained in:
neon
2026-10-17 03:05:40 +00:00
parent b189bf2edd
commit 51b53d4581
4 changed files with 149 additions and 21 deletions
@@ -1,4 +1,4 @@
import { HttpStatus } from '@nestjs/common';
import { HttpStatus, NotFoundException } from '@nestjs/common';
import { KoboReadingServicesController } from './kobo-reading-services.controller';
import { KoboContentNotFoundException } from './services/kobo-annotation-exchange.service';
@@ -24,7 +24,14 @@ describe('KoboReadingServicesController', () => {
recordFailure: vi.fn(),
countsForBook: vi.fn(),
};
const controller = new KoboReadingServicesController(exchangeService as never, historyService as never);
const proxyService = { forward: vi.fn() };
const settingsService = { getSettings: vi.fn() };
const controller = new KoboReadingServicesController(
exchangeService as never,
historyService as never,
proxyService as never,
settingsService as never,
);
beforeEach(() => {
vi.clearAllMocks();
@@ -159,11 +166,57 @@ describe('KoboReadingServicesController', () => {
expect(historyService.recordFailure).not.toHaveBeenCalled();
});
it('delegates changed content and storage metadata without history rows', async () => {
it('delegates changed content without history rows', async () => {
exchangeService.getChangedContentIds.mockResolvedValue(['book-1']);
await expect(controller.checkForChanges({ id: 8 } as never, { deviceId: 5 } as never)).resolves.toEqual(['book-1']);
expect(controller.getUserStorageMetadata()).toEqual({ continuationToken: null, metadata: [] });
expect(historyService.recordSuccess).not.toHaveBeenCalled();
});
describe('notebook storage', () => {
const device = { deviceId: 5, deviceToken: 'token-5', userId: 8 };
it('relays notebook calls to Kobo when store titles are on', async () => {
settingsService.getSettings.mockResolvedValue({ storeSync: true });
const req = { method: 'POST', url: '/api/UserStorage/Metadata' };
const reply = makeReply();
await controller.userStorage({ id: 8 } as never, device as never, req as never, reply as never);
expect(proxyService.forward).toHaveBeenCalledWith(req, reply, 'token-5', 'readingServices');
});
it('answers an empty notebook listing and nothing else when store titles are off', async () => {
settingsService.getSettings.mockResolvedValue({ storeSync: false });
const reply = makeReply();
await controller.userStorage(
{ id: 8 } as never,
device as never,
{ method: 'GET', url: '/api/UserStorage/Metadata?x=1' } as never,
reply as never,
);
expect(reply.send).toHaveBeenCalledWith({ continuationToken: null, metadata: [] });
await expect(
controller.userStorage(
{ id: 8 } as never,
device as never,
{ method: 'POST', url: '/api/UserStorage/Metadata' } as never,
makeReply() as never,
),
).rejects.toThrow(NotFoundException);
expect(proxyService.forward).not.toHaveBeenCalled();
});
it('keeps notebook sync local when the setting cannot be read', async () => {
settingsService.getSettings.mockRejectedValue(new Error('settings unavailable'));
const reply = makeReply();
await controller.userStorage({ id: 8 } as never, device as never, { method: 'GET', url: '/api/UserStorage/Metadata' } as never, reply as never);
expect(proxyService.forward).not.toHaveBeenCalled();
expect(reply.send).toHaveBeenCalledWith({ continuationToken: null, metadata: [] });
});
});
});
@@ -1,5 +1,22 @@
import { Body, Controller, Get, Headers, HttpCode, HttpStatus, Param, Patch, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import {
All,
Body,
Controller,
Get,
Headers,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
Req,
Res,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -9,6 +26,8 @@ import type { KoboDeviceContext } from './guards/kobo-token.guard';
import { KoboTokenGuard } from './guards/kobo-token.guard';
import { KoboAnnotationExchangeService, KoboContentNotFoundException } from './services/kobo-annotation-exchange.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import { KoboSettingsService } from './services/kobo-settings.service';
@Controller(['kobo/:deviceToken', ''])
@Public()
@@ -17,6 +36,8 @@ export class KoboReadingServicesController {
constructor(
private readonly exchangeService: KoboAnnotationExchangeService,
private readonly historyService: KoboSyncHistoryService,
private readonly proxyService: KoboProxyService,
private readonly settingsService: KoboSettingsService,
) {}
@Get('api/v3/content/:contentId/annotations')
@@ -120,8 +141,27 @@ export class KoboReadingServicesController {
return this.exchangeService.getChangedContentIds(user.id, device.deviceId);
}
@Get('api/UserStorage/Metadata')
getUserStorageMetadata() {
return { continuationToken: null, metadata: [] };
/**
* Notebooks sync through Kobo's own UserStorage service, which BookOrbit does not hold. With store titles
* on, the device is signed in to Kobo and already talks to it for its store library, so its notebook
* calls are relayed there too. Otherwise an empty listing keeps the device's notebook sync quiet.
*/
@All('api/UserStorage/*')
async userStorage(
@CurrentUser() user: RequestUser,
@KoboDevice() device: KoboDeviceContext,
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const storeSync = await this.settingsService
.getSettings(user.id)
.then((settings) => settings.storeSync)
.catch(() => false);
if (storeSync) return this.proxyService.forward(req, reply, device.deviceToken, 'readingServices');
if (req.method === 'GET' && req.url.split('?')[0].endsWith('/api/UserStorage/Metadata')) {
reply.send({ continuationToken: null, metadata: [] });
return;
}
throw new NotFoundException();
}
}
@@ -80,6 +80,34 @@ describe('KoboProxyService', () => {
expect(reply.send).toHaveBeenCalledWith(Buffer.from('{"ok":true}'));
});
it.each([
['token-less', '/api/UserStorage/Metadata?pageSize=50'],
['tokened', '/api/v1/kobo/device-1/api/UserStorage/Metadata?pageSize=50'],
])('relays a %s notebook call to Kobo reading services with the device credentials', async (_label, url) => {
const fetchMock = vi.fn().mockResolvedValue({ status: 200, headers: new Headers(), arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)) });
vi.stubGlobal('fetch', fetchMock);
await new KoboProxyService().forward(
{ method: 'POST', url, headers: { authorization: 'Bearer kobo-jwt', host: 'bookorbit.local' }, body: { contentType: 'nebo' } } as never,
makeReply() as never,
'device-1',
'readingServices',
);
expect(fetchMock).toHaveBeenCalledWith(
'https://readingservices.kobo.com/api/UserStorage/Metadata?pageSize=50',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ contentType: 'nebo' }), headers: { authorization: 'Bearer kobo-jwt' } }),
);
});
it('keeps a reading services relay on its host', async () => {
await expect(
new KoboProxyService().request({ method: 'GET', url: '//evil.example/api/UserStorage/Metadata', headers: {} } as never, 'device-1', {
upstream: 'readingServices',
}),
).rejects.toThrow('Invalid proxy path');
});
it('skips body forwarding for GET requests', async () => {
const service = new KoboProxyService();
const upstream = {
@@ -4,8 +4,13 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { decodeSyncToken } from './kobo-sync-token';
const KOBO_API_BASE = 'https://storeapi.kobo.com';
const KOBO_API_HOSTNAME = 'storeapi.kobo.com';
/** The Kobo services a device is relayed to: its store API, and the reading services that hold its notebooks. */
export type KoboUpstream = 'store' | 'readingServices';
const KOBO_UPSTREAMS: Record<KoboUpstream, { base: string; hostname: string }> = {
store: { base: 'https://storeapi.kobo.com', hostname: 'storeapi.kobo.com' },
readingServices: { base: 'https://readingservices.kobo.com', hostname: 'readingservices.kobo.com' },
};
// Headers that must never be forwarded. Connection-specific headers are defined by RFC 7230 as
// scoped to a single hop and reset by every proxy, and `host` would otherwise point Kobo at the
@@ -63,6 +68,7 @@ export type KoboProxyRequestOptions = {
/** Drops headers the device would otherwise copy. */
omitHeaders?: string[];
timeoutMs?: number;
upstream?: KoboUpstream;
};
/**
@@ -86,7 +92,7 @@ export class KoboProxyService {
/** 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 targetUrl = this.resolveTargetUrl(req, deviceToken, options.upstream);
// Fastify lowercases header keys, but defensive: lowercase the lookup keys too.
const sourceHeaders = req.headers as Record<string, string | string[] | undefined>;
@@ -128,7 +134,7 @@ export class KoboProxyService {
body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
}
// codeql[js/request-forgery] - hostname validated by buildTargetUrl() to KOBO_API_HOSTNAME
// codeql[js/request-forgery] - hostname validated by buildTargetUrl() to the chosen Kobo upstream
const upstream = await fetch(targetUrl, {
method: req.method,
headers,
@@ -150,15 +156,15 @@ export class KoboProxyService {
* `PX.<base64>` and treats it as a fresh cursor, so a borrow call that uses it lands Kobo in a
* state where the device has an entitlement BookOrbit never sees on this iteration.
*/
async forward(req: FastifyRequest, reply: FastifyReply, deviceToken: string): Promise<void> {
async forward(req: FastifyRequest, reply: FastifyReply, deviceToken: string, upstream: KoboUpstream = 'store'): Promise<void> {
const startedAt = Date.now();
const targetUrl = this.resolveTargetUrl(req, deviceToken);
const targetUrl = this.resolveTargetUrl(req, deviceToken, upstream);
const deviceId = (req as unknown as { koboDevice?: { deviceId: number } }).koboDevice?.deviceId;
this.logger.debug(`[kobo.proxy] [start] deviceId=${deviceId ?? 'unknown'} - upstream Kobo relay started`);
try {
const response = await this.request(req, deviceToken);
const response = await this.request(req, deviceToken, { upstream });
this.sendUpstream(reply, response);
this.logger.debug(
`[kobo.proxy] [end] deviceId=${deviceId ?? 'unknown'} durationMs=${Date.now() - startedAt} upstreamStatus=${response.status} - upstream Kobo relay completed`,
@@ -187,21 +193,22 @@ export class KoboProxyService {
reply.send(response.body);
}
private resolveTargetUrl(req: FastifyRequest, deviceToken: string): string {
private resolveTargetUrl(req: FastifyRequest, deviceToken: string, upstream: KoboUpstream = 'store'): 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);
return this.buildTargetUrl(koboPath, upstream);
}
private buildTargetUrl(koboPath: string): string {
private buildTargetUrl(koboPath: string, upstream: KoboUpstream = 'store'): string {
const { base, hostname } = KOBO_UPSTREAMS[upstream];
let parsed: URL;
try {
parsed = new URL(koboPath, KOBO_API_BASE);
parsed = new URL(koboPath, base);
} catch {
throw new BadRequestException('Invalid proxy path');
}
if (parsed.hostname !== KOBO_API_HOSTNAME) {
if (parsed.hostname !== hostname) {
throw new BadRequestException('Invalid proxy path');
}
return parsed.toString();