diff --git a/packages/backend/test/resources/dummy-for-file-server-service.png b/packages/backend/test/resources/dummy-for-file-server-service.png
new file mode 100644
index 0000000000..39332b0c1b
Binary files /dev/null and b/packages/backend/test/resources/dummy-for-file-server-service.png differ
diff --git a/packages/backend/test/unit/server/FileServerService.ts b/packages/backend/test/unit/server/FileServerService.ts
new file mode 100644
index 0000000000..c88175c5c7
--- /dev/null
+++ b/packages/backend/test/unit/server/FileServerService.ts
@@ -0,0 +1,770 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import fastifyStatic from '@fastify/static';
+import Fastify, { type FastifyInstance } from 'fastify';
+import { describe, expect, test } from '@jest/globals';
+import sharp from 'sharp';
+import { DataSource, type Repository } from 'typeorm';
+import { initTestDb, randomString } from '../../utils.js';
+import type { AiService } from '@/core/AiService.js';
+import { DownloadService } from '@/core/DownloadService.js';
+import { FileInfoService } from '@/core/FileInfoService.js';
+import { HttpRequestService } from '@/core/HttpRequestService.js';
+import { ImageProcessingService } from '@/core/ImageProcessingService.js';
+import { InternalStorageService } from '@/core/InternalStorageService.js';
+import { IdService } from '@/core/IdService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { VideoProcessingService } from '@/core/VideoProcessingService.js';
+import { loadConfig, type Config } from '@/config.js';
+import { MiDriveFile } from '@/models/DriveFile.js';
+import { FileServerService } from '@/server/FileServerService.js';
+
+const dummyPath = path.resolve('test/resources/dummy-for-file-server-service.png');
+const dummySize = fs.statSync(dummyPath).size;
+const dummyBuffer = fs.readFileSync(dummyPath);
+const svgBuffer = Buffer.from('', 'utf8');
+const textBuffer = Buffer.from('dummy text', 'utf8');
+
+async function createRemoteFileServer() {
+ const flatPngBuffer = await sharp({
+ create: { width: 8, height: 8, channels: 3, background: { r: 0, g: 0, b: 0 } },
+ }).png().toBuffer();
+ const server = Fastify();
+
+ server.get('/dummy.png', async (_request, reply) => {
+ reply.header('Content-Type', 'image/png');
+ reply.header('Content-Length', String(dummyBuffer.length));
+ return reply.send(dummyBuffer);
+ });
+
+ server.get('/dummy.svg', async (_request, reply) => {
+ reply.header('Content-Type', 'image/svg+xml');
+ reply.header('Content-Length', String(svgBuffer.length));
+ return reply.send(svgBuffer);
+ });
+
+ server.get('/dummy.txt', async (_request, reply) => {
+ reply.header('Content-Type', 'text/plain');
+ reply.header('Content-Length', String(textBuffer.length));
+ return reply.send(textBuffer);
+ });
+
+ server.get('/flat.png', async (_request, reply) => {
+ reply.header('Content-Type', 'image/png');
+ reply.header('Content-Length', String(flatPngBuffer.length));
+ return reply.send(flatPngBuffer);
+ });
+
+ const baseUrl = await server.listen({ port: 0, host: '127.0.0.1' });
+
+ return {
+ server,
+ pngUrl: `${baseUrl}/dummy.png`,
+ svgUrl: `${baseUrl}/dummy.svg`,
+ textUrl: `${baseUrl}/dummy.txt`,
+ flatPngUrl: `${baseUrl}/flat.png`,
+ };
+}
+
+describe('FileServerService', () => {
+ let db: DataSource;
+ let fastify: FastifyInstance;
+ let externalFastify: FastifyInstance;
+ let driveFilesRepository: Repository;
+ let internalStorageService: InternalStorageService;
+ let idService: IdService;
+ let config: Config;
+ let fileServerService: FileServerService;
+ let externalFileServerService: FileServerService;
+ let remoteServer: FastifyInstance;
+ let remotePngUrl: string;
+ let remoteSvgUrl: string;
+ let remoteTextUrl: string;
+ let remoteFlatPngUrl: string;
+ const storedPaths: string[] = [];
+ let createdFallbackAssets = false;
+ let fallbackAssetsDir = '';
+
+ function writeInternalFile(key: string) {
+ const dest = internalStorageService.resolvePath(key);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(dummyPath, dest);
+ storedPaths.push(dest);
+ }
+
+ async function insertDriveFile(params: {
+ accessKey: string;
+ thumbnailAccessKey?: string | null;
+ webpublicAccessKey?: string | null;
+ storedInternal: boolean;
+ isLink: boolean;
+ uri?: string | null;
+ name?: string;
+ type?: string;
+ size?: number;
+ }) {
+ const accessKey = params.accessKey;
+ const url = params.uri ?? `${config.url}/files/${accessKey}`;
+ await driveFilesRepository.insert({
+ id: idService.gen(),
+ userId: null,
+ userHost: null,
+ md5: '00000000000000000000000000000000',
+ name: params.name ?? 'dummy.png',
+ type: params.type ?? 'image/png',
+ size: params.size ?? dummySize,
+ comment: null,
+ blurhash: null,
+ properties: {},
+ storedInternal: params.storedInternal,
+ url,
+ thumbnailUrl: null,
+ webpublicUrl: null,
+ webpublicType: null,
+ accessKey,
+ thumbnailAccessKey: params.thumbnailAccessKey ?? null,
+ webpublicAccessKey: params.webpublicAccessKey ?? null,
+ uri: params.uri ?? null,
+ src: null,
+ folderId: null,
+ isSensitive: false,
+ maybeSensitive: false,
+ maybePorn: false,
+ isLink: params.isLink,
+ requestHeaders: {},
+ requestIp: null,
+ });
+ }
+
+ beforeAll(async () => {
+ config = loadConfig();
+ db = await initTestDb(false);
+ driveFilesRepository = db.getRepository(MiDriveFile);
+
+ const loggerService = new LoggerService();
+ const aiService = {
+ detectSensitive: async () => null,
+ } as unknown as AiService;
+ const fileInfoService = new FileInfoService(aiService, loggerService);
+ const httpRequestService = new HttpRequestService(config);
+ const downloadService = new DownloadService(config, httpRequestService, loggerService);
+ const imageProcessingService = new ImageProcessingService();
+ const videoProcessingService = new VideoProcessingService(config, imageProcessingService);
+ internalStorageService = new InternalStorageService(config);
+ idService = new IdService(config);
+ fileServerService = new FileServerService(
+ config,
+ driveFilesRepository as any,
+ fileInfoService,
+ downloadService,
+ imageProcessingService,
+ videoProcessingService,
+ internalStorageService,
+ loggerService,
+ );
+
+ fastify = Fastify();
+ await fastify.register(fastifyStatic, {
+ root: path.resolve('src/server/assets'),
+ serve: false,
+ });
+ fileServerService.createServer(fastify, {}, () => {});
+ await fastify.ready();
+
+ const externalConfig = {
+ ...config,
+ mediaProxy: 'https://media-proxy.test',
+ externalMediaProxyEnabled: true,
+ } as Config;
+ externalFileServerService = new FileServerService(
+ externalConfig,
+ driveFilesRepository as any,
+ fileInfoService,
+ downloadService,
+ imageProcessingService,
+ videoProcessingService,
+ internalStorageService,
+ loggerService,
+ );
+ externalFastify = Fastify();
+ await externalFastify.register(fastifyStatic, {
+ root: path.resolve('src/server/assets'),
+ serve: false,
+ });
+ externalFileServerService.createServer(externalFastify, {}, () => {});
+ await externalFastify.ready();
+
+ const remoteServerInfo = await createRemoteFileServer();
+ remoteServer = remoteServerInfo.server;
+ remotePngUrl = remoteServerInfo.pngUrl;
+ remoteSvgUrl = remoteServerInfo.svgUrl;
+ remoteTextUrl = remoteServerInfo.textUrl;
+ remoteFlatPngUrl = remoteServerInfo.flatPngUrl;
+
+ fallbackAssetsDir = path.resolve('src/server/file/assets');
+ if (!fs.existsSync(fallbackAssetsDir)) {
+ fs.mkdirSync(fallbackAssetsDir, { recursive: true });
+ fs.copyFileSync(dummyPath, path.join(fallbackAssetsDir, 'dummy.png'));
+ createdFallbackAssets = true;
+ }
+ });
+
+ afterEach(async () => {
+ await driveFilesRepository.createQueryBuilder().delete().execute();
+ for (const filePath of storedPaths) {
+ try {
+ fs.unlinkSync(filePath);
+ } catch {
+ // NOP
+ }
+ }
+ storedPaths.length = 0;
+ });
+
+ afterAll(async () => {
+ await fastify.close();
+ await externalFastify.close();
+ await remoteServer.close();
+ await db.destroy();
+ if (createdFallbackAssets) {
+ fs.rmSync(fallbackAssetsDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('GET /files/app-default.jpg', () => {
+ test('GET /files/app-default.jpg ヘッダを検証する', async () => {
+ const prevNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'test';
+
+ try {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-type']).toBe('image/jpeg');
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
+ } finally {
+ process.env.NODE_ENV = prevNodeEnv;
+ }
+ });
+
+ test('GET /files/app-default.jpg development で CORS を許可する', async () => {
+ const prevNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'development';
+
+ try {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['access-control-allow-origin']).toBe('*');
+ } finally {
+ process.env.NODE_ENV = prevNodeEnv;
+ }
+ });
+
+ test('GET /files/app-default.jpg?x=1 クエリを除去してリダイレクトする', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg?x=1',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers.location).toBe('/files/app-default.jpg');
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+ });
+
+ describe('GET /files/:key', () => {
+ test('GET /files/:key 404 のときダミー画像を返す', async () => {
+ const accessKey = randomString();
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=86400');
+ });
+
+ test('GET /files/:key 画像配信ヘッダを検証する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['content-length']).toBe(String(dummySize));
+ expect(res.headers['content-disposition'] ?? '').toMatch(/^inline;/);
+ });
+
+ test('GET /files/:key Range で部分配信する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe('4');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key Range の終端を補正する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-999999',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-${dummySize - 1}/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe(String(dummySize));
+ });
+
+ test('GET /files/:key thumbnail の Range で部分配信する', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ writeInternalFile(thumbnailKey);
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe('4');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key thumbnail のファイル名を整形する', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ writeInternalFile(thumbnailKey);
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('sample-thumb.png');
+ });
+
+ test('GET /files/:key webpublic のファイル名を整形する', async () => {
+ const accessKey = randomString();
+ const webpublicKey = randomString();
+ writeInternalFile(webpublicKey);
+ await insertDriveFile({
+ accessKey,
+ webpublicAccessKey: webpublicKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${webpublicKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('sample-web.png');
+ });
+
+ test('GET /files/:key browsersafe でない MIME は octet-stream になる', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ type: 'application/x-msdownload',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('application/octet-stream');
+ });
+
+ test('GET /files/:key 204 のときキャッシュ制御を返す', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(204);
+ expect(res.headers['cache-control']).toBe('max-age=86400');
+ });
+
+ test('GET /files/:key 外部リンクを取得して配信する', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-length']).toBe(String(dummyBuffer.length));
+ expect(res.headers['content-disposition'] ?? '').toContain('remote.png');
+ });
+
+ test('GET /files/:key 外部リンクを Range で部分配信する', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummyBuffer.length}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe(String(dummyBuffer.length));
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key thumbnail は mediaProxy/static.webp にリダイレクトする', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers.location).toContain(`${config.mediaProxy}/static.webp`);
+ expect(res.headers.location).toContain('static=1');
+ });
+
+ test('GET /files/:key webpublic svg は mediaProxy/svg.webp にリダイレクトする', async () => {
+ const accessKey = randomString();
+ const webpublicKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ webpublicAccessKey: webpublicKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remoteSvgUrl,
+ name: 'vector.svg',
+ type: 'image/svg+xml',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${webpublicKey}`,
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers.location).toContain(`${config.mediaProxy}/svg.webp`);
+ });
+ });
+
+ describe('GET /files/:key/*', () => {
+ test('GET /files/:key/* 正規の /files/:key にリダイレクトする', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/testkey/extra/path',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers.location).toBe(`${config.url}/files/testkey`);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+ });
+
+ describe('GET /proxy/:url*', () => {
+ test('GET /proxy/:url* 外部メディアプロキシへリダイレクトする', async () => {
+ const res = await externalFastify.inject({
+ method: 'GET',
+ url: '/proxy/path-part?url=https%3A%2F%2Fexample.com%2Fimg.png&static=1',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('public, max-age=259200');
+ expect(res.headers.location).toContain('https://media-proxy.test/');
+ expect(res.headers.location).toContain('url=https%3A%2F%2Fexample.com%2Fimg.png');
+ expect(res.headers.location).toContain('static=1');
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+
+ test('GET /proxy/:url* misskey User-Agent を拒否する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png',
+ headers: {
+ 'user-agent': 'misskey/1.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* origin 指定時は User-Agent 必須を検証する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png&origin=1',
+ headers: {
+ 'user-agent': '',
+ },
+ });
+
+ expect(res.statusCode).toBe(400);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ expect(res.headers.location).toBeUndefined();
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+
+ test('GET /proxy/:url* emoji 指定で非画像は 404 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}&emoji=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* 非画像は 403 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* emoji static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&emoji=1&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* avatar static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&avatar=1&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* preview で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&preview=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* svg を webp に変換する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteSvgUrl)}`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.svg.webp');
+ });
+
+ test('GET /proxy/:url* badge で低エントロピー画像は 404 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteFlatPngUrl)}&badge=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* 画像をそのまま返す', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(`${config.url}/files/${accessKey}`)}&origin=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png');
+ });
+ });
+});