From c1b4627e0d3b137a69b039980883e73e8d07e568 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:19:20 +0000 Subject: [PATCH] fix(kobo): take the Kobo's port from APP_URL when Host omits it A Kobo sends Host without a port, so cover, reading state and download links got the port the server listens on. Behind a published mapping such as 3010:3000 that is the internal port, which the Kobo cannot reach. Use APP_URL's origin when it names the same host; other hosts, explicit ports and forwarded headers behave as before. Fixes #1281 --- .../modules/kobo/kobo-sync.controller.test.ts | 64 +++++++++++++++++++ .../src/modules/kobo/kobo-sync.controller.ts | 28 ++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/server/src/modules/kobo/kobo-sync.controller.test.ts b/server/src/modules/kobo/kobo-sync.controller.test.ts index c07fb9676..12cf80902 100644 --- a/server/src/modules/kobo/kobo-sync.controller.test.ts +++ b/server/src/modules/kobo/kobo-sync.controller.test.ts @@ -58,6 +58,7 @@ describe('KoboSyncController', () => { proxyService as never, bookIdentityService as never, historyService as never, + { appUrl: 'http://localhost:6263' } as never, ); beforeEach(() => { @@ -200,6 +201,69 @@ describe('KoboSyncController', () => { expect((payload.Resources as Record).image_host).toBe('http://example.com'); }); + describe('a Kobo behind a published port mapping (#1281)', () => { + function controllerWithAppUrl(appUrl: string) { + return new KoboSyncController( + settingsService as never, + syncService as never, + readingStateService as never, + proxyService as never, + bookIdentityService as never, + historyService as never, + { appUrl } as never, + ); + } + + function koboRequest(host: string, extraHeaders: Record = {}) { + // A Kobo omits the port from Host; inside the container the server listens on 3000. + return { headers: { host, ...extraHeaders }, protocol: 'http', hostname: host, socket: { localPort: 3000 } }; + } + + function resources(controller: KoboSyncController, req: unknown) { + return controller.initialization({ deviceToken: 'abc' } as never, req as never).Resources as Record; + } + + it('takes the port from APP_URL when it names the host the Kobo sent', () => { + const res = resources(controllerWithAppUrl('http://192.168.1.67:3010'), koboRequest('192.168.1.67')); + + expect(res.image_host).toBe('http://192.168.1.67:3010'); + expect(res.library_sync).toBe('http://192.168.1.67:3010/api/v1/kobo/abc/v1/library/sync'); + expect(res.reading_services_host).toBe('http://192.168.1.67:3010'); + }); + + it('takes the scheme too, so a default-port APP_URL drops the listening port', () => { + expect(resources(controllerWithAppUrl('https://books.example.com'), koboRequest('BOOKS.example.com')).image_host).toBe( + 'https://books.example.com', + ); + }); + + it('keeps the listening port for a host APP_URL does not name, including the unset default', () => { + expect(resources(controllerWithAppUrl('http://192.168.1.67:3010'), koboRequest('namek.local')).image_host).toBe('http://namek.local:3000'); + expect(resources(controllerWithAppUrl('http://localhost:6263'), koboRequest('192.168.1.67')).image_host).toBe('http://192.168.1.67:3000'); + }); + + it('lets an explicit port or forwarded headers win over APP_URL', () => { + const controller = controllerWithAppUrl('http://192.168.1.67:3010'); + + expect(resources(controller, koboRequest('192.168.1.67:3000')).image_host).toBe('http://192.168.1.67:3000'); + expect(resources(controller, koboRequest('192.168.1.67', { 'x-forwarded-proto': 'https' })).image_host).toBe('https://192.168.1.67'); + }); + + it('builds library sync download links from the same origin', async () => { + syncService.getDelta.mockResolvedValue({ entitlements: [], hasMore: false, syncToken: 'token' }); + settingsService.getSettings.mockResolvedValue({ storeSync: false }); + + await controllerWithAppUrl('http://192.168.1.67:3010').librarySync( + { deviceId: 5, deviceToken: 'abc' } as never, + { id: 9 } as never, + koboRequest('192.168.1.67') as never, + makeReply() as never, + ); + + expect(syncService.getDelta).toHaveBeenCalledWith(9, 5, 'abc', 'http://192.168.1.67:3010'); + }); + }); + it('librarySync sets response headers and sends entitlement payload', async () => { syncService.getDelta.mockResolvedValue({ entitlements: [{ NewEntitlement: { BookEntitlement: { Id: '12' } } }], diff --git a/server/src/modules/kobo/kobo-sync.controller.ts b/server/src/modules/kobo/kobo-sync.controller.ts index 28efc0378..ef0e19286 100644 --- a/server/src/modules/kobo/kobo-sync.controller.ts +++ b/server/src/modules/kobo/kobo-sync.controller.ts @@ -7,6 +7,7 @@ import { HttpCode, HttpException, HttpStatus, + Inject, Logger, Param, Post, @@ -17,6 +18,7 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { Public } from '../../common/decorators/public.decorator'; @@ -32,6 +34,7 @@ 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'; @@ -62,7 +65,7 @@ function isBookOrbitTag(tagId: string): boolean { return tagId.startsWith('col-') || tagId.startsWith('ss-'); } -function buildBaseUrl(req: FastifyRequest): string { +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']); @@ -77,6 +80,8 @@ function buildBaseUrl(req: FastifyRequest): string { const isDefault = (proto === 'http' && port === '80') || (proto === 'https' && port === '443'); if (!isDefault) host = host + ':' + port; } else if (!hasForwarded) { + const appOrigin = appOriginForHost(appUrl, host); + if (appOrigin) return appOrigin; const localPort = req.socket?.localPort; const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443); if (localPort && !isDefault) host = host + ':' + String(localPort); @@ -86,6 +91,20 @@ function buildBaseUrl(req: FastifyRequest): string { 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). APP_URL names the reachable origin, so + * it is used when it is for the same host; any other host keeps the listening port. + */ +function appOriginForHost(appUrl: string, host: string): string | null { + try { + const url = new URL(appUrl); + return url.hostname.toLowerCase() === host.toLowerCase() ? url.origin : null; + } catch { + return null; + } +} + function buildReadingServicesBaseUrl(req: FastifyRequest, baseUrl: string): string { const localPort = req.socket?.localPort; if (!localPort) return baseUrl; @@ -112,12 +131,13 @@ export class KoboSyncController { 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); + const baseUrl = buildBaseUrl(req, this.app.appUrl); const readingServicesBaseUrl = buildReadingServicesBaseUrl(req, baseUrl); const t = device.deviceToken; return { @@ -144,7 +164,7 @@ export class KoboSyncController { ) { const startedAt = Date.now(); this.logger.debug(`[kobo.library_sync] [start] userId=${user.id} deviceId=${device.deviceId} - library sync started`); - const baseUrl = buildBaseUrl(req); + 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); @@ -352,7 +372,7 @@ export class KoboSyncController { ) { const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId); if (id === null) return this.proxyService.forward(req, reply, device.deviceToken); - const baseUrl = buildBaseUrl(req); + const baseUrl = buildBaseUrl(req, this.app.appUrl); const metadata = await this.syncService.getBookMetadata(user.id, id, device.deviceToken, baseUrl); reply.send(metadata); }