fix(kobo): fix device controller and service errors

This commit is contained in:
neon
2026-02-15 23:41:00 -06:00
parent 6865b07c79
commit 6721e99831
7 changed files with 258 additions and 29 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1771265803603,
"when": 1771266812483,
"tag": "0000_add_kobo_sync",
"breakpoints": true
}
+21 -1
View File
@@ -5,6 +5,7 @@ import { IoAdapter } from '@nestjs/platform-socket.io';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { join } from 'path';
import { Readable } from 'stream';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
@@ -16,7 +17,26 @@ async function bootstrap() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await migrate(drizzle(pool), { migrationsFolder: join(__dirname, '..', 'src', 'db', 'migrations') });
await pool.end();
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter({ logger: true }));
const adapter = new FastifyAdapter({ logger: true });
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter);
// Kobo devices send Content-Type: application/json with empty bodies on GET/DELETE.
// Fastify's default JSON parser rejects empty bodies, so we inject '{}' before parsing.
const fastify = adapter.getInstance();
fastify.addHook('preParsing', (request, _reply, payload, done) => {
const ct = request.headers['content-type'] ?? '';
const isJson = ct.startsWith('application/json');
const isEmpty = request.headers['content-length'] === '0' || request.headers['content-length'] === undefined;
if (isJson && isEmpty) {
const fake = new Readable();
fake.push('{}');
fake.push(null);
done(null, fake);
return;
}
done(null, payload);
});
app.useWebSocketAdapter(new IoAdapter(app));
@@ -14,6 +14,24 @@ import { KoboReadingStateService } from './services/kobo-reading-state.service';
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
import { KoboDownloadService } from './services/kobo-download.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
function buildBaseUrl(req: FastifyRequest): string {
const hasForwarded = req.headers['x-forwarded-host'] || req.headers['x-forwarded-port'] || req.headers['x-forwarded-proto'];
const proto = (req.headers['x-forwarded-proto'] as string | undefined) ?? req.protocol;
const headerHost = req.headers['x-forwarded-host'] ?? req.headers.host;
let host = headerHost ? (Array.isArray(headerHost) ? headerHost[0] : headerHost) : req.hostname;
if (!hasForwarded && !host.includes(':')) {
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;
}
@Controller('kobo/:deviceToken')
@Public()
@@ -40,29 +58,30 @@ export class KoboDeviceController {
};
}
@Post('v1/auth/refresh')
@HttpCode(HttpStatus.OK)
authRefresh(@Body() body: Record<string, unknown>) {
return {
AccessToken: randomUUID(),
RefreshToken: body.RefreshToken ?? randomUUID(),
TokenType: 'Bearer',
TrackingId: randomUUID(),
};
}
@Get('v1/initialization')
@Header('x-kobo-apitoken', 'e30=')
initialization(@KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest) {
const baseUrl = `${req.protocol}://${req.hostname}`;
const baseUrl = buildBaseUrl(req);
const t = device.deviceToken;
return {
Resources: {
image_host: baseUrl,
image_url_template: `/api/kobo/${t}/v1/books/{ImageId}/thumbnail/{Width}/{Height}/false/image.jpg`,
image_url_quality_template: `/api/kobo/${t}/v1/books/{ImageId}/thumbnail/{Width}/{Height}/{Quality}/false/image.jpg`,
library_sync: `/api/kobo/${t}/v1/library/sync`,
library_items: `/api/kobo/${t}/v1/library/{RevisionId}`,
library_metadata: `/api/kobo/${t}/v1/library/{RevisionId}/metadata`,
library_prices: `/api/kobo/${t}/v1/library/{RevisionId}/prices`,
library_book_get: `/api/kobo/${t}/v1/library/{RevisionId}`,
sign_in_page: `${baseUrl}/api/kobo/${t}/v1/signin`,
userguide_host: `${baseUrl}/api/kobo/${t}`,
user_loyalty_benefits: `/api/kobo/${t}/v1/user/loyalty/benefits`,
giftcard_epd_redeem_url: `${baseUrl}/api/kobo/${t}/v1/giftcard/redeem`,
get_tests_endpoint: `/api/kobo/${t}/v1/analytics/gettests`,
},
BookEntitlementsUrl: `/api/kobo/${t}/v1/library/sync`,
const resources = {
...KOBO_STORE_RESOURCES,
image_host: baseUrl,
image_url_template: `${baseUrl}/api/kobo/${t}/v1/books/{ImageId}/thumbnail/{Width}/{Height}/false/image.jpg`,
image_url_quality_template: `${baseUrl}/api/kobo/${t}/v1/books/{ImageId}/thumbnail/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg`,
library_sync: `${baseUrl}/api/kobo/${t}/v1/library/sync`,
};
return { Resources: resources };
}
@Post('v1/analytics/gettests')
@@ -85,7 +104,7 @@ export class KoboDeviceController {
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
) {
const baseUrl = `${req.protocol}://${req.hostname}`;
const baseUrl = buildBaseUrl(req);
const settings = await this.settingsService.getSettings(user.id);
const { entitlements, hasMore, syncToken } = await this.syncService.getDelta(
user.id,
@@ -110,7 +129,7 @@ export class KoboDeviceController {
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const baseUrl = `${req.protocol}://${req.hostname}`;
const baseUrl = buildBaseUrl(req);
const metadata = await this.syncService.getBookMetadata(user.id, id, device.deviceToken, baseUrl);
reply.send(metadata);
}
@@ -0,0 +1,166 @@
// prettier-ignore
export const KOBO_STORE_RESOURCES: Record<string, unknown> = {
account_page: 'https://www.kobo.com/account/settings',
account_page_rakuten: 'https://my.rakuten.co.jp/',
add_device: 'https://storeapi.kobo.com/v1/user/add-device',
add_entitlement: 'https://storeapi.kobo.com/v1/library/{RevisionIds}',
affiliaterequest: 'https://storeapi.kobo.com/v1/affiliate',
assets: 'https://storeapi.kobo.com/v1/assets',
audiobook: 'https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}',
audiobook_detail_page: 'https://www.kobo.com/{region}/{language}/audiobook/{slug}',
audiobook_get_credits: 'https://www.kobo.com/{region}/{language}/audiobooks/plans',
audiobook_landing_page: 'https://www.kobo.com/{region}/{language}/audiobooks',
audiobook_preview: 'https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview',
audiobook_purchase_withcredit: 'https://storeapi.kobo.com/v1/store/audiobook/{Id}',
audiobook_subscription_management: 'https://www.kobo.com/{region}/{language}/account/subscriptions',
audiobook_subscription_orange_deal_inclusion_url: 'https://authorize.kobo.com/inclusion',
audiobook_subscription_purchase: 'https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280',
audiobook_subscription_tiers: 'https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280',
authorproduct_recommendations: 'https://storeapi.kobo.com/v1/products/books/authors/recommendations',
autocomplete: 'https://storeapi.kobo.com/v1/products/autocomplete',
blackstone_header: { key: 'x-amz-request-payer', value: 'requester' },
book: 'https://storeapi.kobo.com/v1/products/books/{ProductId}',
book_detail_page: 'https://www.kobo.com/{region}/{language}/ebook/{slug}',
book_detail_page_rakuten: 'https://books.rakuten.co.jp/rk/{crossrevisionid}',
book_landing_page: 'https://www.kobo.com/ebooks',
book_subscription: 'https://storeapi.kobo.com/v1/products/books/subscriptions',
browse_history: 'https://storeapi.kobo.com/v1/user/browsehistory',
categories: 'https://storeapi.kobo.com/v1/categories',
categories_page: 'https://www.kobo.com/ebooks/categories',
category: 'https://storeapi.kobo.com/v1/categories/{CategoryId}',
category_featured_lists: 'https://storeapi.kobo.com/v1/categories/{CategoryId}/featured',
category_products: 'https://storeapi.kobo.com/v1/categories/{CategoryId}/products',
checkout_borrowed_book: 'https://storeapi.kobo.com/v1/library/borrow',
client_authd_referral: 'https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink',
configuration_data: 'https://storeapi.kobo.com/v1/configuration',
content_access_book: 'https://storeapi.kobo.com/v1/products/books/{ProductId}/access',
customer_care_live_chat: 'https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO',
daily_deal: 'https://storeapi.kobo.com/v1/products/dailydeal',
deals: 'https://storeapi.kobo.com/v1/deals',
delete_entitlement: 'https://storeapi.kobo.com/v1/library/{Ids}',
delete_tag: 'https://storeapi.kobo.com/v1/library/tags/{TagId}',
delete_tag_items: 'https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete',
device_auth: 'https://storeapi.kobo.com/v1/auth/device',
device_refresh: 'https://storeapi.kobo.com/v1/auth/refresh',
dictionary_host: 'https://ereaderfiles.kobo.com',
discovery_host: 'https://discovery.kobobooks.com',
dropbox_link_account_poll: 'https://authorize.kobo.com/{region}/{language}/LinkDropbox',
dropbox_link_account_start: 'https://authorize.kobo.com/LinkDropbox/start',
eula_page: 'https://www.kobo.com/termsofuse?style=onestore',
ereaderdevices: 'https://storeapi.kobo.com/v2/products/EReaderDeviceFeeds',
exchange_auth: 'https://storeapi.kobo.com/v1/auth/exchange',
external_book: 'https://storeapi.kobo.com/v1/products/books/external/{Ids}',
facebook_sso_page: 'https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://kobo.com/',
featured_list: 'https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}',
featured_lists: 'https://storeapi.kobo.com/v1/products/featured',
free_books_page: {
EN: 'https://www.kobo.com/{region}/{language}/p/free-ebooks',
FR: 'https://www.kobo.com/{region}/{language}/p/livres-gratuits',
IT: 'https://www.kobo.com/{region}/{language}/p/libri-gratuiti',
NL: 'https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg',
PT: 'https://www.kobo.com/{region}/{language}/p/livros-gratis',
},
fte_feedback: 'https://storeapi.kobo.com/v1/products/ftefeedback',
funnel_metrics: 'https://storeapi.kobo.com/v1/funnelmetrics',
get_download_keys: 'https://storeapi.kobo.com/v1/library/downloadkeys',
get_download_link: 'https://storeapi.kobo.com/v1/library/downloadlink',
get_tests_request: 'https://storeapi.kobo.com/v1/analytics/gettests',
giftcard_epd_redeem_url: 'https://www.kobo.com/{storefront}/{language}/redeem-ereader',
giftcard_redeem_url: 'https://www.kobo.com/{storefront}/{language}/redeem',
googledrive_link_account_start: 'https://authorize.kobo.com/{region}/{language}/linkcloudstorage/provider/google_drive',
gpb_flow_enabled: 'False',
help_page: 'https://www.kobo.com/help',
image_host: 'https://cdn.kobo.com/book-images/',
image_url_quality_template: 'https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg',
image_url_template: 'https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg',
instapaper_enabled: 'True',
instapaper_env_url: 'https://www.instapaper.com/api/kobo',
instapaper_link_account_start: 'https://authorize.kobo.com/{region}/{language}/linkinstapaper',
kobo_audiobooks_credit_redemption: 'True',
kobo_audiobooks_enabled: 'True',
kobo_audiobooks_orange_deal_enabled: 'True',
kobo_audiobooks_subscriptions_enabled: 'True',
kobo_display_price: 'True',
kobo_dropbox_link_account_enabled: 'True',
kobo_google_tax: 'False',
kobo_googledrive_link_account_enabled: 'True',
kobo_nativeborrow_enabled: 'True',
kobo_onedrive_link_account_enabled: 'False',
kobo_onestorelibrary_enabled: 'False',
kobo_privacyCentre_url: 'https://www.kobo.com/privacy',
kobo_redeem_enabled: 'True',
kobo_shelfie_enabled: 'False',
kobo_subscriptions_enabled: 'True',
kobo_superpoints_enabled: 'True',
kobo_wishlist_enabled: 'True',
library_book: 'https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}',
library_items: 'https://storeapi.kobo.com/v1/user/library',
library_metadata: 'https://storeapi.kobo.com/v1/library/{Ids}/metadata',
library_prices: 'https://storeapi.kobo.com/v1/user/library/previews/prices',
library_search: 'https://storeapi.kobo.com/v1/library/search',
library_sync: 'https://storeapi.kobo.com/v1/library/sync',
love_dashboard_page: 'https://www.kobo.com/{region}/{language}/kobosuperpoints',
love_points_redemption_page: 'https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}',
magazine_landing_page: 'https://www.kobo.com/emagazines',
more_sign_in_options: 'https://authorize.kobo.com/signin?returnUrl=http://kobo.com/#allProviders',
notebooks: 'https://storeapi.kobo.com/api/internal/notebooks',
notifications_registration_issue: 'https://storeapi.kobo.com/v1/notifications/registration',
oauth_host: 'https://oauth.kobo.com',
password_retrieval_page: 'https://www.kobo.com/passwordretrieval.html',
personalizedrecommendations: 'https://storeapi.kobo.com/v2/users/personalizedrecommendations',
pocket_link_account_start: 'https://authorize.kobo.com/{region}/{language}/linkpocket',
post_analytics_event: 'https://storeapi.kobo.com/v1/analytics/event',
ppx_purchasing_url: 'https://purchasing.kobo.com',
privacy_page: 'https://www.kobo.com/privacypolicy?style=onestore',
product_nextread: 'https://storeapi.kobo.com/v1/products/{ProductIds}/nextread',
product_prices: 'https://storeapi.kobo.com/v1/products/{ProductIds}/prices',
product_recommendations: 'https://storeapi.kobo.com/v1/products/{ProductId}/recommendations',
product_reviews: 'https://storeapi.kobo.com/v1/products/{ProductIds}/reviews',
products: 'https://storeapi.kobo.com/v1/products',
productsv2: 'https://storeapi.kobo.com/v2/products',
provider_external_sign_in_page: 'https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://kobo.com/',
purchase_buy: 'https://www.kobo.com/checkoutoption/',
purchase_buy_templated: 'https://www.kobo.com/{region}/{language}/checkoutoption/{ProductId}',
quickbuy_checkout: 'https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout',
quickbuy_create: 'https://storeapi.kobo.com/v1/store/quickbuy/purchase',
rakuten_token_exchange: 'https://storeapi.kobo.com/v1/auth/rakuten_token_exchange',
rating: 'https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}',
reading_services_host: 'https://readingservices.kobo.com',
reading_state: 'https://storeapi.kobo.com/v1/library/{Ids}/state',
redeem_interstitial_page: 'https://www.kobo.com',
registration_page: 'https://authorize.kobo.com/signup?returnUrl=http://kobo.com/',
related_items: 'https://storeapi.kobo.com/v1/products/{Id}/related',
remaining_book_series: 'https://storeapi.kobo.com/v1/products/books/series/{SeriesId}',
rename_tag: 'https://storeapi.kobo.com/v1/library/tags/{TagId}',
review: 'https://storeapi.kobo.com/v1/products/reviews/{ReviewId}',
review_sentiment: 'https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}',
shelfie_recommendations: 'https://storeapi.kobo.com/v1/user/recommendations/shelfie',
sign_in_page: 'https://auth.kobobooks.com/ActivateOnWeb',
social_authorization_host: 'https://social.kobobooks.com:8443',
social_host: 'https://social.kobobooks.com',
store_home: 'www.kobo.com/{region}/{language}',
store_host: 'www.kobo.com',
store_newreleases: 'https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA',
store_search: 'https://www.kobo.com/{region}/{language}/Search?Query={query}',
store_top50: 'https://www.kobo.com/{region}/{language}/ebooks/Top',
subs_landing_page: 'https://www.kobo.com/{region}/{language}/plus',
subs_management_page: 'https://www.kobo.com/{region}/{language}/account/subscriptions',
subs_plans_page: 'https://www.kobo.com/{region}/{language}/plus/plans',
subs_purchase_buy_templated: 'https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}',
subscription_publisher_price_page: 'https://www.kobo.com/{region}/{language}/subscriptionpublisherprice',
tag_items: 'https://storeapi.kobo.com/v1/library/tags/{TagId}/Items',
tags: 'https://storeapi.kobo.com/v1/library/tags',
taste_profile: 'https://storeapi.kobo.com/v1/products/tasteprofile',
terms_of_sale_page: 'https://authorize.kobo.com/{region}/{language}/terms/termsofsale',
update_accessibility_to_preview: 'https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview',
use_one_store: 'True',
user_loyalty_benefits: 'https://storeapi.kobo.com/v1/user/loyalty/benefits',
user_platform: 'https://storeapi.kobo.com/v1/user/platform',
user_profile: 'https://storeapi.kobo.com/v1/user/profile',
user_ratings: 'https://storeapi.kobo.com/v1/user/ratings',
user_recommendations: 'https://storeapi.kobo.com/v1/user/recommendations',
user_reviews: 'https://storeapi.kobo.com/v1/user/reviews',
user_wishlist: 'https://storeapi.kobo.com/v1/user/wishlist',
userguide_host: 'https://ereaderfiles.kobo.com',
wishlist_page: 'https://www.kobo.com/{region}/{language}/account/wishlist',
};
@@ -7,6 +7,7 @@ const FORWARD_HEADERS = [
'accept',
'accept-encoding',
'accept-language',
'authorization',
'content-type',
'user-agent',
'x-kobo-appversion',
@@ -35,10 +36,15 @@ export class KoboProxyService {
}
try {
let body: string | undefined;
if (!['GET', 'HEAD'].includes(req.method) && req.body != null) {
body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
}
const upstream = await fetch(targetUrl, {
method: req.method,
headers,
body: ['GET', 'HEAD'].includes(req.method) ? undefined : (req.body as string | undefined),
body,
});
reply.status(upstream.status);
@@ -48,8 +54,8 @@ export class KoboProxyService {
}
});
const body = await upstream.arrayBuffer();
reply.send(Buffer.from(body));
const responseBody = await upstream.arrayBuffer();
reply.send(Buffer.from(responseBody));
} catch (err) {
this.logger.warn(`Proxy failed for ${targetUrl}: ${(err as Error).message}`);
reply.status(502).send({ message: 'Upstream Kobo API unavailable' });
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { and, eq, isNull, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db/db.module';
import { DB } from '../../../db';
import * as schema from '../../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -25,6 +25,24 @@ export class KoboReadingStateService {
const entitlementId = String(bookId);
const now = new Date().toISOString();
const book = await this.db.query.books.findFirst({
where: eq(schema.books.id, bookId),
columns: { id: true },
});
if (!book) {
return {
RequestResult: 'Success',
UpdateResults: [
{
EntitlementId: entitlementId,
CurrentBookmarkResult: { Result: 'Ignored' },
StatisticsResult: { Result: 'Ignored' },
StatusInfoResult: { Result: 'Ignored' },
},
],
};
}
const created = (payload.Created as string | undefined) ?? now;
const lastModified = (payload.LastModified as string | undefined) ?? now;
const priorityTimestamp = (payload.PriorityTimestamp as string | undefined) ?? lastModified;
@@ -1,8 +1,8 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db/db.module';
import { DB } from '../../../db';
import * as schema from '../../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -30,7 +30,7 @@ export class KoboSettingsService {
row = inserted ?? (await this.db.query.koboSyncSettings.findFirst({ where: eq(schema.koboSyncSettings.userId, userId) }));
}
if (!row) throw new Error('Failed to create kobo settings row');
if (!row) throw new InternalServerErrorException('Failed to create kobo settings row');
return {
readingThreshold: row.readingThreshold,
finishedThreshold: row.finishedThreshold,