fix(api): chemins de bibliothèques — validation dédiée, alias et unicité

Validation des chemins externalisée (library-path) avec codes d'erreur
explicites, support d'alias LIBRARY_PATH_ALIASES pour traduire un chemin
hôte vers le montage conteneur, rejet des doublons de chemin entre
bibliothèques, documentation README/.env.example et docker-compose
paramétrable via READABOOK_LIBRARY_HOST_PATH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 16:23:57 +02:00
parent 0fa2f99289
commit 7b72cc0d83
8 changed files with 194 additions and 16 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Local compose defaults. Copy to .env when you need machine-specific paths.
# Directory mounted read-only as /library in the API container.
READABOOK_LIBRARY_HOST_PATH=./data/library
# Optional path accepted by the API and translated to /library.
# For QA with the real local corpus, set for example:
# READABOOK_LIBRARY_HOST_PATH=./Books
# READABOOK_LIBRARY_ALIAS_FROM=/absolute/path/to/ReadaBook/Books
READABOOK_LIBRARY_ALIAS_FROM=/library

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ dist
*.sqlite-*
*.tsbuildinfo
data/storage
Books/
coverage
.pnpm-store
.ideai/

View File

@ -94,12 +94,14 @@ La PWA fournit `manifest.webmanifest`, `sw.js`, une icône maskable SVG et `disp
Volumes :
- `./data:/data` : base SQLite `/data/readabook.sqlite` et cache `/data/storage`.
- `./data/library:/library:ro` : bibliothèque locale scannée en lecture seule.
- `${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro` : bibliothèque locale scannée en lecture seule.
Variables principales :
- `JWT_SECRET` : secret JWT, à changer hors développement.
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive lenrichissement distant.
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
- `READABOOK_LIBRARY_ALIAS_FROM` : chemin alternatif accepté par lAPI et traduit vers `/library`.
- `DATABASE_PATH=/data/readabook.sqlite`
- `STORAGE_DIR=/data/storage`
@ -134,6 +136,16 @@ curl -b cookies.txt \
http://localhost:3000/admin/libraries
```
Pour tester le corpus réel local `Books/` sans le versionner, crée un `.env` local :
```bash
READABOOK_LIBRARY_HOST_PATH=./Books
READABOOK_LIBRARY_ALIAS_FROM=/chemin/absolu/vers/ReadaBook/Books
```
QA peut ensuite créer la bibliothèque avec le chemin absolu saisi dans `READABOOK_LIBRARY_ALIAS_FROM`;
lAPI le traduit vers `/library`, puis le scan manuel teste lextraction ISBN/métadonnées/jaquettes sur ce corpus.
## Lancer un scan
```bash

View File

@ -11,6 +11,7 @@ export type AppConfig = {
cookieName: string;
cookieSecure: boolean;
openLibraryEnabled: boolean;
libraryPathAliases: Array<{ from: string; to: string }>;
initialAdminEmail: string;
initialAdminPassword: string;
initialAdminPasswordIsDefault: boolean;
@ -36,8 +37,25 @@ export function loadConfig(): AppConfig {
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
cookieSecure: process.env.COOKIE_SECURE === "true",
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false",
libraryPathAliases: parseLibraryPathAliases(process.env.LIBRARY_PATH_ALIASES),
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
initialAdminPasswordIsDefault: !process.env.INITIAL_ADMIN_PASSWORD
};
}
function parseLibraryPathAliases(value: string | undefined): Array<{ from: string; to: string }> {
if (!value) return [];
return value
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const separator = entry.indexOf("=");
if (separator === -1) return null;
const from = entry.slice(0, separator).trim();
const to = entry.slice(separator + 1).trim();
return from && to ? { from, to } : null;
})
.filter((entry): entry is { from: string; to: string } => Boolean(entry));
}

View File

@ -1,10 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { accessSync, constants, realpathSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { and, eq, ne } from "drizzle-orm";
import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { libraries } from "../database/schema.js";
import { LibraryPathValidationError, resolveLibraryPath } from "./library-path.js";
@Injectable()
export class LibrariesService {
@ -24,6 +23,7 @@ export class LibrariesService {
create(input: CreateLibraryDto) {
const path = this.validatePath(input.path);
this.ensurePathUnused(path);
const now = this.database.now();
return this.database.db
.insert(libraries)
@ -35,7 +35,10 @@ export class LibrariesService {
update(id: number, input: UpdateLibraryDto) {
const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() };
if (input.name) values.name = input.name;
if (input.path) values.path = this.validatePath(input.path);
if (input.path) {
values.path = this.validatePath(input.path);
this.ensurePathUnused(values.path, id);
}
if (input.enabled !== undefined) values.enabled = input.enabled;
const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get();
if (!library) {
@ -49,17 +52,29 @@ export class LibrariesService {
}
private validatePath(input: string): string {
const resolved = resolve(input);
try {
accessSync(resolved, constants.R_OK);
const stats = statSync(resolved);
if (!stats.isDirectory()) {
throw new BadRequestException("Library path must be a directory");
}
return realpathSync(resolved);
return resolveLibraryPath(input, this.database.config.libraryPathAliases);
} catch (error) {
if (error instanceof BadRequestException) throw error;
throw new BadRequestException("Library path is not readable");
if (error instanceof LibraryPathValidationError) {
throw new BadRequestException({
code: error.code,
message: error.message,
path: error.path
});
}
throw error;
}
}
private ensurePathUnused(path: string, exceptId?: number): void {
const where = exceptId === undefined ? eq(libraries.path, path) : and(eq(libraries.path, path), ne(libraries.id, exceptId));
const existing = this.database.db.select({ id: libraries.id }).from(libraries).where(where).get();
if (existing) {
throw new ConflictException({
code: "LIBRARY_PATH_ALREADY_USED",
message: "Library path is already used",
path
});
}
}
}

View File

@ -0,0 +1,49 @@
import { closeSync, existsSync, mkdtempSync, openSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { LibraryPathValidationError, resolveLibraryPath } from "./library-path.js";
const tempDirs: string[] = [];
const realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books";
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("library path resolution", () => {
it("resolves a readable directory", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-library-"));
tempDirs.push(dir);
expect(resolveLibraryPath(dir)).toBe(resolve(dir));
});
it("maps a host path alias to the mounted container path", () => {
const hostRoot = "/host/project/Books";
const mountedRoot = mkdtempSync(join(tmpdir(), "readabook-mounted-books-"));
tempDirs.push(mountedRoot);
expect(resolveLibraryPath(hostRoot, [{ from: hostRoot, to: mountedRoot }])).toBe(resolve(mountedRoot));
});
it("rejects regular files with a stable code", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-library-"));
tempDirs.push(dir);
const file = join(dir, "book.epub");
closeSync(openSync(file, "w"));
expect(() => resolveLibraryPath(file)).toThrowError(LibraryPathValidationError);
try {
resolveLibraryPath(file);
} catch (error) {
expect(error).toMatchObject({ code: "LIBRARY_PATH_NOT_DIRECTORY" });
}
});
it.runIf(existsSync(realBooksPath))("accepts the real Books corpus path used by QA", () => {
expect(resolveLibraryPath(realBooksPath)).toBe(resolve(realBooksPath));
});
});

View File

@ -0,0 +1,72 @@
import { accessSync, constants, realpathSync, statSync } from "node:fs";
import { relative, resolve, sep } from "node:path";
export type LibraryPathAlias = {
from: string;
to: string;
};
export type LibraryPathErrorCode = "LIBRARY_PATH_NOT_FOUND" | "LIBRARY_PATH_NOT_DIRECTORY" | "LIBRARY_PATH_NOT_READABLE";
export class LibraryPathValidationError extends Error {
constructor(
public readonly code: LibraryPathErrorCode,
public readonly path: string
) {
super(messageForCode(code));
}
}
export function resolveLibraryPath(input: string, aliases: LibraryPathAlias[] = []): string {
const candidates = candidatePaths(input, aliases);
let firstError: LibraryPathValidationError | null = null;
for (const candidate of candidates) {
try {
const stats = statSync(candidate);
if (!stats.isDirectory()) {
throw new LibraryPathValidationError("LIBRARY_PATH_NOT_DIRECTORY", candidate);
}
accessSync(candidate, constants.R_OK | constants.X_OK);
return realpathSync(candidate);
} catch (error) {
firstError ??= normalizePathError(error, candidate);
}
}
throw firstError ?? new LibraryPathValidationError("LIBRARY_PATH_NOT_FOUND", resolve(input));
}
function candidatePaths(input: string, aliases: LibraryPathAlias[]): string[] {
const resolved = resolve(input);
const candidates = [resolved];
for (const alias of aliases) {
const from = resolve(alias.from);
const to = resolve(alias.to);
const remainder = relative(from, resolved);
if (remainder === "" || (!remainder.startsWith("..") && remainder !== ".." && !remainder.startsWith(`..${sep}`))) {
candidates.push(resolve(to, remainder));
}
}
return [...new Set(candidates)];
}
function normalizePathError(error: unknown, path: string): LibraryPathValidationError {
if (error instanceof LibraryPathValidationError) return error;
const code = typeof error === "object" && error && "code" in error ? String(error.code) : "";
if (code === "ENOENT" || code === "ENOTDIR") {
return new LibraryPathValidationError("LIBRARY_PATH_NOT_FOUND", path);
}
if (code === "EACCES" || code === "EPERM") {
return new LibraryPathValidationError("LIBRARY_PATH_NOT_READABLE", path);
}
return new LibraryPathValidationError("LIBRARY_PATH_NOT_READABLE", path);
}
function messageForCode(code: LibraryPathErrorCode): string {
if (code === "LIBRARY_PATH_NOT_FOUND") return "Library path does not exist";
if (code === "LIBRARY_PATH_NOT_DIRECTORY") return "Library path must be a directory";
return "Library path is not readable";
}

View File

@ -11,11 +11,12 @@ services:
STORAGE_DIR: /data/storage
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
OPEN_LIBRARY_ENABLED: ${OPEN_LIBRARY_ENABLED:-true}
LIBRARY_PATH_ALIASES: ${READABOOK_LIBRARY_ALIAS_FROM:-/library}=/library
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@readabook.local}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
volumes:
- ./data:/data
- ./data/library:/library:ro
- /home/anthony/Documents/Projects/ReadaBook/Books:/library:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s