From 856a80d1116bc7bacd4954f36c7a9a5bcb61d360 Mon Sep 17 00:00:00 2001 From: IliesAllali <97601122+IliesAllali@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:33:24 +0100 Subject: [PATCH] =?UTF-8?q?Souvenirs=20:=20envoi=20en=20flux=20et=20par=20?= =?UTF-8?q?plages=20(Range),=20pour=20les=20vid=C3=A9os=20sur=20Safari=20(?= =?UTF-8?q?#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Souvenirs : envoi en flux et par plages (Range, 206), pour Safari ; le fichier ne passe plus en entier en mémoire * Range : fichier vide refusé en 416 ; test d'un souvenir en attente demandé par morceaux --- backend/src/lib/media-files.ts | 51 ++++++++++++- backend/src/routes/media.ts | 11 ++- backend/test/media-range.db.test.ts | 109 ++++++++++++++++++++++++++++ backend/test/media-range.test.ts | 31 ++++++++ 4 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 backend/test/media-range.db.test.ts create mode 100644 backend/test/media-range.test.ts diff --git a/backend/src/lib/media-files.ts b/backend/src/lib/media-files.ts index c0900b4..7fb8ad3 100644 --- a/backend/src/lib/media-files.ts +++ b/backend/src/lib/media-files.ts @@ -1,7 +1,8 @@ -import { unlink } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { stat, unlink } from 'node:fs/promises' import path from 'node:path' -import type { FastifyReply } from 'fastify' +import type { FastifyReply, FastifyRequest } from 'fastify' import sharp from 'sharp' // Fichiers envoyés : le type vient des premiers octets, jamais de ce que déclare le navigateur. @@ -235,3 +236,49 @@ export async function removeStoredFile(storageRoot: string, relativePath: string // déjà absent } } + +// Plage demandée par « Range: bytes=début-fin » (une seule plage), bornée à la taille du fichier. null : pas de +// plage lisible, on envoie tout ; 'unsatisfiable' : la plage tombe hors du fichier. +export function parseByteRange(header: string | undefined, size: number): { start: number; end: number } | 'unsatisfiable' | null { + const match = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim()) + if (!match || (match[1] === '' && match[2] === '')) return null + if (match[1] === '') { + // Les n derniers octets + const suffix = Number(match[2]) + if (suffix === 0 || size === 0) return 'unsatisfiable' + return { start: Math.max(0, size - suffix), end: size - 1 } + } + const start = Number(match[1]) + const end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1) + if (start >= size || end < start) return 'unsatisfiable' + return { start, end } +} + +// Taille d'un fichier stocké, null s'il manque +export async function storedFileSize(absolutePath: string): Promise { + try { + const info = await stat(absolutePath) + return info.isFile() ? info.size : null + } catch { + return null + } +} + +// Envoie un fichier en flux, par morceaux si le navigateur le demande. Safari ne lit une vidéo que si le serveur +// répond aux plages (206) ; avant, le fichier entier passait en mémoire puis partait d'un bloc. À appeler après +// applyMediaResponseHeaders. +export function sendFileWithRanges(request: FastifyRequest, reply: FastifyReply, absolutePath: string, size: number): FastifyReply { + reply.header('Accept-Ranges', 'bytes') + const range = parseByteRange(request.headers.range, size) + if (range === 'unsatisfiable') { + return reply.code(416).header('Content-Range', `bytes */${size}`).send() + } + if (range) { + reply.code(206) + reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`) + reply.header('Content-Length', String(range.end - range.start + 1)) + return reply.send(createReadStream(absolutePath, { start: range.start, end: range.end })) + } + reply.header('Content-Length', String(size)) + return reply.send(createReadStream(absolutePath)) +} diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index e611edd..cb244ee 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -16,7 +16,9 @@ import { removeStoredFile, sanitizeImage, scrubMp4Location, + sendFileWithRanges, sniffMimeType, + storedFileSize, type BinaryMediaType, } from '../lib/media-files.js' import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js' @@ -1121,16 +1123,13 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_media_path' }) } - let fileBuffer: Buffer - try { - fileBuffer = await readFile(absolutePath) - } catch { + const size = await storedFileSize(absolutePath) + if (size === null) { return reply.code(404).send({ error: 'media_file_missing' }) } applyMediaResponseHeaders(reply, media.mimeType, path.basename(media.filePath)) - - return reply.send(fileBuffer) + return sendFileWithRanges(request, reply, absolutePath, size) }) // ---- Annotation Photos ---- diff --git a/backend/test/media-range.db.test.ts b/backend/test/media-range.db.test.ts new file mode 100644 index 0000000..3bddad3 --- /dev/null +++ b/backend/test/media-range.db.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +// Un souvenir se lit par morceaux (Range), sur une vraie base. Lancé seulement avec TEST_DATABASE_URL : +// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/media-range.db.test.ts +const TEST_DB = process.env.TEST_DATABASE_URL + +let app: FastifyInstance +let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string } +let ip = 0 +const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}` +const run = Date.now().toString(36) +// PNG d'un pixel +const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' +const storage = mkdtempSync(path.join(tmpdir(), 'pth-media-range-')) + +async function call(method: string, url: string, token?: string, payload?: unknown, headers: Record = {}) { + return app.inject({ + method: method as 'GET', + url, + payload: payload as Record, + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers }, + remoteAddress: nextIp(), + }) +} + +describe.skipIf(!TEST_DB)('souvenir lu par morceaux, vraie base', () => { + let token: string + let url: string + let treeId: string + let personId: string + let whole: Buffer + + beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.DATABASE_URL = TEST_DB + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.CORS_ORIGIN = 'http://localhost:5173' + process.env.MEDIA_STORAGE_PATH = storage + ;({ env } = await import('../src/config/env.js')) + const { createApp } = await import('../src/app.js') + app = createApp() + env.ALLOW_REGISTRATION = true + env.MEDIA_STORAGE_PATH = storage + const owner = await call('POST', '/auth/register', undefined, { email: `range-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Hélène' }) + token = owner.json().token + const tree = await call('POST', '/trees', token, { name: 'Arbre de démo', slug: `range-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' }) + treeId = tree.json().tree?.id ?? tree.json().id + const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' }) + personId = person.json().person?.id ?? person.json().id + const sent = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, { + type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL, + }) + expect(sent.statusCode).toBe(201) + const media = sent.json().media ?? sent.json() + url = `/trees/${treeId}/media/${media.id}` + const full = await call('GET', url, token) + expect(full.statusCode).toBe(200) + whole = full.rawPayload + }) + + afterAll(async () => { + await app?.close() + }) + + it('sans plage : tout le fichier, et l annonce des plages', async () => { + const res = await call('GET', url, token) + expect(res.statusCode).toBe(200) + expect(res.headers['accept-ranges']).toBe('bytes') + expect(Number(res.headers['content-length'])).toBe(whole.length) + expect(res.headers['content-type']).toBe('image/png') + }) + + it('les deux premiers octets, comme Safari avant une vidéo', async () => { + const res = await call('GET', url, token, undefined, { range: 'bytes=0-1' }) + expect(res.statusCode).toBe(206) + expect(res.headers['content-range']).toBe(`bytes 0-1/${whole.length}`) + expect(res.rawPayload.equals(whole.subarray(0, 2))).toBe(true) + }) + + it('la fin du fichier, et une plage hors du fichier', async () => { + const tail = await call('GET', url, token, undefined, { range: 'bytes=-10' }) + expect(tail.statusCode).toBe(206) + expect(tail.rawPayload.equals(whole.subarray(whole.length - 10))).toBe(true) + const out = await call('GET', url, token, undefined, { range: `bytes=${whole.length}-` }) + expect(out.statusCode).toBe(416) + expect(out.headers['content-range']).toBe(`bytes */${whole.length}`) + }) + + it('les droits passent avant la plage, souvenir en attente compris', async () => { + expect((await call('GET', url, undefined, undefined, { range: 'bytes=0-1' })).statusCode).toBe(401) + // Un membre envoie un souvenir, qui attend la relecture ; un autre membre le demande par morceaux + const register = async (name: string) => (await call('POST', '/auth/register', undefined, { email: `${name}-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: name })).json().token as string + const author = await register('auteur') + const reader = await register('lecteur') + for (const t of [author, reader]) expect((await call('POST', `/trees/${treeId}/access/unlock`, t, { password: 'partage-demo-1' })).statusCode).toBe(200) + const sent = await call('POST', `/trees/${treeId}/persons/${personId}/media`, author, { + type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL, + }) + const pending = sent.json().media ?? sent.json() + expect(pending.status).toBe('pending') + const pendingUrl = `/trees/${treeId}/media/${pending.id}` + expect((await call('GET', pendingUrl, reader, undefined, { range: 'bytes=0-1' })).statusCode).toBe(404) + expect((await call('GET', pendingUrl, author, undefined, { range: 'bytes=0-1' })).statusCode).toBe(206) + }) +}) diff --git a/backend/test/media-range.test.ts b/backend/test/media-range.test.ts new file mode 100644 index 0000000..158381b --- /dev/null +++ b/backend/test/media-range.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { parseByteRange } from '../src/lib/media-files.js' + +// Plages demandées par les navigateurs pour lire une vidéo (Safari ne lit rien sans elles) +describe('parseByteRange', () => { + it('lit les plages que demandent Safari et Chrome', () => { + expect(parseByteRange('bytes=0-1', 1000)).toEqual({ start: 0, end: 1 }) + expect(parseByteRange('bytes=0-', 1000)).toEqual({ start: 0, end: 999 }) + expect(parseByteRange('bytes=500-', 1000)).toEqual({ start: 500, end: 999 }) + expect(parseByteRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 }) + }) + + it('borne la fin à la taille du fichier', () => { + expect(parseByteRange('bytes=900-5000', 1000)).toEqual({ start: 900, end: 999 }) + expect(parseByteRange('bytes=-5000', 1000)).toEqual({ start: 0, end: 999 }) + }) + + it('refuse une plage hors du fichier', () => { + expect(parseByteRange('bytes=1000-', 1000)).toBe('unsatisfiable') + expect(parseByteRange('bytes=5-2', 1000)).toBe('unsatisfiable') + expect(parseByteRange('bytes=-0', 1000)).toBe('unsatisfiable') + expect(parseByteRange('bytes=-5', 0)).toBe('unsatisfiable') + }) + + it('envoie tout sans plage lisible', () => { + expect(parseByteRange(undefined, 1000)).toBeNull() + expect(parseByteRange('bytes=-', 1000)).toBeNull() + expect(parseByteRange('bytes=0-1,5-9', 1000)).toBeNull() + expect(parseByteRange('items=0-1', 1000)).toBeNull() + }) +})