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:
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
apps/api/src/libraries/library-path.test.ts
Normal file
49
apps/api/src/libraries/library-path.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
72
apps/api/src/libraries/library-path.ts
Normal file
72
apps/api/src/libraries/library-path.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user