diff --git a/server/src/modules/kobo/kobo-reading-services.controller.test.ts b/server/src/modules/kobo/kobo-reading-services.controller.test.ts index 28f633d0c..fa0a09dbd 100644 --- a/server/src/modules/kobo/kobo-reading-services.controller.test.ts +++ b/server/src/modules/kobo/kobo-reading-services.controller.test.ts @@ -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: [] }); + }); + }); }); diff --git a/server/src/modules/kobo/kobo-reading-services.controller.ts b/server/src/modules/kobo/kobo-reading-services.controller.ts index 2dd27fdc8..70ca0493b 100644 --- a/server/src/modules/kobo/kobo-reading-services.controller.ts +++ b/server/src/modules/kobo/kobo-reading-services.controller.ts @@ -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(); } } diff --git a/server/src/modules/kobo/services/kobo-proxy.service.test.ts b/server/src/modules/kobo/services/kobo-proxy.service.test.ts index 445b9c0e3..f10cdbe03 100644 --- a/server/src/modules/kobo/services/kobo-proxy.service.test.ts +++ b/server/src/modules/kobo/services/kobo-proxy.service.test.ts @@ -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 = { diff --git a/server/src/modules/kobo/services/kobo-proxy.service.ts b/server/src/modules/kobo/services/kobo-proxy.service.ts index febeb43cb..3a856eb7c 100644 --- a/server/src/modules/kobo/services/kobo-proxy.service.ts +++ b/server/src/modules/kobo/services/kobo-proxy.service.ts @@ -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 = { + 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 { - 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; @@ -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.` 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 { + async forward(req: FastifyRequest, reply: FastifyReply, deviceToken: string, upstream: KoboUpstream = 'store'): Promise { 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();