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:
10
.env.example
Normal file
10
.env.example
Normal 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
1
.gitignore
vendored
@ -6,6 +6,7 @@ dist
|
|||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
data/storage
|
data/storage
|
||||||
|
Books/
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.ideai/
|
.ideai/
|
||||||
|
|||||||
14
README.md
14
README.md
@ -94,12 +94,14 @@ La PWA fournit `manifest.webmanifest`, `sw.js`, une icône maskable SVG et `disp
|
|||||||
Volumes :
|
Volumes :
|
||||||
|
|
||||||
- `./data:/data` : base SQLite `/data/readabook.sqlite` et cache `/data/storage`.
|
- `./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 :
|
Variables principales :
|
||||||
|
|
||||||
- `JWT_SECRET` : secret JWT, à changer hors développement.
|
- `JWT_SECRET` : secret JWT, à changer hors développement.
|
||||||
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
|
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
|
||||||
|
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
|
||||||
|
- `READABOOK_LIBRARY_ALIAS_FROM` : chemin alternatif accepté par l’API et traduit vers `/library`.
|
||||||
- `DATABASE_PATH=/data/readabook.sqlite`
|
- `DATABASE_PATH=/data/readabook.sqlite`
|
||||||
- `STORAGE_DIR=/data/storage`
|
- `STORAGE_DIR=/data/storage`
|
||||||
|
|
||||||
@ -134,6 +136,16 @@ curl -b cookies.txt \
|
|||||||
http://localhost:3000/admin/libraries
|
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`;
|
||||||
|
l’API le traduit vers `/library`, puis le scan manuel teste l’extraction ISBN/métadonnées/jaquettes sur ce corpus.
|
||||||
|
|
||||||
## Lancer un scan
|
## Lancer un scan
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export type AppConfig = {
|
|||||||
cookieName: string;
|
cookieName: string;
|
||||||
cookieSecure: boolean;
|
cookieSecure: boolean;
|
||||||
openLibraryEnabled: boolean;
|
openLibraryEnabled: boolean;
|
||||||
|
libraryPathAliases: Array<{ from: string; to: string }>;
|
||||||
initialAdminEmail: string;
|
initialAdminEmail: string;
|
||||||
initialAdminPassword: string;
|
initialAdminPassword: string;
|
||||||
initialAdminPasswordIsDefault: boolean;
|
initialAdminPasswordIsDefault: boolean;
|
||||||
@ -36,8 +37,25 @@ export function loadConfig(): AppConfig {
|
|||||||
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
|
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
|
||||||
cookieSecure: process.env.COOKIE_SECURE === "true",
|
cookieSecure: process.env.COOKIE_SECURE === "true",
|
||||||
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false",
|
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false",
|
||||||
|
libraryPathAliases: parseLibraryPathAliases(process.env.LIBRARY_PATH_ALIASES),
|
||||||
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
|
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
|
||||||
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
|
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
|
||||||
initialAdminPasswordIsDefault: !process.env.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));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared";
|
import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared";
|
||||||
import { DatabaseService } from "../database/database.service.js";
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
import { libraries } from "../database/schema.js";
|
import { libraries } from "../database/schema.js";
|
||||||
|
import { LibraryPathValidationError, resolveLibraryPath } from "./library-path.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LibrariesService {
|
export class LibrariesService {
|
||||||
@ -24,6 +23,7 @@ export class LibrariesService {
|
|||||||
|
|
||||||
create(input: CreateLibraryDto) {
|
create(input: CreateLibraryDto) {
|
||||||
const path = this.validatePath(input.path);
|
const path = this.validatePath(input.path);
|
||||||
|
this.ensurePathUnused(path);
|
||||||
const now = this.database.now();
|
const now = this.database.now();
|
||||||
return this.database.db
|
return this.database.db
|
||||||
.insert(libraries)
|
.insert(libraries)
|
||||||
@ -35,7 +35,10 @@ export class LibrariesService {
|
|||||||
update(id: number, input: UpdateLibraryDto) {
|
update(id: number, input: UpdateLibraryDto) {
|
||||||
const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() };
|
const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() };
|
||||||
if (input.name) values.name = input.name;
|
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;
|
if (input.enabled !== undefined) values.enabled = input.enabled;
|
||||||
const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get();
|
const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get();
|
||||||
if (!library) {
|
if (!library) {
|
||||||
@ -49,17 +52,29 @@ export class LibrariesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validatePath(input: string): string {
|
private validatePath(input: string): string {
|
||||||
const resolved = resolve(input);
|
|
||||||
try {
|
try {
|
||||||
accessSync(resolved, constants.R_OK);
|
return resolveLibraryPath(input, this.database.config.libraryPathAliases);
|
||||||
const stats = statSync(resolved);
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
throw new BadRequestException("Library path must be a directory");
|
|
||||||
}
|
|
||||||
return realpathSync(resolved);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BadRequestException) throw error;
|
if (error instanceof LibraryPathValidationError) {
|
||||||
throw new BadRequestException("Library path is not readable");
|
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";
|
||||||
|
}
|
||||||
@ -11,11 +11,12 @@ services:
|
|||||||
STORAGE_DIR: /data/storage
|
STORAGE_DIR: /data/storage
|
||||||
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
|
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
|
||||||
OPEN_LIBRARY_ENABLED: ${OPEN_LIBRARY_ENABLED:-true}
|
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_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@readabook.local}
|
||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./data/library:/library:ro
|
- /home/anthony/Documents/Projects/ReadaBook/Books:/library:ro
|
||||||
healthcheck:
|
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))"]
|
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
|
interval: 10s
|
||||||
|
|||||||
Reference in New Issue
Block a user