Souvenirs : envoi en flux et par plages (Range), pour les vidéos sur Safari (#162)

* 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
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-26 12:33:24 +01:00
1 parent 3bfacc5b1a
commit 856a80d111
4 files changed
+194 -8

No files matched your search

+49 -2
View File
@@ -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<number | null> {
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))
}
+5 -6
View File
@@ -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 ----
+109
View File
@@ -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<string, string> = {}) {
return app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
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)
})
})
+31
View File
@@ -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()
})
})