import { Body, Controller, Delete, Get, Header, HttpCode, HttpException, HttpStatus, Inject, Logger, Param, Post, Put, Req, Res, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { Public } from '../../common/decorators/public.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { KoboDevice } from './decorators/kobo-device.decorator'; import type { KoboDeviceContext } from './guards/kobo-token.guard'; import { KoboTokenGuard } from './guards/kobo-token.guard'; import { KoboSettingsService } from './services/kobo-settings.service'; import { KoboSyncService } from './services/kobo-sync.service'; import { KoboReadingStateService } from './services/kobo-reading-state.service'; import type { KoboProxyResponse } from './services/kobo-proxy.service'; import { KoboProxyService } from './services/kobo-proxy.service'; import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin'; import { appConfig } from '../../config/config'; 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; // A request that reached the API through the Vite dev proxy names the client's port, not the API's. const DEV_CLIENT_PORT = new URL(DEV_CLIENT_ORIGIN).port; const KOBO_SYNC_TOKEN_HEADER = 'x-kobo-synctoken'; // Kobo answers a delete for a tag it does not know with a client error, and the device keeps the // operation queued until it succeeds, so relaying that error strands a tag that no longer exists // anywhere in a delete it can never complete. Server faults and auth failures still reach the // device, because those mean "try again later" rather than "already gone". const TAG_ALREADY_GONE_STATUSES: number[] = [HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND, HttpStatus.GONE]; type StoreSyncResult = { entitlements: unknown[]; hasMore: boolean; syncToken: string | undefined; }; function readHeaderValue(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } function isBookOrbitTag(tagId: string): boolean { return tagId.startsWith('col-') || tagId.startsWith('ss-'); } function buildBaseUrl(req: FastifyRequest, appUrl: string): string { const fwdHost = readHeaderValue(req.headers['x-forwarded-host']); const fwdPort = readHeaderValue(req.headers['x-forwarded-port']); const fwdProto = readHeaderValue(req.headers['x-forwarded-proto']); const hasForwarded = fwdHost || fwdPort || fwdProto; const proto = fwdProto ?? req.protocol; const headerHost = fwdHost ?? readHeaderValue(req.headers.host); let host = headerHost ?? req.hostname; if (!host.includes(':')) { const port = fwdPort ?? null; if (port) { const isDefault = (proto === 'http' && port === '80') || (proto === 'https' && port === '443'); if (!isDefault) host = host + ':' + port; } else { const appOrigin = appOriginForHost(appUrl, host, fwdProto); if (appOrigin) return appOrigin; if (!hasForwarded) { const localPort = req.socket?.localPort; const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443); if (localPort && !isDefault) host = host + ':' + String(localPort); } } } return proto + '://' + host; } /** * A Kobo leaves the port out of its Host header, and behind a published port mapping the port the * server listens on is not the one the Kobo reached (#1281). A forwarding proxy such as Tailscale * Funnel repeats that port-less Host in X-Forwarded-Host and adds no X-Forwarded-Port, so the * public port is lost the same way (#2036). APP_URL names the reachable origin, so it is used when * it is for the same host and, if the proxy named a scheme, the same scheme; any other host keeps * the listening port. */ function appOriginForHost(appUrl: string, host: string, forwardedProto: string | undefined): string | null { try { const url = new URL(appUrl); if (url.hostname.toLowerCase() !== host.toLowerCase()) return null; if (forwardedProto && url.protocol !== `${forwardedProto.toLowerCase()}:`) return null; return url.origin; } catch { return null; } } function buildReadingServicesBaseUrl(req: FastifyRequest, baseUrl: string): string { const localPort = req.socket?.localPort; if (!localPort) return baseUrl; const url = new URL(baseUrl); if (url.port !== DEV_CLIENT_PORT) return baseUrl; const proto = readHeaderValue(req.headers['x-forwarded-proto']) ?? req.protocol; url.protocol = proto + ':'; url.port = String(localPort); return url.toString().replace(/\/$/, ''); } @Controller('kobo/:deviceToken') @Public() @UseGuards(KoboTokenGuard) export class KoboSyncController { private readonly logger = new Logger(KoboSyncController.name); constructor( private readonly settingsService: KoboSettingsService, private readonly syncService: KoboSyncService, private readonly readingStateService: KoboReadingStateService, private readonly proxyService: KoboProxyService, private readonly bookIdentityService: KoboBookIdentityService, private readonly historyService: KoboSyncHistoryService, @Inject(appConfig.KEY) private readonly app: ConfigType, ) {} @Get('v1/initialization') @Header('x-kobo-apitoken', 'e30=') initialization(@KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest) { const baseUrl = buildBaseUrl(req, this.app.appUrl); const readingServicesBaseUrl = buildReadingServicesBaseUrl(req, baseUrl); const t = device.deviceToken; return { Resources: { ...KOBO_STORE_RESOURCES, image_host: baseUrl, image_url_template: `${baseUrl}/api/v1/kobo/${t}/v1/books/{ImageId}/thumbnail/{Width}/{Height}/false/image.jpg`, image_url_quality_template: `${baseUrl}/api/v1/kobo/${t}/v1/books/{ImageId}/thumbnail/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg`, library_sync: `${baseUrl}/api/v1/kobo/${t}/v1/library/sync`, reading_state: `${baseUrl}/api/v1/kobo/${t}/v1/library/{Ids}/state`, get_tests_request: `${baseUrl}/api/v1/kobo/${t}/v1/analytics/gettests`, post_analytics_event: `${baseUrl}/api/v1/kobo/${t}/v1/analytics/event`, reading_services_host: readingServicesBaseUrl, }, }; } @Get('v1/library/sync') async librarySync( @KoboDevice() device: KoboDeviceContext, @CurrentUser() user: RequestUser, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { const startedAt = Date.now(); this.logger.debug(`[kobo.library_sync] [start] userId=${user.id} deviceId=${device.deviceId} - library sync started`); const baseUrl = buildBaseUrl(req, this.app.appUrl); let result: { entitlements: unknown[]; hasMore: boolean; syncToken: string }; try { result = await this.syncService.getDelta(user.id, device.deviceId, device.deviceToken, baseUrl); } catch (error: unknown) { const errorClass = error instanceof Error ? error.name : 'UnknownError'; const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : 'unknown error'); this.logger.error( `[kobo.library_sync] [fail] userId=${user.id} deviceId=${device.deviceId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - library sync failed`, ); await this.historyService.recordFailure( { userId: user.id, deviceId: device.deviceId, event: 'library_sync', durationMs: Date.now() - startedAt, }, error, ); 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. const storeCursorCarried = store?.syncToken !== undefined && isUsableKoboSyncToken(store.syncToken); if (store?.syncToken !== undefined && !storeCursorCarried) { 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, and so does a dropped one, which // sends the store back to its first page. Keep paging the store only while the cursor the device // will send back actually advances. The page just fetched is still delivered either way. const storeCursorAdvanced = storeCursorCarried && 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} 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: merged.length, hasMore: combinedHasMore, ...(store ? { storeEntitlements: store.entitlements.length } : {}), }, }); 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 { 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') @HttpCode(HttpStatus.OK) async deleteTagItems( @Param('tagId') tagId: string, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { if (!isBookOrbitTag(tagId)) return this.forwardTagDelete(req, reply, device, tagId); reply.status(HttpStatus.OK).send({ RequestResult: 'Success' }); } @Post('v1/library/tags/:tagId/items') @HttpCode(HttpStatus.OK) async addTagItems(@Param('tagId') tagId: string, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply) { if (!isBookOrbitTag(tagId)) return this.proxyService.forward(req, reply, device.deviceToken); reply.status(HttpStatus.OK).send({ RequestResult: 'Success' }); } @Delete('v1/library/tags/:tagId') @HttpCode(HttpStatus.OK) async deleteTag(@Param('tagId') tagId: string, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply) { if (!isBookOrbitTag(tagId)) return this.forwardTagDelete(req, reply, device, tagId); reply.status(HttpStatus.OK).send({ RequestResult: 'Success' }); } /** * Forwards a delete for a tag BookOrbit does not own, reporting success when Kobo says the tag is * not there. Only deletes get this treatment: a failed add must stay a failure, because reporting * success would drop the device's pending change instead of retrying it. */ private async forwardTagDelete(req: FastifyRequest, reply: FastifyReply, device: KoboDeviceContext, tagId: string): Promise { let response: KoboProxyResponse; try { response = await this.proxyService.request(req, device.deviceToken); } catch (error: unknown) { // A path that does not resolve to Kobo is rejected before the request leaves, and that stays // the client error the shared proxy raises rather than becoming an upstream fault. if (error instanceof HttpException) throw error; const errorClass = error instanceof Error ? error.name : 'UnknownError'; const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : 'unknown error'); this.logger.warn( `[kobo.tag_delete] [fail] deviceId=${device.deviceId} errorClass=${errorClass} error="${errorMessage}" - upstream tag delete failed`, ); reply.status(HttpStatus.BAD_GATEWAY).send({ message: 'Upstream Kobo API unavailable' }); return; } if (TAG_ALREADY_GONE_STATUSES.includes(response.status)) { this.logger.warn( `[kobo.tag_delete] [end] deviceId=${device.deviceId} tagId="${sanitizeLogValue(tagId)}" status=${response.status} - upstream does not know this tag, reporting success so the device stops retrying`, ); reply.status(HttpStatus.OK).send({ RequestResult: 'Success' }); return; } this.proxyService.sendUpstream(reply, response); } @Get('v1/library/:bookId/metadata') async getBookMetadata( @Param('bookId') bookId: string, @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId); if (id === null) return this.proxyService.forward(req, reply, device.deviceToken); const baseUrl = buildBaseUrl(req, this.app.appUrl); const metadata = await this.syncService.getBookMetadata(user.id, id, device.deviceToken, baseUrl); reply.send(metadata); } @Delete('v1/library/:bookId') @HttpCode(HttpStatus.OK) async deleteFromLibrary( @Param('bookId') bookId: string, @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId); if (id === null) return this.proxyService.forward(req, reply, device.deviceToken); await this.syncService.removeBookFromSync(user.id, device.deviceId, id); reply.status(HttpStatus.OK).send(); } @Get('v1/library/:bookId/state') async getReadingState( @Param('bookId') bookId: string, @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId); if (id === null) return this.proxyService.forward(req, reply, device.deviceToken); const state = await this.readingStateService.getRawState(user.id, id, device.deviceId); reply.send(state ? [state] : []); } @Put('v1/library/:bookId/state') @UsePipes(new ValidationPipe({ transform: false, whitelist: false })) async updateReadingState( @Param('bookId') bookId: string, @Body() body: Record, @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId); if (id === null) return this.proxyService.forward(req, reply, device.deviceToken); const startedAt = Date.now(); try { const settings = await this.settingsService.getSettings(user.id); const states = body.ReadingStates as Record[] | undefined; const statePayload = states?.[0] ?? body; const result = await this.readingStateService.upsertState( user.id, id, statePayload, settings.readingThreshold, settings.finishedThreshold, settings.twoWayProgressSync, device.deviceId, ); await this.historyService.recordSuccess({ userId: user.id, deviceId: device.deviceId, event: 'progress_update', durationMs: Date.now() - startedAt, counts: await this.historyService.countsForBook(user.id, id, { progressUpdates: 1, twoWayProgressSync: settings.twoWayProgressSync }), }); reply.send(result); } catch (error: unknown) { await this.historyService.recordFailure( { userId: user.id, deviceId: device.deviceId, event: 'progress_update', durationMs: Date.now() - startedAt, }, error, ); throw error; } } }