From c26169bcf2d031c1e20c11ed51822f45c30d2270 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Sun, 23 Aug 2026 16:23:57 +0200 Subject: [PATCH] feat(api): streaming des fichiers livres avec support Range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RĂ©ponses 206 partielles avec Content-Range/Accept-Ranges pour la lecture PDF, types MIME explicites par format, en-tĂȘte Content-Disposition UTF-8 et nosniff. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/books/books.controller.ts | 17 ++++++--- apps/api/src/books/books.service.ts | 50 +++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/api/src/books/books.controller.ts b/apps/api/src/books/books.controller.ts index fe2cbe9..e54b89a 100644 --- a/apps/api/src/books/books.controller.ts +++ b/apps/api/src/books/books.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common"; +import { Controller, Get, Headers, Param, Query, Res, UseGuards } from "@nestjs/common"; import { FastifyReply } from "fastify"; import { lookup } from "mime-types"; import { BookQueryDto, BookQuerySchema } from "@readabook/shared"; @@ -40,10 +40,17 @@ export class BooksController { } @Get(":id/file") - file(@Param("id") id: string, @Res() reply: FastifyReply) { - const { book, stream } = this.books.streamFile(Number(id)); - reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream"); - reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`); + file(@Param("id") id: string, @Headers("range") range: string | undefined, @Res() reply: FastifyReply) { + const { book, stream, contentLength, contentType, end, partial, size, start } = this.books.streamFile(Number(id), range); + if (partial) { + reply.code(206); + reply.header("Content-Range", `bytes ${start}-${end}/${size}`); + } + reply.header("Accept-Ranges", "bytes"); + reply.header("Content-Length", String(contentLength)); + reply.header("Content-Type", contentType || lookup(book.filePath) || "application/octet-stream"); + reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(`${book.title}.${book.format}`)}`); + reply.header("X-Content-Type-Options", "nosniff"); return reply.send(stream); } diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts index 0876df1..43fd7da 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -1,5 +1,5 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { createReadStream, existsSync } from "node:fs"; +import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common"; +import { createReadStream, existsSync, statSync } from "node:fs"; import { extname } from "node:path"; import { and, eq, sql } from "drizzle-orm"; import { BookQueryDto } from "@readabook/shared"; @@ -53,12 +53,22 @@ export class BooksService { return book; } - streamFile(id: number) { + streamFile(id: number, range?: string) { const book = this.get(id); if (!existsSync(book.filePath)) { throw new NotFoundException("Book file not found on disk"); } - return { book, stream: createReadStream(book.filePath) }; + const size = statSync(book.filePath).size; + const byteRange = parseByteRange(range, size); + const stream = createReadStream(book.filePath, { start: byteRange.start, end: byteRange.end }); + return { + book, + stream, + contentType: bookContentType(book.format), + contentLength: byteRange.end - byteRange.start + 1, + size, + ...byteRange + }; } streamCover(id: number) { @@ -108,6 +118,38 @@ export class BooksService { } } +function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } { + if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false }; + const match = range.match(/^bytes=(\d*)-(\d*)$/); + if (!match || size <= 0) { + throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + } + + const [, rawStart, rawEnd] = match; + let start: number; + let end: number; + if (!rawStart && rawEnd) { + const suffixLength = Number(rawEnd); + start = Math.max(size - suffixLength, 0); + end = size - 1; + } else { + start = Number(rawStart); + end = rawEnd ? Number(rawEnd) : size - 1; + } + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) { + throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + } + return { start, end: Math.min(end, size - 1), partial: true }; +} + +function bookContentType(format: string): string { + if (format === "epub") return "application/epub+zip"; + if (format === "pdf") return "application/pdf"; + if (format === "cbz") return "application/vnd.comicbook+zip"; + if (format === "cbr") return "application/vnd.comicbook-rar"; + return "application/octet-stream"; +} + function lookupMime(entryName: string): string { const extension = extname(entryName).toLowerCase(); if (extension === ".png") return "image/png";