diff --git a/Dockerfile b/Dockerfile index e1bf949a7..287efe124 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ARG NODE_IMAGE=node:26.12.1-alpine3.23@sha256:bc2b334006f15c7167bbeb2f08f28c2df883f7594a0470e04454ebe7560cfe68 FROM ${NODE_IMAGE} AS base -RUN npm install -g pnpm@11.22.0 +RUN npm install -g pnpm@11.28.9 # Stage 1: Build client FROM base AS client-builder diff --git a/koreader-plugin/bookorbit.koplugin/main.lua b/koreader-plugin/bookorbit.koplugin/main.lua index 1f4fecfc8..771f35d09 100644 --- a/koreader-plugin/bookorbit.koplugin/main.lua +++ b/koreader-plugin/bookorbit.koplugin/main.lua @@ -51,7 +51,7 @@ local BookOrbitSidecar = require("bookorbit_sidecar") local BookOrbitSweep = require("bookorbit_sweep") local BookOrbitUpdater = require("bookorbit_updater") -local PLUGIN_VERSION = "2.1.0" +local PLUGIN_VERSION = "2.2.0" local SYNC_STRATEGY = { PROMPT = 1, diff --git a/server/src/common/text-match/title-match.test.ts b/server/src/common/text-match/title-match.test.ts index bfcbe701c..5b1b4fa91 100644 --- a/server/src/common/text-match/title-match.test.ts +++ b/server/src/common/text-match/title-match.test.ts @@ -96,6 +96,10 @@ describe('title-match', () => { expect(scoreTitleMatch('Foundatun', 'Foundation')).toBeGreaterThan(0); }); + it('does not let a volume digit in the query dilute the words a candidate shares', () => { + expect(scoreTitleMatch('The Faraway Paladin Volume 1', 'The Faraway Paladin: The Boy in the City of the Dead')).toBe(4); + }); + it('returns zero when either side is empty after normalization', () => { expect(scoreTitleMatch('', 'Dune')).toBe(0); expect(scoreTitleMatch('Dune', '???')).toBe(0); diff --git a/server/src/common/text-match/title-match.ts b/server/src/common/text-match/title-match.ts index cf8db1c2c..5775b45d7 100644 --- a/server/src/common/text-match/title-match.ts +++ b/server/src/common/text-match/title-match.ts @@ -81,6 +81,12 @@ export function tokenizeTitleText(value: string): string[] { return value.split(' ').filter((token) => token.length > 1 || /^\p{N}$/u.test(token)); } +// A lone digit tells volumes apart (symmetricTitleSimilarity), but in a fuzzy relevance match it dilutes the +// overlap ratio of a candidate that does not spell the volume number, pushing a right book under the floor. +function relevanceTokens(value: string): string[] { + return tokenizeTitleText(value).filter((token) => token.length > 1); +} + /** * Tokens worth matching on. Falls back to the raw tokens when a title is nothing but stopwords, * so a book genuinely titled "It" or "Them" still has something to match. @@ -134,8 +140,8 @@ export function scoreTitleMatch(query: string, candidate: string): number { return TITLE_MATCH_SCORES.substring; } - const queryTokens = significantTokens(tokenizeTitleText(normalizedQuery)); - const candidateTokens = new Set(tokenizeTitleText(normalizedCandidate)); + const queryTokens = significantTokens(relevanceTokens(normalizedQuery)); + const candidateTokens = new Set(relevanceTokens(normalizedCandidate)); const overlapRatio = queryTokens.length > 0 ? queryTokens.filter((token) => candidateTokens.has(token)).length / queryTokens.length : 0; // A minority of the query's meaningful words in common is not evidence of the same book; // "The Way of Kings" and "The Way We Were" share exactly one. diff --git a/server/src/modules/koreader/koreader-auth.guard.test.ts b/server/src/modules/koreader/koreader-auth.guard.test.ts index b2ea5f78c..4c61e80b6 100644 --- a/server/src/modules/koreader/koreader-auth.guard.test.ts +++ b/server/src/modules/koreader/koreader-auth.guard.test.ts @@ -163,9 +163,10 @@ describe('KoreaderAuthGuard', () => { expect(mockUserService.findByIdWithPermissions).not.toHaveBeenCalled(); }); - it('passes bcrypt authentication and sets koreaderUserId', async () => { + it('passes bcrypt authentication for plaintext clients when no md5 is stored', async () => { mockRequest.headers['x-auth-user'] = 'alice'; mockRequest.headers['x-auth-key'] = 'secret'; + mockRepo.findKoreaderUserByUsername.mockResolvedValueOnce(makeKoreaderUser({ passwordMd5: null })); mockCompare.mockResolvedValueOnce(true); await expect(guard.canActivate(mockContext as ExecutionContext)).resolves.toBe(true); @@ -176,10 +177,9 @@ describe('KoreaderAuthGuard', () => { expect(mockCreateHash).not.toHaveBeenCalled(); }); - it('passes MD5 authentication when bcrypt comparison fails', async () => { + it('passes MD5 authentication for a plaintext key without running bcrypt', async () => { mockRequest.headers['x-auth-user'] = 'alice'; mockRequest.headers['x-auth-key'] = 'secret'; - mockCompare.mockResolvedValueOnce(false); const mockHash = { update: vi.fn().mockReturnThis(), @@ -191,6 +191,7 @@ describe('KoreaderAuthGuard', () => { expect(mockCreateHash).toHaveBeenCalledWith('md5'); expect(mockHash.update).toHaveBeenCalledWith('secret'); + expect(mockCompare).not.toHaveBeenCalled(); expect(mockRequest.koreaderUserId).toBe(42); }); @@ -205,6 +206,7 @@ describe('KoreaderAuthGuard', () => { mockCreateHash.mockReturnValueOnce(mockHash as unknown as ReturnType); await expectGuardToReject(UnauthorizedException, 401, 'Invalid credentials'); + expect(mockCompare).toHaveBeenCalledWith('wrong-secret', 'bcrypt-hash'); expect(mockUserService.findByIdWithPermissions).not.toHaveBeenCalled(); }); @@ -213,7 +215,6 @@ describe('KoreaderAuthGuard', () => { mockRequest.headers['x-auth-key'] = 'secret'; mockRepo.findKoreaderUserByUsername.mockResolvedValueOnce(makeKoreaderUser({ userId: 99 })); mockUserService.findByIdWithPermissions.mockResolvedValueOnce(makeUser({ id: 99, username: 'alice' })); - mockCompare.mockResolvedValueOnce(true); await guard.canActivate(mockContext as ExecutionContext); @@ -224,7 +225,6 @@ describe('KoreaderAuthGuard', () => { it('returns 401 when user account is not found after credential validation', async () => { mockRequest.headers['x-auth-user'] = 'alice'; mockRequest.headers['x-auth-key'] = 'secret'; - mockCompare.mockResolvedValueOnce(true); mockUserService.findByIdWithPermissions.mockResolvedValueOnce(null); await expectGuardToReject(UnauthorizedException, 401, 'Account not found or disabled'); @@ -233,7 +233,6 @@ describe('KoreaderAuthGuard', () => { it('returns 401 when user account is disabled', async () => { mockRequest.headers['x-auth-user'] = 'alice'; mockRequest.headers['x-auth-key'] = 'secret'; - mockCompare.mockResolvedValueOnce(true); mockUserService.findByIdWithPermissions.mockResolvedValueOnce(makeUser({ active: false })); await expectGuardToReject(UnauthorizedException, 401, 'Account not found or disabled'); @@ -242,7 +241,6 @@ describe('KoreaderAuthGuard', () => { it('returns 401 when user lacks koreader_sync permission', async () => { mockRequest.headers['x-auth-user'] = 'alice'; mockRequest.headers['x-auth-key'] = 'secret'; - mockCompare.mockResolvedValueOnce(true); mockPermissionService.userHas.mockReturnValueOnce(false); await expectGuardToReject(UnauthorizedException, 401, 'KOReader sync permission revoked'); @@ -251,7 +249,6 @@ describe('KoreaderAuthGuard', () => { it('passes when user is superuser (bypasses permission check)', async () => { mockRequest.headers['x-auth-user'] = 'alice'; mockRequest.headers['x-auth-key'] = 'secret'; - mockCompare.mockResolvedValueOnce(true); mockUserService.findByIdWithPermissions.mockResolvedValueOnce(makeUser({ isSuperuser: true, permissions: [] })); mockPermissionService.userHas.mockReturnValueOnce(true); @@ -259,15 +256,43 @@ describe('KoreaderAuthGuard', () => { expect(mockPermissionService.userHas).toHaveBeenCalledWith(expect.objectContaining({ isSuperuser: true }), Permission.KoreaderSync); }); - it('passes when incoming key is exactly 32 hex chars matching md5 hash (pre-hashed key)', async () => { + it('passes when incoming key is exactly 32 hex chars matching md5 hash (pre-hashed key) without running bcrypt', async () => { const md5Key = 'abcdef0123456789abcdef0123456789'; mockRequest.headers['x-auth-user'] = 'alice'; mockRequest.headers['x-auth-key'] = md5Key; - mockCompare.mockResolvedValueOnce(false); mockRepo.findKoreaderUserByUsername.mockResolvedValueOnce(makeKoreaderUser({ passwordMd5: md5Key })); await expect(guard.canActivate(mockContext as ExecutionContext)).resolves.toBe(true); expect(mockCreateHash).not.toHaveBeenCalled(); + expect(mockCompare).not.toHaveBeenCalled(); + }); + + it('matches a pre-hashed key regardless of hex case', async () => { + mockRequest.headers['x-auth-user'] = 'alice'; + mockRequest.headers['x-auth-key'] = 'ABCDEF0123456789ABCDEF0123456789'; + mockRepo.findKoreaderUserByUsername.mockResolvedValueOnce(makeKoreaderUser({ passwordMd5: 'abcdef0123456789abcdef0123456789' })); + + await expect(guard.canActivate(mockContext as ExecutionContext)).resolves.toBe(true); + expect(mockCompare).not.toHaveBeenCalled(); + }); + + it('falls back to bcrypt when a 32-hex key is the plaintext password itself', async () => { + const hexPassword = '0123456789abcdef0123456789abcdef'; + mockRequest.headers['x-auth-user'] = 'alice'; + mockRequest.headers['x-auth-key'] = hexPassword; + mockCompare.mockResolvedValueOnce(true); + + await expect(guard.canActivate(mockContext as ExecutionContext)).resolves.toBe(true); + expect(mockCompare).toHaveBeenCalledWith(hexPassword, 'bcrypt-hash'); + expect(mockCreateHash).not.toHaveBeenCalled(); + }); + + it('returns 401 when a pre-hashed key does not match and bcrypt rejects it', async () => { + mockRequest.headers['x-auth-user'] = 'alice'; + mockRequest.headers['x-auth-key'] = '00000000000000000000000000000000'; + + await expectGuardToReject(UnauthorizedException, 401, 'Invalid credentials'); + expect(mockUserService.findByIdWithPermissions).not.toHaveBeenCalled(); }); it('returns 401 when md5 hash matches no password and no md5 stored', async () => { diff --git a/server/src/modules/koreader/koreader-auth.guard.ts b/server/src/modules/koreader/koreader-auth.guard.ts index 992f3b85d..072ac1ee7 100644 --- a/server/src/modules/koreader/koreader-auth.guard.ts +++ b/server/src/modules/koreader/koreader-auth.guard.ts @@ -9,6 +9,8 @@ import { PermissionService } from '../../common/services/permission.service'; import { UserService } from '../user/user.service'; import { KoreaderRepository } from './koreader.repository'; +const MD5_HEX = /^[0-9a-f]{32}$/i; + @Injectable() export class KoreaderAuthGuard implements CanActivate { constructor( @@ -58,18 +60,15 @@ export class KoreaderAuthGuard implements CanActivate { } private async verifyPassword(incoming: string, bcryptHash: string, md5Hash: string | null): Promise { - const bcryptMatch = await compare(incoming, bcryptHash); - if (bcryptMatch) return true; + // The KOReader plugin sends md5(password), which can never match the bcrypt hash of the plaintext. + // bcrypt costs hundreds of milliseconds of CPU, so it only runs when the cheap md5 check does not match. + if (md5Hash && this.matchesMd5(incoming, md5Hash)) return true; - if (md5Hash && incoming.length === 32 && /^[0-9a-f]{32}$/i.test(incoming)) { - return incoming.toLowerCase() === md5Hash.toLowerCase(); - } + return compare(incoming, bcryptHash); + } - if (md5Hash) { - const incomingMd5 = createHash('md5').update(incoming).digest('hex'); - return incomingMd5 === md5Hash.toLowerCase(); - } - - return false; + private matchesMd5(incoming: string, md5Hash: string): boolean { + const incomingMd5 = MD5_HEX.test(incoming) ? incoming : createHash('md5').update(incoming).digest('hex'); + return incomingMd5.toLowerCase() === md5Hash.toLowerCase(); } } diff --git a/workers/read-aloud/Dockerfile b/workers/read-aloud/Dockerfile index e9f4474e4..a00c19972 100644 --- a/workers/read-aloud/Dockerfile +++ b/workers/read-aloud/Dockerfile @@ -4,7 +4,7 @@ ARG NODE_IMAGE=node:26.12.1-trixie-slim FROM ${NODE_IMAGE} AS builder WORKDIR /app -RUN npm install -g pnpm@11.22.0 +RUN npm install -g pnpm@11.28.9 # The image runs on CPU only: without this, onnxruntime-node downloads its CUDA provider on # linux/x64, which cannot load here (no CUDA or cuDNN libraries) and only adds size.