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
This commit is contained in:
neon
2026-10-17 05:19:20 +00:00
parent 7f172fd8fb
commit c1b4627e0d
2 changed files with 88 additions and 4 deletions
@@ -58,6 +58,7 @@ describe('KoboSyncController', () => {
proxyService as never, proxyService as never,
bookIdentityService as never, bookIdentityService as never,
historyService as never, historyService as never,
{ appUrl: 'http://localhost:6263' } as never,
); );
beforeEach(() => { beforeEach(() => {
@@ -200,6 +201,69 @@ describe('KoboSyncController', () => {
expect((payload.Resources as Record<string, string>).image_host).toBe('http://example.com'); expect((payload.Resources as Record<string, string>).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<string, string> = {}) {
// 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<string, string>;
}
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 () => { it('librarySync sets response headers and sends entitlement payload', async () => {
syncService.getDelta.mockResolvedValue({ syncService.getDelta.mockResolvedValue({
entitlements: [{ NewEntitlement: { BookEntitlement: { Id: '12' } } }], entitlements: [{ NewEntitlement: { BookEntitlement: { Id: '12' } } }],
@@ -7,6 +7,7 @@ import {
HttpCode, HttpCode,
HttpException, HttpException,
HttpStatus, HttpStatus,
Inject,
Logger, Logger,
Param, Param,
Post, Post,
@@ -17,6 +18,7 @@ import {
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { Public } from '../../common/decorators/public.decorator'; 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 type { KoboProxyResponse } from './services/kobo-proxy.service';
import { KoboProxyService } from './services/kobo-proxy.service'; import { KoboProxyService } from './services/kobo-proxy.service';
import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin'; import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin';
import { appConfig } from '../../config/config';
import { KOBO_STORE_RESOURCES } from './kobo-store-resources'; import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
import { KoboBookIdentityService } from './services/kobo-book-identity.service'; import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.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-'); 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 fwdHost = readHeaderValue(req.headers['x-forwarded-host']);
const fwdPort = readHeaderValue(req.headers['x-forwarded-port']); const fwdPort = readHeaderValue(req.headers['x-forwarded-port']);
const fwdProto = readHeaderValue(req.headers['x-forwarded-proto']); 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'); const isDefault = (proto === 'http' && port === '80') || (proto === 'https' && port === '443');
if (!isDefault) host = host + ':' + port; if (!isDefault) host = host + ':' + port;
} else if (!hasForwarded) { } else if (!hasForwarded) {
const appOrigin = appOriginForHost(appUrl, host);
if (appOrigin) return appOrigin;
const localPort = req.socket?.localPort; const localPort = req.socket?.localPort;
const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443); const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443);
if (localPort && !isDefault) host = host + ':' + String(localPort); if (localPort && !isDefault) host = host + ':' + String(localPort);
@@ -86,6 +91,20 @@ function buildBaseUrl(req: FastifyRequest): string {
return proto + '://' + host; 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 { function buildReadingServicesBaseUrl(req: FastifyRequest, baseUrl: string): string {
const localPort = req.socket?.localPort; const localPort = req.socket?.localPort;
if (!localPort) return baseUrl; if (!localPort) return baseUrl;
@@ -112,12 +131,13 @@ export class KoboSyncController {
private readonly proxyService: KoboProxyService, private readonly proxyService: KoboProxyService,
private readonly bookIdentityService: KoboBookIdentityService, private readonly bookIdentityService: KoboBookIdentityService,
private readonly historyService: KoboSyncHistoryService, private readonly historyService: KoboSyncHistoryService,
@Inject(appConfig.KEY) private readonly app: ConfigType<typeof appConfig>,
) {} ) {}
@Get('v1/initialization') @Get('v1/initialization')
@Header('x-kobo-apitoken', 'e30=') @Header('x-kobo-apitoken', 'e30=')
initialization(@KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest) { initialization(@KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest) {
const baseUrl = buildBaseUrl(req); const baseUrl = buildBaseUrl(req, this.app.appUrl);
const readingServicesBaseUrl = buildReadingServicesBaseUrl(req, baseUrl); const readingServicesBaseUrl = buildReadingServicesBaseUrl(req, baseUrl);
const t = device.deviceToken; const t = device.deviceToken;
return { return {
@@ -144,7 +164,7 @@ export class KoboSyncController {
) { ) {
const startedAt = Date.now(); const startedAt = Date.now();
this.logger.debug(`[kobo.library_sync] [start] userId=${user.id} deviceId=${device.deviceId} - library sync started`); 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 }; let result: { entitlements: unknown[]; hasMore: boolean; syncToken: string };
try { try {
result = await this.syncService.getDelta(user.id, device.deviceId, device.deviceToken, baseUrl); 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); const id = await this.bookIdentityService.resolveBookIdByEntitlementId(user.id, bookId);
if (id === null) return this.proxyService.forward(req, reply, device.deviceToken); 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); const metadata = await this.syncService.getBookMetadata(user.id, id, device.deviceToken, baseUrl);
reply.send(metadata); reply.send(metadata);
} }