summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorおさむのひと <46447427+samunohito@users.noreply.github.com>2026-01-13 15:00:11 +0900
committerGitHub <noreply@github.com>2026-01-13 15:00:11 +0900
commit0b383efa5a8bb7f6f27545ea8f51f335d88e5b8d (patch)
tree454b056d12200b9d20baf6b373c4c95ac8cbbd62
parentfix(frontend/widget): AiscriptAppのスクリプト適用を手動に変更 (... (diff)
downloadmisskey-0b383efa5a8bb7f6f27545ea8f51f335d88e5b8d.tar.gz
misskey-0b383efa5a8bb7f6f27545ea8f51f335d88e5b8d.tar.bz2
misskey-0b383efa5a8bb7f6f27545ea8f51f335d88e5b8d.zip
Refactor FileServerService: Extract file handling logic into dedicated handlers (#17090)
- 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.
-rw-r--r--packages/backend/src/server/FileServerService.ts500
-rw-r--r--packages/backend/src/server/file/FileServerDriveHandler.ts116
-rw-r--r--packages/backend/src/server/file/FileServerFileResolver.ts126
-rw-r--r--packages/backend/src/server/file/FileServerProxyHandler.ts272
-rw-r--r--packages/backend/src/server/file/FileServerUtils.ts107
5 files changed, 650 insertions, 471 deletions
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<DownloadedFileResult> {
+ 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<FileResolveResult> {
+ // 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<ProxySource, { kind: 'not-found' | 'unavailable' }>;
+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<IImageStreamable> {
+ 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<ProxyQuery, 'emoji' | 'avatar' | 'static'>,
+ ): Promise<IImageStreamable> {
+ 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<IImageStreamable> {
+ 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<ProxySource> {
+ 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<T extends { kind?: string; cleanup?: () => void }>(file: T): file is T & { cleanup: () => void } {
+ return 'cleanup' in file && typeof file.cleanup === 'function';
+}