mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
d9031a33cd
A Kobo sends Host without a port, and Tailscale Funnel repeats that Host in X-Forwarded-Host without adding X-Forwarded-Port. buildBaseUrl then skipped APP_URL because forwarded headers were present, so cover, reading state and download links lost the public port and pointed at port 443. The Kobo never reached the server for a download or a thumbnail, and the delivery stayed offered. Use APP_URL's origin whenever no port is given and its host matches, and, when the proxy names a scheme, only if the scheme matches too. Explicit ports, X-Forwarded-Port and hosts or schemes APP_URL does not share keep their previous behaviour. Fixes #2036
464 lines
20 KiB
TypeScript
464 lines
20 KiB
TypeScript
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<typeof appConfig>,
|
|
) {}
|
|
|
|
@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<StoreSyncResult | null> {
|
|
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<void> {
|
|
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<string, unknown>,
|
|
@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<string, unknown>[] | 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;
|
|
}
|
|
}
|
|
}
|