fix(security): complete sanitizeLogValue sweep, DOMParser stripHtml, least-privilege CI permissions, pin Dockerfile image

This commit is contained in:
neon
2026-05-14 23:37:32 +00:00
parent 86147ead2c
commit 3138cf3a4d
38 changed files with 322 additions and 108 deletions
+1 -1
View File
@@ -2,6 +2,6 @@
## Reporting a Vulnerability
Use GitHub's [private vulnerability reporting](../../security/advisories/new) to report security issues confidentially. Do not open public issues for security vulnerabilities.
Use GitHub's [private vulnerability reporting](https://github.com/bookorbit/bookorbit/security/advisories/new) to report security issues confidentially. Do not open public issues for security vulnerabilities.
I aim to acknowledge reports within a few days and publish a fix as quickly as the severity warrants.
+3
View File
@@ -43,6 +43,9 @@ jobs:
name: ${{ inputs.check-name }}
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.job-timeout-minutes }}
permissions:
contents: read
checks: write
services:
postgres:
image: pgvector/pgvector:pg16
+6 -3
View File
@@ -14,9 +14,7 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
checks: write
permissions: read-all
env:
NODE_VERSION: "24"
@@ -26,6 +24,8 @@ jobs:
name: Build Suite Matrix
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
matrix: ${{ steps.build.outputs.matrix }}
steps:
@@ -48,6 +48,9 @@ jobs:
fail-fast: false
matrix:
suite: ${{ fromJSON(needs.setup.outputs.matrix) }}
permissions:
contents: read
checks: write
uses: ./.github/workflows/e2e-runner.yml
with:
suite-id: ${{ matrix.suite.id }}
+1 -6
View File
@@ -11,12 +11,7 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && 'release' || format('pr-{0}', github.event.pull_request.number) }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
permissions:
actions: read
contents: write
issues: write
pull-requests: write
packages: write
permissions: read-all
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+2 -2
View File
@@ -1,7 +1,7 @@
ARG NODE_IMAGE=node:24.11-alpine
ARG NODE_IMAGE=node:24.11-alpine@sha256:682368d8253e0c3364b803956085c456a612d738bd635926d73fa24db3ce53d7
FROM ${NODE_IMAGE} AS base
RUN npm install -g pnpm@10
RUN npm install -g pnpm@10.33.4
# Stage 1: Build client
FROM base AS client-builder
+13
View File
@@ -300,6 +300,19 @@
@apply bg-background text-foreground font-sans antialiased;
}
@supports (-webkit-touch-callout: none) {
@media (pointer: coarse) {
/* iOS Safari auto-zooms focused inputs below 16px. Keep editable controls at 16px on touch devices. */
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']):not([type='file']):not([type='button']):not(
[type='submit']
):not([type='reset']):not([data-ios-zoom-exempt]),
textarea:not([data-ios-zoom-exempt]),
select:not([data-ios-zoom-exempt]) {
font-size: 16px;
}
}
}
@media (pointer: coarse) {
.app-shell-inset,
.app-shell-scroll {
+4 -4
View File
@@ -104,7 +104,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
<template>
<div class="login-bg min-h-screen flex items-center justify-center px-4 overflow-hidden">
<!-- Compact theme picker -->
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
<div class="fixed top-5 right-5 z-20 flex items-center gap-1.5">
<!-- Dark / light toggle -->
<Tooltip>
<TooltipTrigger as-child>
@@ -119,7 +119,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
<!-- Radius picker -->
<div class="relative">
<Transition name="popover">
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg">
<div v-if="radiusOpen" class="accent-popover absolute top-full right-0 mt-2 p-2.5 rounded-lg">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
@@ -153,7 +153,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
<!-- Background picker -->
<div class="relative">
<Transition name="popover">
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
<div v-if="backgroundOpen" class="accent-popover absolute top-full right-0 mt-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
<div class="grid grid-cols-5 gap-1.5">
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
<TooltipTrigger as-child>
@@ -186,7 +186,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
<div class="relative">
<!-- Colour popover -->
<Transition name="popover">
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-lg space-y-2">
<div v-if="accentOpen" class="accent-popover absolute top-full right-0 mt-2 p-3 rounded-lg space-y-2">
<div class="flex items-center gap-1.5">
<Tooltip v-for="opt in ACCENT_VIVID" :key="opt.id">
<TooltipTrigger as-child>
@@ -325,6 +325,63 @@ describe('useDictionary', () => {
expect(result!.entries[0]!.definitions[0]!.definition).toBe("Say 'hi'")
})
it('Wiktionary: strips script tags and their content from definitions', async () => {
fetchMock.mockResolvedValueOnce(
mockOk({
en: [
{
partOfSpeech: 'Noun',
definitions: [{ definition: 'Normal text.<script>alert(1)</script>', examples: [] }],
},
],
}),
)
const { lookup } = await load()
const result = await lookup('test', 'fr')
const def = result!.entries[0]!.definitions[0]!.definition
expect(def).not.toContain('<script>')
expect(def).not.toContain('</script>')
expect(def).toContain('Normal text.')
})
it('Wiktionary: handles partial/unclosed HTML tags that old regex could not strip', async () => {
fetchMock.mockResolvedValueOnce(
mockOk({
en: [
{
partOfSpeech: 'Noun',
definitions: [{ definition: '<script src="evil.js"', examples: [] }],
},
],
}),
)
const { lookup } = await load()
const result = await lookup('test', 'fr')
// DOMParser parses the partial tag as an HTML element - textContent is empty, so the
// definition is skipped and there are no entries.
expect(result).toBeNull()
})
it('Wiktionary: trims whitespace-only definitions after stripping', async () => {
fetchMock.mockResolvedValueOnce(
mockOk({
en: [
{
partOfSpeech: 'Noun',
definitions: [
{ definition: '<span> </span>', examples: [] },
{ definition: 'Valid.', examples: [] },
],
},
],
}),
)
const { lookup } = await load()
const result = await lookup('test', 'fr')
expect(result!.entries[0]!.definitions).toHaveLength(1)
expect(result!.entries[0]!.definitions[0]!.definition).toBe('Valid.')
})
it('Wiktionary: strips HTML from example text', async () => {
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
const { lookup } = await load()
@@ -1,25 +1,7 @@
import type { DictionaryDefinition, DictionaryEntry, DictionaryResult } from '@bookorbit/types'
const HTML_TAG_RE = /<[^>]*>/g
const HTML_ENTITY_RE = /&(\w+);/g
const NUMERIC_ENTITY_RE = /&#(\d+);/g
const HTML_ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
ndash: '\u2013',
mdash: '\u2014',
}
function stripHtml(html: string): string {
let text = html.replace(HTML_TAG_RE, '')
text = text.replace(HTML_ENTITY_RE, (_, name) => HTML_ENTITIES[name] ?? `&${name};`)
text = text.replace(NUMERIC_ENTITY_RE, (_, code) => String.fromCharCode(Number(code)))
return text.trim()
return (new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '').trim()
}
function normalizeLang(lang: string): string {
@@ -250,4 +250,37 @@ describe('AuthorEnrichmentOrchestratorService', () => {
expect(session.getSnapshot().sessionTotal).toBe(4);
expect(gateway.emitStatus).toHaveBeenCalled();
});
it('pollOnce catches unexpected errors and does not re-throw', async () => {
queueRepo.recoverStuckProcessing.mockRejectedValue(new Error('DB "connection" lost\nnewline'));
await expect((service as any).pollOnce()).resolves.toBeUndefined();
expect((service as any).running).toBe(false);
});
it('pollOnce resets running flag in finally even on error', async () => {
queueRepo.fetchDue.mockRejectedValue(new Error('unexpected'));
(service as any).running = false;
await (service as any).pollOnce();
expect((service as any).running).toBe(false);
});
it('processOne logs retry attempt and marks failed with nextAttemptAt', async () => {
executor.execute.mockResolvedValue({
kind: 'failed',
message: 'rate limited: "429" response\nnewline',
provider: 'audnexus',
httpStatus: 429,
retryAfterMs: 60_000,
transient: true,
descriptionUpdated: false,
imageUpdated: false,
});
await (service as any).processOne(55, 0);
expect(queueRepo.markFailed).toHaveBeenCalledWith(expect.objectContaining({ authorId: 55, nextAttemptAt: expect.any(Date) }));
});
});
@@ -5,6 +5,7 @@ import { NotificationType } from '@bookorbit/types';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { NotificationService } from '../notification/notification.service';
import { METADATA_AUTHORS_REPLACED, MetadataAuthorsReplacedEvent, MetadataEventsService } from '../metadata/metadata-events.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
import { AuthorEnrichmentGateway } from './author-enrichment.gateway';
@@ -167,7 +168,7 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
}
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const message = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(`[author.enrichment.poll] [fail] errorClass=${errorClass} error="${message}" - author enrichment poll failed`);
} finally {
this.running = false;
@@ -245,11 +246,11 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
if (nextAttemptAt) {
this.logger.debug(
`[author.enrichment.process] [fail] authorId=${authorId} attempts=${attemptNumber}/${MAX_ATTEMPTS} status=${result.httpStatus ?? 'none'} nextAttemptAt=${nextAttemptAt.toISOString()} errorClass=AuthorEnrichmentError error="${truncateError(result.message).replace(/"/g, '\\"')}" - author enrichment failed and will retry`,
`[author.enrichment.process] [fail] authorId=${authorId} attempts=${attemptNumber}/${MAX_ATTEMPTS} status=${result.httpStatus ?? 'none'} nextAttemptAt=${nextAttemptAt.toISOString()} errorClass=AuthorEnrichmentError error="${sanitizeLogValue(truncateError(result.message))}" - author enrichment failed and will retry`,
);
} else {
this.logger.warn(
`[author.enrichment.process] [fail] authorId=${authorId} attempts=${attemptNumber} status=${result.httpStatus ?? 'none'} errorClass=AuthorEnrichmentError error="${truncateError(result.message).replace(/"/g, '\\"')}" - author enrichment failed`,
`[author.enrichment.process] [fail] authorId=${authorId} attempts=${attemptNumber} status=${result.httpStatus ?? 'none'} errorClass=AuthorEnrichmentError error="${sanitizeLogValue(truncateError(result.message))}" - author enrichment failed`,
);
}
@@ -145,4 +145,20 @@ describe('AuthorEnrichmentGateway', () => {
expect(client.disconnect).toHaveBeenCalledTimes(1);
expect(client.emit).not.toHaveBeenCalled();
});
it('handleConnection disconnects and does not throw when error message has special characters', async () => {
const { gateway, jwtService } = makeGateway();
jwtService.verify.mockImplementation(() => {
throw new Error('JWT "expired"\nlog injection attempt');
});
const client = {
id: 'sock-4',
handshake: { auth: { token: 'bad-token' } },
emit: vi.fn(),
disconnect: vi.fn(),
} as any;
await expect(gateway.handleConnection(client)).resolves.toBeUndefined();
expect(client.disconnect).toHaveBeenCalledTimes(1);
});
});
@@ -6,6 +6,7 @@ import { Permission, type AuthorEnrichmentStatusEvent } from '@bookorbit/types';
import { Server, Socket } from 'socket.io';
import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AuthService } from '../auth/auth.service';
import { AuthorEnrichmentRepository } from './author-enrichment.repository';
import { AuthorEnrichmentSessionService } from './author-enrichment-session.service';
@@ -55,7 +56,7 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
client.emit(AUTHOR_ENRICHMENT_STATUS_EVENT, { ...summary, paused, ...this.session.getSnapshot() });
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const message = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const message = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[${event}] [fail] socketId=${client.id} errorClass=${errorClass} error="${message}" - websocket rejected`);
client.disconnect();
}
@@ -338,4 +338,42 @@ describe('AuthorImageStorageService', () => {
await expect(service.getImageUrlIfExists(22)).resolves.toBe('/api/v1/authors/22/image');
await expect(service.getThumbnailUrlIfExists(22)).resolves.toBe('/api/v1/authors/22/thumbnail');
});
it('deleteAuthorDir removes the author directory when it exists', async () => {
const authorDir = join(booksPath, 'authors', '50');
await mkdir(authorDir, { recursive: true });
await writeFile(join(authorDir, 'photo.jpg'), Buffer.from('img'));
await expect(service.deleteAuthorDir(50)).resolves.toBeUndefined();
const { access } = await import('fs/promises');
await expect(access(authorDir)).rejects.toThrow();
});
it('deleteAuthorDir resolves without throwing when directory does not exist', async () => {
await expect(service.deleteAuthorDir(9999)).resolves.toBeUndefined();
});
it('saveFromUrl logs cleanup failure and continues when an old photo file cannot be deleted', async () => {
vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never);
const authorDir = join(booksPath, 'authors', '60');
await mkdir(authorDir, { recursive: true });
const existingPhoto = join(authorDir, 'photo.webp');
await writeFile(existingPhoto, Buffer.from('old'));
await rm(existingPhoto);
global.fetch = vi.fn().mockResolvedValue({
status: 200,
ok: true,
headers: { get: vi.fn().mockReturnValue('image/jpeg') },
body: (async function* () {
await Promise.resolve();
yield Buffer.from('new-image');
})(),
} as never);
await expect(service.saveFromUrl(60, 'https://cdn.example.com/image.jpg')).resolves.toBe(true);
});
});
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { lookup } from 'dns/promises';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { access, copyFile, mkdir, readdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { constants as fsConstants } from 'fs';
import { isIP } from 'net';
@@ -58,7 +59,7 @@ export class AuthorImageStorageService {
const errorClass = error instanceof Error ? error.name : 'Error';
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[author.image.cleanup] [fail] authorId=${authorId} file=${file} errorClass=${errorClass} error="${message.replace(/"/g, '\\"')}" - author image cleanup failed`,
`[author.image.cleanup] [fail] authorId=${authorId} file=${file} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - author image cleanup failed`,
);
});
}
@@ -117,7 +118,7 @@ export class AuthorImageStorageService {
await rm(dir, { recursive: true, force: true }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[author.image.delete_dir] [fail] authorId=${authorId} error="${message.replace(/"/g, '\\"')}" - author image directory cleanup failed`,
`[author.image.delete_dir] [fail] authorId=${authorId} error="${sanitizeLogValue(message)}" - author image directory cleanup failed`,
);
});
}
@@ -13,6 +13,7 @@ import type {
} from '@bookorbit/types';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { RequestUser } from '../../common/types/request-user';
import { books } from '../../db/schema';
import { BookReadService } from '../book/book-read.service';
@@ -151,7 +152,7 @@ export class AuthorsService {
return result;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] query=${JSON.stringify(dto.q)} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author metadata search failed`,
);
@@ -173,7 +174,7 @@ export class AuthorsService {
return result;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] provider=${dto.provider} providerId=${JSON.stringify(dto.id)} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author metadata lookup failed`,
);
@@ -236,7 +237,7 @@ export class AuthorsService {
return detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} authorId=${authorId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author update failed`,
);
@@ -278,7 +279,7 @@ export class AuthorsService {
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} targetAuthorId=${dto.targetAuthorId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author merge failed`,
);
@@ -310,7 +311,7 @@ export class AuthorsService {
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} count=${dto.authorIds.length} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author delete failed`,
);
@@ -333,7 +334,7 @@ export class AuthorsService {
return detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} authorId=${authorId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author enrichment refresh failed`,
);
@@ -396,7 +397,7 @@ export class AuthorsService {
failed += 1;
const itemErrorClass = error instanceof Error ? error.name : 'Error';
errorMessage = error instanceof Error ? error.message : 'Failed to refresh metadata';
const itemError = errorMessage.replace(/"/g, '\\"');
const itemError = sanitizeLogValue(errorMessage);
this.logger.warn(
`[${event}] [fail] userId=${user.id} authorId=${authorId} durationMs=${Date.now() - startedAt} errorClass=${itemErrorClass} error="${itemError}" - author metadata refresh item failed`,
);
@@ -422,7 +423,7 @@ export class AuthorsService {
return { processed, failed, updated };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} count=${authorIds.length} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - bulk author metadata refresh failed`,
);
@@ -5,6 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { appSettings, libraries } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -105,7 +106,7 @@ export class BookMetadataFetchConfigService {
return JSON.parse(value) as T;
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const message = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[book.metadata_fetch.config_parse] [fail] key=${key} errorClass=${errorClass} error="${message}" - failed to parse stored config`,
);
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional }
import type { BookMetadataFetchReason, MetadataField } from '@bookorbit/types';
import { MetadataProviderKey, NotificationType } from '@bookorbit/types';
import { NotificationService } from '../notification/notification.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { BookReadService } from '../book/book-read.service';
import * as schema from '../../db/schema';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
@@ -149,7 +150,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
}
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const message = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[book.metadata_fetch.poll] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${message}" - metadata fetch poll failed`,
);
@@ -218,7 +219,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
.calculateAndSave(bookId)
.catch((err: Error) =>
this.logger.warn(
`[book.metadata_fetch.score_recalc] [fail] bookId=${bookId} errorClass=${err.name} error="${err.message.replace(/"/g, '\\"')}" - metadata score recalculation failed`,
`[book.metadata_fetch.score_recalc] [fail] bookId=${bookId} errorClass=${err.name} error="${sanitizeLogValue(err.message)}" - metadata score recalculation failed`,
),
);
@@ -232,9 +233,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
const httpStatus = extractHttpStatus(error);
const errorClass = error instanceof Error ? error.name : 'Error';
this.logger.warn(
`[book.metadata_fetch] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} status=${httpStatus ?? 'none'} errorClass=${errorClass} error="${message
.slice(0, 200)
.replace(/"/g, '\\"')}" - metadata fetch failed`,
`[book.metadata_fetch] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} status=${httpStatus ?? 'none'} errorClass=${errorClass} error="${sanitizeLogValue(message.slice(0, 200))}" - metadata fetch failed`,
);
await this.queueRepo.markFailed(bookId, message, httpStatus);
this.session.setCurrentItemName(null);
+4 -3
View File
@@ -23,6 +23,7 @@ import { stat } from 'fs/promises';
import type { FastifyReply } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { Auditable } from '../../common/decorators/auditable.decorator';
import { ForbidPermission } from '../../common/decorators/forbid-permission.decorator';
import { imageContentTypeFromPath } from '../../common/image-content-type';
@@ -268,7 +269,7 @@ export class BookController {
);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} format=${dto.format} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - export failed`,
);
@@ -324,7 +325,7 @@ export class BookController {
}
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] userId=${user.id} count=${bookIds.length} scope=${scope} files=${plannedFiles} projectedBytes=${projectedBytes} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - export books failed`,
);
@@ -459,7 +460,7 @@ export class BookController {
);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] fileId=${fileId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - download file failed`,
);
+15 -14
View File
@@ -15,6 +15,7 @@ import { inArray, type SQL } from 'drizzle-orm';
import { bookCoverDirPath, bookThumbnailPath, findPreferredBookCoverFileName } from '../../common/book-cover-storage';
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
import { parseFb2File } from '../metadata/lib/fb2-parser';
@@ -836,7 +837,7 @@ export class BookService {
} catch (err) {
if (this.isMissingFilesystemEntry(err)) return null;
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} path="${dir}" errorClass=${errorClass} error="${errorMessage}" - get cover path failed`,
);
@@ -854,7 +855,7 @@ export class BookService {
} catch (err) {
if (this.isMissingFilesystemEntry(err)) return null;
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} path="${path}" errorClass=${errorClass} error="${errorMessage}" - get thumbnail path failed`,
);
@@ -958,7 +959,7 @@ export class BookService {
} catch (err) {
const event = 'book.resolve_download_filename';
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${file.bookId} errorClass=${errorClass} error="${errorMessage}" - download filename pattern resolution failed`,
);
@@ -1023,7 +1024,7 @@ export class BookService {
const target = deleteTargets[i]!;
const reason = result.reason;
const errorClass = reason instanceof Error ? reason.name : 'Error';
const errorMessage = (reason instanceof Error ? reason.message : String(reason)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(reason instanceof Error ? reason.message : String(reason));
this.logger.warn(
`[${event}] [fail] userId=${user.id} path="${target.path}" kind=${target.kind} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - delete books cleanup target failed`,
);
@@ -1033,7 +1034,7 @@ export class BookService {
);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - delete books failed`,
);
@@ -1132,7 +1133,7 @@ export class BookService {
return detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata update failed`,
);
@@ -1154,7 +1155,7 @@ export class BookService {
return detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata lock update failed`,
);
@@ -1200,7 +1201,7 @@ export class BookService {
return { queued: bookIds.length };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - embed all failed`);
throw err;
}
@@ -1232,7 +1233,7 @@ export class BookService {
failed += 1;
const reason = result.reason;
const errorClass = reason instanceof Error ? reason.name : 'Error';
const errorMessage = (reason instanceof Error ? reason.message : String(reason)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(reason instanceof Error ? reason.message : String(reason));
this.logger.warn(
`[${event}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - embedding run item failed`,
);
@@ -1608,7 +1609,7 @@ export class BookService {
return result;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} preview=${preview} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - refresh metadata failed`,
);
@@ -1651,7 +1652,7 @@ export class BookService {
success = true;
} catch (err) {
const itemErrorClass = err instanceof Error ? err.name : 'Error';
const itemError = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const itemError = sanitizeLogValue(err instanceof Error ? err.message : String(err));
errorMessage = err instanceof Error ? err.message : String(err);
this.logger.warn(
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${itemErrorClass} error="${itemError}" - bulk refresh metadata item failed`,
@@ -1671,7 +1672,7 @@ export class BookService {
return { processed, failed };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - bulk refresh metadata failed`,
);
@@ -1735,7 +1736,7 @@ export class BookService {
return { processed, updated };
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - bulk re-extract cover failed`,
);
@@ -1839,7 +1840,7 @@ export class BookService {
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} scope=${scope} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - get export files failed`,
);
@@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, ForbiddenException, Injectable,
import { and } from 'drizzle-orm';
import type { BookQuery, BooksPage } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { BookQueryBuilder } from '../book/book-query-builder.service';
@@ -76,7 +77,7 @@ export class CollectionService {
private buildErrorLogFields(error: unknown): { errorClass: string; errorMessage: string } {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
return { errorClass, errorMessage };
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { join } from 'path';
import { bookCoverDirPath, bookThumbnailPath, findExtractedBookCoverFileName } from '../../common/book-cover-storage';
import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { ensureSafeRemoteHost } from '../../common/utils/ssrf.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -424,7 +425,7 @@ function hasErrorCode(error: unknown, code: string): boolean {
function sanitizeErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.replace(/"/g, '\\"');
return sanitizeLogValue(message);
}
function errorClass(error: unknown): string {
@@ -6,6 +6,7 @@ import { join } from 'path';
import type { WriteResult } from '@bookorbit/types';
import { NotificationType } from '@bookorbit/types';
import { bookCoverDirPath, findPreferredBookCoverFileName } from '../../common/book-cover-storage';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { NotificationService } from '../notification/notification.service';
import { computeFileHash } from '../scanner/lib/hash';
import { FORMAT_CB7, FORMAT_CBZ, FORMAT_EPUB, FORMAT_PDF, createBookWriteFieldMask } from './file-write.constants';
@@ -354,5 +355,5 @@ function formatUserId(userId: number | undefined): string {
}
function sanitizeErrorMessage(message: string): string {
return message.replace(/"/g, '\\"');
return sanitizeLogValue(message);
}
@@ -105,4 +105,45 @@ describe('buildComicInfoXml', () => {
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:goodreadsId] 111');
expect(parsed.ComicInfo.Notes).not.toContain('[bookorbit:goodreadsId] 222');
});
describe('stripHtml (via description field)', () => {
function summaryOf(description: string): string {
const xml = buildComicInfoXml(null, { description }, new Set(['description']));
const parsed = parser.parse(xml) as { ComicInfo: Record<string, string> };
return parsed.ComicInfo.Summary ?? '';
}
it('strips plain HTML tags', () => {
expect(summaryOf('<p>Hello <b>world</b></p>')).toBe('Hello world');
});
it('decodes &amp; entity in plain text', () => {
expect(summaryOf('Rock &amp; Roll')).toBe('Rock & Roll');
});
it('decodes entity-encoded tags before stripping - prevents double-escaping bypass', () => {
// Old (buggy) order: strip tags first, then decode -> &lt;b&gt;text&lt;/b&gt; survives
// New (correct) order: decode first -> <b>text</b>, then strip -> text
expect(summaryOf('&lt;b&gt;bold text&lt;/b&gt;')).toBe('bold text');
});
it('removes entity-encoded script tags to prevent bypass', () => {
const result = summaryOf('&lt;script src="evil.js"&gt;alert(1)&lt;/script&gt; safe');
expect(result).not.toContain('<script');
expect(result).not.toContain('</script');
expect(result).toContain('safe');
});
it('handles mixed HTML and entities correctly', () => {
expect(summaryOf('<p>Epic &amp; vast</p>')).toBe('Epic & vast');
});
it('handles &quot; and &#39; entities', () => {
expect(summaryOf('She said &quot;hello&quot; and it&#39;s fine')).toBe(`She said "hello" and it's fine`);
});
it('collapses extra whitespace from removed tags', () => {
expect(summaryOf('<p> lots of space </p>')).toBe('lots of space');
});
});
});
@@ -81,13 +81,13 @@ export function buildComicInfoXml(existingXml: string | null, payload: BookWrite
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s{2,}/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/<[^>]*>/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
}
@@ -43,6 +43,7 @@ export class KoboProxyService {
body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
}
// codeql[js/request-forgery] - hostname validated by buildTargetUrl() to KOBO_API_HOSTNAME
const upstream = await fetch(targetUrl, {
method: req.method,
headers,
@@ -13,6 +13,7 @@ import { join } from 'path';
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types';
import type { AccessLevel, LibraryFileSyncProgressEvent, OrganizationMode, WriteResult } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { RequestUser } from '../../common/types/request-user';
import { FileWriteService } from '../file-write/file-write.service';
import { isPrimaryFormat } from '../scanner/lib/classify';
@@ -371,7 +372,7 @@ export class LibraryService {
} catch (err) {
failedCount++;
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = getErrorMessage(err).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(getErrorMessage(err));
this.logger.warn(
`[${event}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - cover directory cleanup failed`,
);
@@ -5,6 +5,7 @@ import { FieldPreferenceOverrides, LibraryMetadataPreferences, MetadataFetchPref
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { MetadataPreferenceResolver } from './metadata-preference-resolver';
type Db = NodePgDatabase<typeof schema>;
@@ -33,7 +34,7 @@ export class MetadataPreferencesService {
const durationMs = Date.now() - startedAt;
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const rawMessage = error instanceof Error ? error.message : 'unknown error';
const errorMessage = rawMessage.replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(rawMessage);
this.logger.warn(
`[metadata_preferences.global_parse] [fail] key=${GLOBAL_PREFERENCES_KEY} durationMs=${durationMs} errorClass=${errorClass} error="${errorMessage}" - failed to parse persisted global preferences`,
);
@@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { MetadataProviderKey, ProviderConfigurations, ProviderStatus } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -154,7 +155,7 @@ export class ProviderConfigService {
const durationMs = Date.now() - startedAt;
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const rawMessage = error instanceof Error ? error.message : 'unknown error';
const errorMessage = rawMessage.replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(rawMessage);
this.logger.warn(
`[metadata_provider_config.parse] [fail] key=${PROVIDER_CONFIG_KEY} source=${source} durationMs=${durationMs} errorClass=${errorClass} error="${errorMessage}" - failed to parse persisted provider config`,
);
@@ -167,6 +167,16 @@ describe('parseMobiBuffer', () => {
expect(parseMobiBuffer(buf).description).toBe('A great book.');
});
it('strips empty tags from description', () => {
const buf = buildMobiBuffer({ description: 'text<>more' });
expect(parseMobiBuffer(buf).description).toBe('textmore');
});
it('strips malformed unclosed tags from description', () => {
const buf = buildMobiBuffer({ description: 'before<br after="x">rest' });
expect(parseMobiBuffer(buf).description).toBe('beforerest');
});
it('parses isbn', () => {
const buf = buildMobiBuffer({ isbn: '9780441013593' });
expect(parseMobiBuffer(buf).isbn).toBe('9780441013593');
@@ -109,7 +109,7 @@ function exthUint32(records: ExthRecord[], type: number): number | null {
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, '').trim();
return html.replace(/<[^>]*>/g, '').trim();
}
export function parseMobiBuffer(buf: Buffer): MobiParsed {
@@ -2,6 +2,7 @@ import { readFile } from 'fs/promises';
import { PDFDocument } from 'pdf-lib';
import { BOOKORBIT_NS_PREFIX } from '../../../common/bookorbit-ns';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { extractPdfCover } from './pdf-cover';
import { extractXmpXml, parseXmp, type XmpParsed } from './pdf-xmp-reader';
@@ -62,7 +63,7 @@ function splitCommaList(value: string | null): string[] {
function createWarning(code: PdfParseWarning['code'], absolutePath: string, error: unknown): PdfParseWarning {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
return { code, absolutePath, errorClass, errorMessage };
}
@@ -14,6 +14,7 @@ import {
isCustomBookCoverFileName,
isExtractedBookCoverFileName,
} from '../../common/book-cover-storage';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { BookEmbedderService } from '../embedding/book-embedder.service';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
import { ComicMetadataRepository } from './comic-metadata.repository';
@@ -121,7 +122,7 @@ export class MetadataService {
this.scoreService.calculateAndSave(bookId).catch((error: Error) => {
this.logger.warn(
`[metadata.score_calculation] [fail] bookId=${bookId} errorClass=${error.name} error="${error.message.replace(/"/g, '\\"')}" - metadata score calculation failed`,
`[metadata.score_calculation] [fail] bookId=${bookId} errorClass=${error.name} error="${sanitizeLogValue(error.message)}" - metadata score calculation failed`,
);
});
@@ -130,7 +131,7 @@ export class MetadataService {
);
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[${event}] [fail] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata extraction failed`,
);
@@ -197,7 +198,7 @@ export class MetadataService {
return true;
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[${event}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - cover download failed`,
);
@@ -241,7 +242,7 @@ export class MetadataService {
return true;
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[${event}] [fail] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - cover refresh failed`,
);
@@ -476,7 +477,7 @@ export class MetadataService {
}
this.embedder?.embedBook(bookId).catch((error: Error) => {
this.logger.warn(
`[metadata.embedding] [fail] bookId=${bookId} errorClass=${error.name} error="${error.message.replace(/"/g, '\\"')}" - book embedding failed`,
`[metadata.embedding] [fail] bookId=${bookId} errorClass=${error.name} error="${sanitizeLogValue(error.message)}" - book embedding failed`,
);
});
}
@@ -517,9 +518,7 @@ export class MetadataService {
await this.narratorService.replaceForBook(bookId, filtered.audioMetadata.narrators);
}
this.logger.debug(
`[metadata.persist_audio] [end] bookId=${bookId} title="${(data.title ?? '').replace(/"/g, '\\"')}" - audio metadata persisted`,
);
this.logger.debug(`[metadata.persist_audio] [end] bookId=${bookId} title="${sanitizeLogValue(data.title ?? '')}" - audio metadata persisted`);
}
private async persistBookMetadata(bookId: number, data: ParsedBookData, format: string): Promise<void> {
@@ -587,7 +586,7 @@ export class MetadataService {
}
this.logger.debug(
`[metadata.persist_book] [end] bookId=${bookId} format=${format} title="${(data.title ?? '').replace(/"/g, '\\"')}" - book metadata persisted`,
`[metadata.persist_book] [end] bookId=${bookId} format=${format} title="${sanitizeLogValue(data.title ?? '')}" - book metadata persisted`,
);
}
@@ -5,6 +5,7 @@ import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessa
import { Server, Socket } from 'socket.io';
import { Permission, type MigrationProgressEvent, type MigrationRunState } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { RequestUser } from '../../common/types/request-user';
import { AuthService } from '../auth/auth.service';
import { MigrationRepository } from './migration.repository';
@@ -46,7 +47,7 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
this.logger.debug(`[migration.ws_connection] [start] userId=${user.id} socketId=${client.id} - websocket connected`);
} catch (err) {
this.logger.warn(
`[migration.ws_connection] [fail] socketId=${client.id} errorClass=${err instanceof Error ? err.name : 'Error'} error="${(err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"')}" - websocket rejected`,
`[migration.ws_connection] [fail] socketId=${client.id} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - websocket rejected`,
);
client.disconnect();
}
@@ -7,6 +7,7 @@ import type { NotificationItem } from '@bookorbit/types';
import { Server, Socket } from 'socket.io';
import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { UserService } from '../user/user.service';
import { NotificationRepository } from './notification.repository';
@@ -54,7 +55,7 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
client.emit('notification:unread-count', { count: unreadCount });
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const message = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const message = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[${event}] [fail] socketId=${client.id} errorClass=${errorClass} error="${message}" - websocket rejected`);
client.disconnect();
}
@@ -192,6 +192,13 @@ describe('NotificationService', () => {
await expect(service.notify(makePayload({ kind: 'all' }))).rejects.toThrow('DB connection failed');
});
it('catches and re-throws errors with special chars in message', async () => {
const error = new Error('DB "connection" lost\nnewline injected');
repo.findAllActiveUserIds.mockRejectedValue(error);
await expect(service.notify(makePayload({ kind: 'all' }))).rejects.toThrow('DB "connection" lost');
});
it('skips insert when all users filtered out by preferences', async () => {
repo.findUserSettings.mockResolvedValue(new Map<number, Record<string, unknown>>([[42, { notificationPreferences: { scanning: false } }]]));
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { NOTIFICATION_CATEGORIES, Permission } from '@bookorbit/types';
import type { NotificationCategory, NotificationItem, NotificationType, NotificationPreferences } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { NotificationGateway } from './notification.gateway';
import { NotificationRepository } from './notification.repository';
@@ -76,7 +77,7 @@ export class NotificationService {
);
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.error(
`[${event}] [fail] type=${payload.type} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - notification dispatch failed`,
);
+14 -14
View File
@@ -710,7 +710,7 @@ export class ScannerService implements OnApplicationBootstrap {
const { role, format } = classifyFile(filePath);
if (role !== 'content') {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} skippedNonContent=true scanScope=file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} skippedNonContent=true scanScope=file - targeted book scan completed`,
);
return;
}
@@ -718,7 +718,7 @@ export class ScannerService implements OnApplicationBootstrap {
const allowed = settings.allowedFormats.length > 0 ? new Set(settings.allowedFormats) : null;
if (allowed && format !== null && !allowed.has(format)) {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} skippedByAllowedFormats=true scanScope=file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} skippedByAllowedFormats=true scanScope=file - targeted book scan completed`,
);
return;
}
@@ -726,7 +726,7 @@ export class ScannerService implements OnApplicationBootstrap {
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat || !fileStat.isFile()) {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} candidateFound=false scanScope=file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} candidateFound=false scanScope=file - targeted book scan completed`,
);
return;
}
@@ -754,7 +754,7 @@ export class ScannerService implements OnApplicationBootstrap {
});
this.emitTargetedScanResult(libraryId, result);
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} added=${result.added} updated=${result.updated} scanScope=file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} added=${result.added} updated=${result.updated} scanScope=file - targeted book scan completed`,
);
}
@@ -769,7 +769,7 @@ export class ScannerService implements OnApplicationBootstrap {
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat || !fileStat.isFile()) {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} candidateFound=false scanScope=root_file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} candidateFound=false scanScope=root_file - targeted book scan completed`,
);
return;
}
@@ -777,7 +777,7 @@ export class ScannerService implements OnApplicationBootstrap {
const { role, format } = classifyFile(filePath);
if (role !== 'content') {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} skippedNonContent=true scanScope=root_file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} skippedNonContent=true scanScope=root_file - targeted book scan completed`,
);
return;
}
@@ -785,7 +785,7 @@ export class ScannerService implements OnApplicationBootstrap {
const allowed = settings.allowedFormats.length > 0 ? new Set(settings.allowedFormats) : null;
if (allowed && format !== null && !allowed.has(format)) {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} skippedByAllowedFormats=true scanScope=root_file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} skippedByAllowedFormats=true scanScope=root_file - targeted book scan completed`,
);
return;
}
@@ -813,7 +813,7 @@ export class ScannerService implements OnApplicationBootstrap {
});
this.emitTargetedScanResult(libraryId, result);
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} added=${result.added} updated=${result.updated} scanScope=root_file - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} added=${result.added} updated=${result.updated} scanScope=root_file - targeted book scan completed`,
);
}
@@ -851,19 +851,19 @@ export class ScannerService implements OnApplicationBootstrap {
try {
candidate = await buildSingleBookCandidate(resolvedBookFolder, libraryFolder.path, settings.excludePatterns, (msg) =>
this.logger.warn(
`[scanner.walk_candidates] [fail] libraryId=${libraryId} path="${resolvedBookFolder.replace(/"/g, '\\' + '"')}" error="${msg.replace(/"/g, '\\' + '"')}" - candidate walk warning`,
`[scanner.walk_candidates] [fail] libraryId=${libraryId} path="${sanitizeLogValue(resolvedBookFolder)}" error="${sanitizeLogValue(msg)}" - candidate walk warning`,
),
);
} catch (err) {
this.logger.warn(
`[${event}] [fail] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} scanScope=folder errorClass=${err instanceof Error ? err.name : 'Error'} error="${(err instanceof Error ? err.message : String(err)).replace(/"/g, '\\' + '"')}" - cannot walk target book folder`,
`[${event}] [fail] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} scanScope=folder errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - cannot walk target book folder`,
);
return;
}
if (!candidate) {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} candidateFound=false scanScope=folder - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} candidateFound=false scanScope=folder - targeted book scan completed`,
);
return;
}
@@ -877,7 +877,7 @@ export class ScannerService implements OnApplicationBootstrap {
});
if (!filtered.some((f) => (f.role ?? classifyFile(f.absolutePath).role) === 'content')) {
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} candidateFound=true skippedByAllowedFormats=true scanScope=folder - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} candidateFound=true skippedByAllowedFormats=true scanScope=folder - targeted book scan completed`,
);
return;
}
@@ -892,7 +892,7 @@ export class ScannerService implements OnApplicationBootstrap {
});
this.emitTargetedScanResult(libraryId, result);
this.logger.log(
`[${event}] [end] libraryId=${libraryId} path="${filePath.replace(/"/g, '\\' + '"')}" durationMs=${Date.now() - startedAt} scanScope=folder folder="${basename(resolvedBookFolder).replace(/"/g, '\\' + '"')}" added=${result.added} updated=${result.updated} - targeted book scan completed`,
`[${event}] [end] libraryId=${libraryId} path="${sanitizeLogValue(filePath)}" durationMs=${Date.now() - startedAt} scanScope=folder folder="${sanitizeLogValue(basename(resolvedBookFolder))}" added=${result.added} updated=${result.updated} - targeted book scan completed`,
);
}
@@ -1688,7 +1688,7 @@ export class ScannerService implements OnApplicationBootstrap {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'EACCES') {
this.logger.debug(
`[scanner.process_file] [end] bookId=${bookId} path="${fileStat.absolutePath.replace(/"/g, '\\' + '"')}" action=skip_inaccessible - file no longer accessible`,
`[scanner.process_file] [end] bookId=${bookId} path="${sanitizeLogValue(fileStat.absolutePath)}" action=skip_inaccessible - file no longer accessible`,
);
return { isNew: false, reassigned: false, fileId: null };
}