diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index e3e7c93..8f3206b 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -6,11 +6,12 @@ import { BooksModule } from "./books/books.module.js";
import { DatabaseModule } from "./database/database.module.js";
import { ProgressModule } from "./progress/progress.module.js";
import { ReaderModule } from "./reader/reader.module.js";
+import { ReadingModule } from "./reading/reading.module.js";
import { ScannerModule } from "./scanner/scanner.module.js";
import { HealthController } from "./health.controller.js";
@Module({
- imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
+ imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ReadingModule, ScannerModule, AutomationModule],
controllers: [HealthController]
})
export class AppModule {}
diff --git a/apps/api/src/reading/adapters/archive-reading-source.adapter.test.ts b/apps/api/src/reading/adapters/archive-reading-source.adapter.test.ts
new file mode 100644
index 0000000..7619978
--- /dev/null
+++ b/apps/api/src/reading/adapters/archive-reading-source.adapter.test.ts
@@ -0,0 +1,75 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import AdmZip from "adm-zip";
+import { afterEach, describe, expect, it } from "vitest";
+import { ArchiveReadingSourceAdapter } from "./archive-reading-source.adapter.js";
+
+const directories: string[] = [];
+
+afterEach(() => {
+ for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
+});
+
+describe("ArchiveReadingSourceAdapter", () => {
+ it("builds an ordered EPUB spine and serves only opaque resource ids", async () => {
+ const directory = mkdtempSync(join(tmpdir(), "readabook-reading-"));
+ directories.push(directory);
+ const filePath = join(directory, "book.epub");
+ const zip = new AdmZip();
+ zip.addFile("META-INF/container.xml", Buffer.from(`

First
')); + zip.addFile("OPS/chapter-2.xhtml", Buffer.from("Second
")); + zip.addFile("OPS/images/cover.jpg", Buffer.from([1, 2, 3])); + zip.addFile("OPS/styles/book.css", Buffer.from("body { color: black; }")); + zip.writeZip(filePath); + const stat = await import("node:fs").then(({ statSync }) => statSync(filePath)); + const source = new ArchiveReadingSourceAdapter(); + const book = { id: 7, format: "epub", filePath, fileSize: stat.size, fileMtime: stat.mtime.toISOString() }; + + const manifest = await source.manifest(book); + + expect(manifest.spine).toHaveLength(2); + expect(manifest).toMatchObject({ + containerPath: "META-INF/container.xml", + opfPath: "OPS/package.opf", + basePath: "OPS/", + revision: expect.any(String) + }); + expect(JSON.stringify(manifest)).not.toContain(filePath); + expect(JSON.stringify(manifest)).not.toContain(`/books/${book.id}/file`); + expect(manifest.resources).toHaveLength(6); + expect(manifest.resources.map((resource) => resource.resourceId)).not.toContain("OPS/chapter-1.xhtml"); + expect(manifest.resources).toEqual(expect.arrayContaining([ + expect.objectContaining({ href: "META-INF/container.xml", mediaType: "application/xml", size: expect.any(Number), url: expect.stringContaining("/reading-resources/") }), + expect.objectContaining({ href: "OPS/package.opf", mediaType: "application/oebps-package+xml" }), + expect.objectContaining({ href: "OPS/images/cover.jpg", mediaType: "image/jpeg", size: 3 }) + ])); + expect(manifest.spine[0]).toMatchObject({ index: 0, href: "OPS/chapter-1.xhtml", mediaType: "application/xhtml+xml", size: expect.any(Number) }); + const firstResource = manifest.resources.find((resource) => resource.href === "OPS/chapter-1.xhtml"); + const coverResource = manifest.resources.find((resource) => resource.href === "OPS/images/cover.jpg"); + expect(firstResource?.references).toContain(coverResource?.resourceId); + const chapter = await source.resource(book, manifest.spine[0].resourceId); + expect(chapter.contentType).toBe("application/xhtml+xml"); + expect(chapter.data?.toString()).toContain("First
"); + const container = manifest.resources.find((resource) => resource.href === manifest.containerPath); + expect((await source.resource(book, container!.resourceId)).data?.toString()).toContain("container"); + }); + + it("exposes a PDF as a range-capable original without leaking its path in the manifest", async () => { + const directory = mkdtempSync(join(tmpdir(), "readabook-reading-")); + directories.push(directory); + const filePath = join(directory, "secret-name.pdf"); + writeFileSync(filePath, "%PDF-test"); + const source = new ArchiveReadingSourceAdapter(); + const book = { id: 8, format: "pdf", filePath, fileSize: 9, fileMtime: "2026-01-01T00:00:00.000Z" }; + + const manifest = await source.manifest(book); + const resource = await source.resource(book, "original"); + + expect(JSON.stringify(manifest)).not.toContain(filePath); + expect(manifest).toMatchObject({ format: "pdf", spine: [expect.objectContaining({ resourceId: "original" })], capabilities: { byteRanges: true, lightweightPages: false } }); + expect(resource).toMatchObject({ filePath, contentType: "application/pdf", supportsRanges: true }); + }); +}); diff --git a/apps/api/src/reading/adapters/archive-reading-source.adapter.ts b/apps/api/src/reading/adapters/archive-reading-source.adapter.ts new file mode 100644 index 0000000..f041fd5 --- /dev/null +++ b/apps/api/src/reading/adapters/archive-reading-source.adapter.ts @@ -0,0 +1,220 @@ +import { createHash } from "node:crypto"; +import { existsSync, statSync } from "node:fs"; +import { extname, posix } from "node:path"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import AdmZip from "adm-zip"; +import { XMLParser } from "fast-xml-parser"; +import { lookup } from "mime-types"; +import { ReadingSourcePort } from "../reading.ports.js"; +import { ReadingBook, ReadingManifest, ReadingResource, ReadingResourcePayload, ReadingSpineItem } from "../reading.types.js"; + +const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" }); +const MAX_EPUB_ENTRIES = 20_000; +const MAX_RESOURCE_BYTES = 64 * 1024 * 1024; + +@Injectable() +export class ArchiveReadingSourceAdapter implements ReadingSourcePort { + private readonly epubManifests = new MapPage courante
+Page courante
+