mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(security): complete sanitizeLogValue sweep, DOMParser stripHtml, least-privilege CI permissions, pin Dockerfile image
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & entity in plain text', () => {
|
||||
expect(summaryOf('Rock & Roll')).toBe('Rock & Roll');
|
||||
});
|
||||
|
||||
it('decodes entity-encoded tags before stripping - prevents double-escaping bypass', () => {
|
||||
// Old (buggy) order: strip tags first, then decode -> <b>text</b> survives
|
||||
// New (correct) order: decode first -> <b>text</b>, then strip -> text
|
||||
expect(summaryOf('<b>bold text</b>')).toBe('bold text');
|
||||
});
|
||||
|
||||
it('removes entity-encoded script tags to prevent bypass', () => {
|
||||
const result = summaryOf('<script src="evil.js">alert(1)</script> 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 & vast</p>')).toBe('Epic & vast');
|
||||
});
|
||||
|
||||
it('handles " and ' entities', () => {
|
||||
expect(summaryOf('She said "hello" and it'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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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`,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user