From 106fffdcfe57c06314413cd436d81a8c5d5c04bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:34:29 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore(backend):=20FileServerService?= =?UTF-8?q?=E3=81=AEunit-test=E3=82=92=E8=BF=BD=E5=8A=A0=20(#17086)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add test * fix * fix type error --- .../dummy-for-file-server-service.png | Bin 0 -> 6285 bytes .../test/unit/server/FileServerService.ts | 770 ++++++++++++++++++ 2 files changed, 770 insertions(+) create mode 100644 packages/backend/test/resources/dummy-for-file-server-service.png create mode 100644 packages/backend/test/unit/server/FileServerService.ts 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 0000000000000000000000000000000000000000..39332b0c1beeda1edb90d78d25c16e7372aff030 GIT binary patch literal 6285 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', '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'); + }); + }); +}); From faf2399e315fd7a2d4aca0a57b898f0d09065606 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:58:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?enhance(frontend):=20=E9=80=A3=E5=90=88?= =?UTF-8?q?=E3=81=AA=E3=81=97=E3=81=8C=E6=8C=87=E5=AE=9A=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=82=8B=E3=81=A8=E3=81=8D=E3=81=AB=E5=85=AC?= =?UTF-8?q?=E9=96=8B=E7=AF=84=E5=9B=B2=E3=82=92=E6=8C=87=E5=90=8D=E3=81=AB?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve #14760 --- packages/frontend/src/components/MkPostForm.vue | 5 ++--- packages/frontend/src/components/MkVisibilityPicker.vue | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index c869eeb3fd..9d5d2392d0 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ targetChannel.name }} - @@ -529,7 +529,6 @@ function setVisibility() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility.value, isSilenced: $i.isSilenced, - localOnly: localOnly.value, anchorElement: visibilityButton.value, ...(replyTargetNote.value ? { isReplyVisibilitySpecified: replyTargetNote.value.visibility === 'specified' } : {}), }, { @@ -1023,7 +1022,7 @@ async function post(ev?: PointerEvent) { channelId: targetChannel.value ? targetChannel.value.id : undefined, poll: poll.value, cw: useCw.value ? cw.value ?? '' : null, - localOnly: localOnly.value, + localOnly: visibility.value === 'specified' ? false : localOnly.value, visibility: visibility.value, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, reactionAcceptance: reactionAcceptance.value, diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 88b934bb58..361fda0c24 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._visibility.followersDescription }} -