import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; import sharp from 'sharp'; import fastifyStatic from '@fastify/static'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { createTemp } from '@/misc/create-temp.js'; import { DownloadService, NonNullBodyResponse } from '@/core/DownloadService.js'; import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; import type { IImage } from '@/core/ImageProcessingService.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { StatusError } from '@/misc/status-error.js'; import type Logger from '@/logger.js'; import { FileInfoService, TYPE_SVG } from '@/core/FileInfoService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify'; import { PassThrough, Readable, pipeline } from 'node:stream'; import { Request } from 'got'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); const assets = `${_dirname}/../../src/server/assets/`; @Injectable() export class MediaProxyServerService { private logger: Logger; constructor( @Inject(DI.config) private config: Config, private fileInfoService: FileInfoService, private downloadService: DownloadService, private imageProcessingService: ImageProcessingService, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray', false); //this.createServer = this.createServer.bind(this); } @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.addHook('onRequest', (request, reply, done) => { reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); done(); }); fastify.register(fastifyStatic, { root: _dirname, serve: false, }); fastify.get<{ Params: { url: string; }; Querystring: { url?: string; }; }>('/:url*', async (request, reply) => await this.handler(request, reply)); done(); } @bindThis private async handler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; if (typeof url !== 'string') { reply.code(400); return; } // Create temp file const [path, cleanup] = await createTemp(); try { const _response = await this.downloadService.fetchUrl(url); const response = _response.clone() as NonNullBodyResponse; const fileSaving = this.downloadService.pipeRequestToFile(response, path); let { mime, ext } = await this.fileInfoService.detectRequestType(response); if (mime === 'application/octet-stream' || mime === 'application/xml') { await fileSaving; if (await this.fileInfoService.checkSvg(path)) { mime = TYPE_SVG.mime; ext = TYPE_SVG.ext; } } const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image'); let image: IImageStreamable | null = null; if ('emoji' in request.query && isConvertibleImage) { const data = pipeline( Readable.fromWeb(response.body), sharp({ animated: !('static' in request.query) }) .resize({ height: 128, withoutEnlargement: true, }) .webp(webpDefault), ); image = { data, ext: 'webp', type: 'image/webp', }; } else if ('static' in request.query && isConvertibleImage) { image = this.imageProcessingService.convertSharpToWebpStreamObj(Readable.fromWeb(response.body).pipe(sharp()), 498, 280); } else if ('preview' in request.query && isConvertibleImage) { image = this.imageProcessingService.convertSharpToWebpStreamObj(Readable.fromWeb(response.body).pipe(sharp()), 200, 200); } else if ('badge' in request.query) { if (!isConvertibleImage) { // 画像でないなら404でお茶を濁す throw new StatusError('Unexpected mime', 404); } await fileSaving; const mask = sharp(path) .resize(96, 96, { fit: 'inside', withoutEnlargement: false, }) .greyscale() .normalise() .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast .flatten({ background: '#000' }) .toColorspace('b-w'); const stats = await mask.clone().stats(); if (stats.entropy < 0.1) { // エントロピーがあまりない場合は404にする throw new StatusError('Skip to provide badge', 404); } const data = sharp({ create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, }) .pipelineColorspace('b-w') .boolean(await mask.png().toBuffer(), 'eor'); image = { data: await data.png().toBuffer(), ext: 'png', type: 'image/png', }; } else if (mime === 'image/svg+xml') { image = this.imageProcessingService.convertSharpToWebpStreamObj(Readable.fromWeb(response.body).pipe(sharp()), 2048, 2048); } else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { throw new StatusError('Rejected type', 403, 'Rejected type'); } if (!image) { image = { data: Readable.fromWeb(response.body), ext, type: mime, }; } reply.header('Content-Type', image.type); reply.header('Cache-Control', 'max-age=31536000, immutable'); return image.data; } catch (err) { this.logger.error(`${err}`); if ('fallback' in request.query) { return reply.sendFile('/dummy.png', assets); } if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { reply.code(err.statusCode); } else { reply.code(500); } } finally { cleanup(); } return; // Not all code paths return a value. 対策 } }