From 7ff095c6f2b2bf2bec69e3f527d27810af850520 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:16:31 +0900 Subject: [PATCH] Refactor FileServerService: Extract file handling logic into dedicated handlers - Introduced FileServerDriveHandler to manage drive file requests. - Created FileServerFileResolver for resolving file access keys and downloading files. - Added FileServerProxyHandler to handle proxy requests and image processing. - Moved utility functions to FileServerUtils for better organization. - Removed redundant methods from FileServerService, improving readability and maintainability. --- .../backend/src/server/FileServerService.ts | 500 +----------------- .../src/server/file/FileServerDriveHandler.ts | 116 ++++ .../src/server/file/FileServerFileResolver.ts | 126 +++++ .../src/server/file/FileServerProxyHandler.ts | 272 ++++++++++ .../src/server/file/FileServerUtils.ts | 107 ++++ 5 files changed, 650 insertions(+), 471 deletions(-) create mode 100644 packages/backend/src/server/file/FileServerDriveHandler.ts create mode 100644 packages/backend/src/server/file/FileServerFileResolver.ts create mode 100644 packages/backend/src/server/file/FileServerProxyHandler.ts create mode 100644 packages/backend/src/server/file/FileServerUtils.ts diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 772c37094c..f5034d0733 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -7,27 +7,22 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; -import rename from 'rename'; -import sharp from 'sharp'; -import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import type { Config } from '@/config.js'; -import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { StatusError } from '@/misc/status-error.js'; import type Logger from '@/logger.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { InternalStorageService } from '@/core/InternalStorageService.js'; -import { contentDisposition } from '@/misc/content-disposition.js'; import { FileInfoService } from '@/core/FileInfoService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; -import { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; +import { FileServerDriveHandler } from './file/FileServerDriveHandler.js'; +import { FileServerFileResolver } from './file/FileServerFileResolver.js'; +import { FileServerProxyHandler } from './file/FileServerProxyHandler.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; const _filename = fileURLToPath(import.meta.url); @@ -38,6 +33,9 @@ const assets = `${_dirname}/../../server/file/assets/`; @Injectable() export class FileServerService { private logger: Logger; + private driveHandler: FileServerDriveHandler; + private proxyHandler: FileServerProxyHandler; + private fileResolver: FileServerFileResolver; constructor( @Inject(DI.config) @@ -54,6 +52,24 @@ export class FileServerService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray'); + this.fileResolver = new FileServerFileResolver( + this.driveFilesRepository, + this.fileInfoService, + this.downloadService, + this.internalStorageService, + ); + this.driveHandler = new FileServerDriveHandler( + this.config, + this.fileResolver, + assets, + this.videoProcessingService, + ); + this.proxyHandler = new FileServerProxyHandler( + this.config, + this.fileResolver, + assets, + this.imageProcessingService, + ); //this.createServer = this.createServer.bind(this); } @@ -78,7 +94,7 @@ export class FileServerService { }); fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => { - return await this.sendDriveFile(request, reply) + return await this.driveHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { @@ -91,7 +107,7 @@ export class FileServerService { Params: { url: string; }; Querystring: { url?: string; }; }>('/proxy/:url*', async (request, reply) => { - return await this.proxyHandler(request, reply) + return await this.proxyHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); @@ -116,462 +132,4 @@ export class FileServerService { reply.code(500); return; } - - @bindThis - private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) { - const key = request.params.key; - const file = await this.getFileFromKey(key).then(); - - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - if (file.state === 'remote') { - let image: IImageStreamable | null = null; - - if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/static.webp`); - url.searchParams.set('url', file.url); - url.searchParams.set('static', '1'); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } else if (file.mime.startsWith('video/')) { - const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); - if (externalThumbnail) { - file.cleanup(); - return await reply.redirect(externalThumbnail, 301); - } - - image = await this.videoProcessingService.generateVideoThumbnail(file.path); - } - } - - if (file.fileRole === 'webpublic') { - if (['image/svg+xml'].includes(file.mime)) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/svg.webp`); - url.searchParams.set('url', file.url); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } - } - - if (!image) { - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } - - if (file.fileRole !== 'original') { - const filename = rename(file.filename, { - suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : '.unknown', - }).toString(); - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } else { - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async proxyHandler(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; - } - - // アバタークロップなど、どうしてもオリジンである必要がある場合 - const mustOrigin = 'origin' in request.query; - - if (this.config.externalMediaProxyEnabled && !mustOrigin) { - // 外部のメディアプロキシが有効なら、そちらにリダイレクト - - reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - - const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); - - for (const [key, value] of Object.entries(request.query)) { - url.searchParams.append(key, value); - } - - return await reply.redirect( - url.toString(), - 301, - ); - } - - if (!request.headers['user-agent']) { - throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); - } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { - throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); - } - - // Create temp file - const file = await this.getStreamAndTypeFromUrl(url); - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); - - if ( - 'emoji' in request.query || - 'avatar' in request.query || - 'static' in request.query || - 'preview' in request.query || - 'badge' in request.query - ) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - } - - let image: IImageStreamable | null = null; - if ('emoji' in request.query || 'avatar' in request.query) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } else { - const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) - .resize({ - height: 'emoji' in request.query ? 128 : 320, - withoutEnlargement: true, - }) - .webp(webpDefault); - - image = { - data, - ext: 'webp', - type: 'image/webp', - }; - } - } else if ('static' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); - } else if ('preview' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); - } else if ('badge' in request.query) { - const mask = (await sharpBmp(file.path, file.mime)) - .resize(96, 96, { - fit: 'contain', - position: 'centre', - 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 (file.mime === 'image/svg+xml') { - image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); - } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { - throw new StatusError('Rejected type', 403, 'Rejected type'); - } - - if (!image) { - if (request.headers.range && file.file && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('cleanup' in file) { - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - } - - reply.header('Content-Type', image.type); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - if (url.startsWith(`${this.config.url}/files/`)) { - const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); - if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); - - return await this.getFileFromKey(key); - } - - return await this.downloadAndDetectTypeFromUrl(url); - } - - @bindThis - private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - > { - const [path, cleanup] = await createTemp(); - try { - const { filename } = await this.downloadService.downloadUrl(url, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - - return { - state: 'remote', - mime, ext, - path, cleanup, - filename, - }; - } catch (e) { - cleanup(); - throw e; - } - } - - @bindThis - private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - // Fetch drive file - const file = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.accessKey = :accessKey', { accessKey: key }) - .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) - .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) - .getOne(); - - if (file == null) return '404'; - - const isThumbnail = file.thumbnailAccessKey === key; - const isWebpublic = file.webpublicAccessKey === key; - - if (!file.storedInternal) { - if (!(file.isLink && file.uri)) return '204'; - const result = await this.downloadAndDetectTypeFromUrl(file.uri); - file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので - return { - ...result, - url: file.uri, - fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', - file, - filename: file.name, - }; - } - - const path = this.internalStorageService.resolvePath(key); - - if (isThumbnail || isWebpublic) { - const { mime, ext } = await this.fileInfoService.detectType(path); - return { - state: 'stored_internal', - fileRole: isThumbnail ? 'thumbnail' : 'webpublic', - file, - filename: file.name, - mime, ext, - path, - }; - } - - return { - state: 'stored_internal', - fileRole: 'original', - file, - filename: file.name, - // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる - mime: this.fileInfoService.fixMime(file.type), - ext: null, - path, - }; - } } diff --git a/packages/backend/src/server/file/FileServerDriveHandler.ts b/packages/backend/src/server/file/FileServerDriveHandler.ts new file mode 100644 index 0000000000..51b527b146 --- /dev/null +++ b/packages/backend/src/server/file/FileServerDriveHandler.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import rename from 'rename'; +import type { Config } from '@/config.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, getSafeContentType, needsCleanup } from './FileServerUtils.js'; +import type { FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +export class FileServerDriveHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private videoProcessingService: VideoProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) { + const key = request.params.key; + const file = await this.fileResolver.resolveFileByAccessKey(key); + + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + if (file.kind === 'remote') { + let image: IImageStreamable | null = null; + + if (file.fileRole === 'thumbnail') { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/static.webp`); + url.searchParams.set('url', file.url); + url.searchParams.set('static', '1'); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } else if (file.mime.startsWith('video/')) { + const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); + if (externalThumbnail) { + file.cleanup(); + return await reply.redirect(externalThumbnail, 301); + } + + image = await this.videoProcessingService.generateVideoThumbnail(file.path); + } + } + + if (file.fileRole === 'webpublic') { + if (['image/svg+xml'].includes(file.mime)) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/svg.webp`); + url.searchParams.set('url', file.url); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } + } + + image ??= { + data: handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path), + ext: file.ext, + type: file.mime, + }; + + attachStreamCleanup(image.data, file.cleanup); + + reply.header('Content-Type', getSafeContentType(image.type)); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } + + if (file.fileRole !== 'original') { + const filename = rename(file.filename, { + suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', + extname: file.ext ? `.${file.ext}` : '.unknown', + }).toString(); + + setFileResponseHeaders(reply, { mime: file.mime, filename }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } else { + setFileResponseHeaders(reply, { mime: file.file.type, filename: file.filename, size: file.file.size }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } + } catch (e) { + if (file.kind === 'remote') file.cleanup(); + throw e; + } + } +} diff --git a/packages/backend/src/server/file/FileServerFileResolver.ts b/packages/backend/src/server/file/FileServerFileResolver.ts new file mode 100644 index 0000000000..687d486efd --- /dev/null +++ b/packages/backend/src/server/file/FileServerFileResolver.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { DownloadService } from '@/core/DownloadService.js'; +import type { FileInfoService } from '@/core/FileInfoService.js'; +import type { InternalStorageService } from '@/core/InternalStorageService.js'; + +export type DownloadedFileResult = { + kind: 'downloaded'; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + filename: string; +}; + +export type FileResolveResult = + | { kind: 'not-found' } + | { kind: 'unavailable' } + | { + kind: 'stored'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + mime: string; + ext: string | null; + path: string; + } + | { + kind: 'remote'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + url: string; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + }; + +export class FileServerFileResolver { + constructor( + private driveFilesRepository: DriveFilesRepository, + private fileInfoService: FileInfoService, + private downloadService: DownloadService, + private internalStorageService: InternalStorageService, + ) {} + + public async downloadAndDetectTypeFromUrl(url: string): Promise { + const [path, cleanup] = await createTemp(); + try { + const { filename } = await this.downloadService.downloadUrl(url, path); + + const { mime, ext } = await this.fileInfoService.detectType(path); + + return { + kind: 'downloaded', + mime, ext, + path, cleanup, + filename, + }; + } catch (e) { + cleanup(); + throw e; + } + } + + public async resolveFileByAccessKey(key: string): Promise { + // Fetch drive file + const file = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey: key }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) + .getOne(); + + if (file == null) return { kind: 'not-found' }; + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + + if (!file.storedInternal) { + if (!(file.isLink && file.uri)) return { kind: 'unavailable' }; + const result = await this.downloadAndDetectTypeFromUrl(file.uri); + const { kind: _kind, ...downloaded } = result; + file.size = (await fs.promises.stat(downloaded.path)).size; // DB file.sizeは正確とは限らないので + return { + kind: 'remote', + ...downloaded, + url: file.uri, + fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', + file, + filename: file.name, + }; + } + + const path = this.internalStorageService.resolvePath(key); + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await this.fileInfoService.detectType(path); + return { + kind: 'stored', + fileRole: isThumbnail ? 'thumbnail' : 'webpublic', + file, + filename: file.name, + mime, ext, + path, + }; + } + + return { + kind: 'stored', + fileRole: 'original', + file, + filename: file.name, + // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる + mime: this.fileInfoService.fixMime(file.type), + ext: null, + path, + }; + } +} diff --git a/packages/backend/src/server/file/FileServerProxyHandler.ts b/packages/backend/src/server/file/FileServerProxyHandler.ts new file mode 100644 index 0000000000..41e8e47ba5 --- /dev/null +++ b/packages/backend/src/server/file/FileServerProxyHandler.ts @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import sharp from 'sharp'; +import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; +import type { Config } from '@/config.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { StatusError } from '@/misc/status-error.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; +import { createRangeStream, attachStreamCleanup, needsCleanup } from './FileServerUtils.js'; +import type { DownloadedFileResult, FileResolveResult, FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +type ProxySource = DownloadedFileResult | FileResolveResult; +type CleanupableFile = ProxySource & { cleanup: () => void }; +type AvailableFile = Exclude; +type ProxyQuery = { + emoji?: string; + avatar?: string; + static?: string; + preview?: string; + badge?: string; + origin?: string; + url?: string; +}; + +export class FileServerProxyHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private imageProcessingService: ImageProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, reply: FastifyReply) { + const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + + if (typeof url !== 'string') { + reply.code(400); + return; + } + + // アバタークロップなど、どうしてもオリジンである必要がある場合 + const mustOrigin = 'origin' in request.query; + + if (this.config.externalMediaProxyEnabled && !mustOrigin) { + return await this.redirectToExternalProxy(request, reply); + } + + this.validateUserAgent(request); + + // Create temp file + const file = await this.getStreamAndTypeFromUrl(url); + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + const image = await this.processImage(file, request, reply); + + if (needsCleanup(file)) { + attachStreamCleanup(image.data, file.cleanup); + } + + reply.header('Content-Type', image.type); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } catch (e) { + if (needsCleanup(file)) file.cleanup(); + throw e; + } + } + + /** + * 外部メディアプロキシにリダイレクトする + */ + private async redirectToExternalProxy( + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ) { + reply.header('Cache-Control', 'public, max-age=259200'); // 3 days + + const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); + + for (const [key, value] of Object.entries(request.query)) { + url.searchParams.append(key, value); + } + + return reply.redirect(url.toString(), 301); + } + + /** + * User-Agent を検証する + */ + private validateUserAgent(request: FastifyRequest): void { + if (!request.headers['user-agent']) { + throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); + } + if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { + throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); + } + } + + /** + * 画像を処理してストリーム可能な形式に変換する + */ + private async processImage( + file: AvailableFile, + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ): Promise { + const query = request.query; + + const requiresImageConversion = 'emoji' in query || 'avatar' in query || 'static' in query || 'preview' in query || 'badge' in query; + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + if (requiresImageConversion && !isConvertibleImage) { + throw new StatusError('Unexpected mime', 404); + } + + if ('emoji' in query || 'avatar' in query) { + return this.processEmojiOrAvatar(file, query); + } + + if ('static' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); + } + + if ('preview' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); + } + + if ('badge' in query) { + return this.processBadge(file); + } + + if (file.mime === 'image/svg+xml') { + return this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); + } + + if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } + + return this.createDefaultStream(file, request, reply); + } + + /** + * 絵文字またはアバター用の画像を処理する + */ + private async processEmojiOrAvatar( + file: AvailableFile, + query: Pick, + ): Promise { + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); + if (!isAnimationConvertibleImage && !('static' in query)) { + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in query) })) + .resize({ + height: 'emoji' in query ? 128 : 320, + withoutEnlargement: true, + }) + .webp(webpDefault); + + return { + data, + ext: 'webp', + type: 'image/webp', + }; + } + + /** + * バッジ用の画像を処理する + */ + private async processBadge(file: AvailableFile): Promise { + const mask = (await sharpBmp(file.path, file.mime)) + .resize(96, 96, { + fit: 'contain', + position: 'centre', + 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) { + 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'); + + return { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } + + /** + * デフォルトのストリームを作成する(Range リクエスト対応) + */ + private createDefaultStream( + file: AvailableFile, + request: FastifyRequest, + reply: FastifyReply, + ): IImageStreamable { + if (request.headers.range && 'file' in file && file.file.size > 0) { + const { stream, start, end, chunksize } = createRangeStream(request.headers.range as string, file.file.size, file.path); + + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + + return { + data: stream, + ext: file.ext, + type: file.mime, + }; + } + + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + private async getStreamAndTypeFromUrl(url: string): Promise { + if (url.startsWith(`${this.config.url}/files/`)) { + const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); + if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); + + return await this.fileResolver.resolveFileByAccessKey(key); + } + + return await this.fileResolver.downloadAndDetectTypeFromUrl(url); + } +} diff --git a/packages/backend/src/server/file/FileServerUtils.ts b/packages/backend/src/server/file/FileServerUtils.ts new file mode 100644 index 0000000000..c5995a2cca --- /dev/null +++ b/packages/backend/src/server/file/FileServerUtils.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import type { FastifyReply } from 'fastify'; + +export type RangeStream = { + stream: fs.ReadStream; + start: number; + end: number; + chunksize: number; +}; + +/** + * Range リクエストに対応したストリームを作成する + */ +export function createRangeStream(rangeHeader: string, size: number, path: string): RangeStream { + const parts = rangeHeader.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : size - 1; + if (end > size) { + end = size - 1; + } + const chunksize = end - start + 1; + + return { + stream: fs.createReadStream(path, { start, end }), + start, + end, + chunksize, + }; +} + +/** + * ストリームにcleanupハンドラを設定する + * ストリームでない場合は即座にcleanupを実行する + */ +export function attachStreamCleanup(data: IImageStreamable['data'], cleanup: () => void): void { + if ('pipe' in data && typeof data.pipe === 'function') { + data.on('end', cleanup); + data.on('close', cleanup); + } else { + cleanup(); + } +} + +/** + * MIME タイプがブラウザセーフかどうかに応じて Content-Type を返す + */ +export function getSafeContentType(mime: string): string { + return FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'; +} + +/** + * Range リクエストを処理してストリームを返す + * Range ヘッダーがない場合は通常のストリームを返す + */ +export function handleRangeRequest( + reply: FastifyReply, + rangeHeader: string | undefined, + size: number, + path: string, +): fs.ReadStream { + if (rangeHeader && size > 0) { + const { stream, start, end, chunksize } = createRangeStream(rangeHeader, size, path); + reply.header('Content-Range', `bytes ${start}-${end}/${size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return stream; + } + return fs.createReadStream(path); +} + +export type FileResponseOptions = { + mime: string; + filename: string; + size?: number; + cacheControl?: string; +}; + +/** + * ファイルレスポンス用の共通ヘッダーを設定する + */ +export function setFileResponseHeaders( + reply: FastifyReply, + options: FileResponseOptions, +): void { + reply.header('Content-Type', getSafeContentType(options.mime)); + reply.header('Cache-Control', options.cacheControl ?? 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', options.filename)); + if (options.size !== undefined) { + reply.header('Content-Length', options.size); + } +} + +/** + * cleanup が必要なファイルかどうかを判定する型ガード + */ +export function needsCleanup void }>(file: T): file is T & { cleanup: () => void } { + return 'cleanup' in file && typeof file.cleanup === 'function'; +}