mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
1 parent
3bfacc5b1a
commit
856a80d111
4 files changed
+194
-8
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user