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

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";
}