mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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<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 () => {
|
||||
syncService.getDelta.mockResolvedValue({
|
||||
entitlements: [{ NewEntitlement: { BookEntitlement: { Id: '12' } } }],
|
||||
|
||||
@@ -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<typeof appConfig>,
|
||||
) {}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user