summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/file
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2026-03-05 10:56:50 +0000
committerGitHub <noreply@github.com>2026-03-05 10:56:50 +0000
commitfe3dd8edb5f30104cd0a7ed755eb254feda2922d (patch)
treeaf6cf5fa4ca75302ac2de5db742cead00bc13d21 /packages/backend/src/server/file
parentMerge pull request #16998 from misskey-dev/develop (diff)
parentRelease: 2026.3.0 (diff)
downloadmisskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.gz
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.bz2
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.zip
Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
Diffstat (limited to 'packages/backend/src/server/file')
-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
4 files changed, 621 insertions, 0 deletions
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';
+}