import { FastifyInstance, FastifyReply } from 'fastify'; import { guess } from 'lib/mimes'; import { extname } from 'path'; import fastifyPlugin from 'fastify-plugin'; import { createBrotliCompress, createDeflate, createGzip } from 'zlib'; import pump from 'pump'; import { Transform } from 'stream'; import { parseRangeHeader } from 'lib/utils/range'; function rawFileDecorator(fastify: FastifyInstance, _, done) { fastify.decorateReply('rawFile', rawFile); done(); async function rawFile(this: FastifyReply, id: string) { const { download, compress = 'false' } = this.request.query as { download?: string; compress?: string }; const size = await this.server.datasource.size(id); if (size === null) return this.notFound(); const mimetype = await guess(extname(id).slice(1)); // eslint-disable-next-line prefer-const let [rangeStart, rangeEnd] = parseRangeHeader(this.request.headers.range); if (rangeStart >= rangeEnd) return this.code(416) .header('Content-Range', `bytes 0/${size - 1}`) .send(); if (rangeEnd === Infinity) rangeEnd = size - 1; const data = await this.server.datasource.get(id, rangeStart, rangeEnd + 1); // only send content-range if the client asked for it if (this.request.headers.range) { this.code(206); this.header('Content-Range', `bytes ${rangeStart}-${rangeEnd}/${size}`); } this.header('Content-Length', rangeEnd - rangeStart + 1); this.header('Content-Type', download ? 'application/octet-stream' : mimetype); this.header('Accept-Ranges', 'bytes'); if ( this.server.config.core.compression.enabled && compress?.match(/^true$/i) && !this.request.headers['X-Zipline-NoCompress'] && !!this.request.headers['accept-encoding'] ) if (size > this.server.config.core.compression.threshold && mimetype.match(/^(image|video|text)/)) return this.send(useCompress.call(this, data)); return this.send(data); } } function useCompress(this: FastifyReply, data: NodeJS.ReadableStream) { let compress: Transform; switch ((this.request.headers['accept-encoding'] as string).split(', ')[0]) { case 'gzip': case 'x-gzip': compress = createGzip(); this.header('Content-Encoding', 'gzip'); break; case 'deflate': compress = createDeflate(); this.header('Content-Encoding', 'deflate'); break; case 'br': compress = createBrotliCompress(); this.header('Content-Encoding', 'br'); break; default: this.server.logger .child('response') .error(`Unsupported encoding: ${this.request.headers['accept-encoding']}}`); break; } if (!compress) return data; setTimeout(() => compress.destroy(), 2000); return pump(data, compress, (err) => (err ? this.server.logger.error(err) : null)); } export default fastifyPlugin(rawFileDecorator, { name: 'rawFile', decorators: { fastify: ['datasource', 'logger'], }, }); declare module 'fastify' { interface FastifyReply { rawFile: (id: string) => Promise; } }