feat(api): streaming des fichiers livres avec support Range
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 <noreply@anthropic.com>
This commit is contained in:
@ -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 { FastifyReply } from "fastify";
|
||||||
import { lookup } from "mime-types";
|
import { lookup } from "mime-types";
|
||||||
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
|
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
|
||||||
@ -40,10 +40,17 @@ export class BooksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/file")
|
@Get(":id/file")
|
||||||
file(@Param("id") id: string, @Res() reply: FastifyReply) {
|
file(@Param("id") id: string, @Headers("range") range: string | undefined, @Res() reply: FastifyReply) {
|
||||||
const { book, stream } = this.books.streamFile(Number(id));
|
const { book, stream, contentLength, contentType, end, partial, size, start } = this.books.streamFile(Number(id), range);
|
||||||
reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream");
|
if (partial) {
|
||||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`);
|
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);
|
return reply.send(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { createReadStream, existsSync } from "node:fs";
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { BookQueryDto } from "@readabook/shared";
|
import { BookQueryDto } from "@readabook/shared";
|
||||||
@ -53,12 +53,22 @@ export class BooksService {
|
|||||||
return book;
|
return book;
|
||||||
}
|
}
|
||||||
|
|
||||||
streamFile(id: number) {
|
streamFile(id: number, range?: string) {
|
||||||
const book = this.get(id);
|
const book = this.get(id);
|
||||||
if (!existsSync(book.filePath)) {
|
if (!existsSync(book.filePath)) {
|
||||||
throw new NotFoundException("Book file not found on disk");
|
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) {
|
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 {
|
function lookupMime(entryName: string): string {
|
||||||
const extension = extname(entryName).toLowerCase();
|
const extension = extname(entryName).toLowerCase();
|
||||||
if (extension === ".png") return "image/png";
|
if (extension === ".png") return "image/png";
|
||||||
|
|||||||
Reference in New Issue
Block a user