diff options
| author | おさむのひと <46447427+samunohito@users.noreply.github.com> | 2025-04-29 08:15:09 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-29 08:15:09 +0900 |
| commit | 7e8cc4d7c0a86ad0bf71a727fb16132e8bc180a8 (patch) | |
| tree | 32566ed4917ac80de0d4a1095f5442923e29a8fb /packages | |
| parent | enhance(frontend): disable router view transition (diff) | |
| download | misskey-7e8cc4d7c0a86ad0bf71a727fb16132e8bc180a8.tar.gz misskey-7e8cc4d7c0a86ad0bf71a727fb16132e8bc180a8.tar.bz2 misskey-7e8cc4d7c0a86ad0bf71a727fb16132e8bc180a8.zip | |
fix: 添付ファイルのあるリクエストを受けたときの初動を改善 (#15896)
* wip
* ロールポリシーの値も参照するように
* エンドポイントのテストを追加
* fix review
* add spdx
* fix CHANGELOG.md
* fix test
* regenerate
* add log
* Revert "add log"
This reverts commit 4b2bf59a609b85ca0bfcc9b71438db782f11983d.
* add log
* fix
* Revert "add log"
This reverts commit c5a73d57da0f30ec5215e08a8b4d78785cce48d1.
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/backend/package.json | 4 | ||||
| -rw-r--r-- | packages/backend/src/server/ServerService.ts | 7 | ||||
| -rw-r--r-- | packages/backend/src/server/api/ApiCallService.ts | 166 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoint-base.ts | 10 | ||||
| -rw-r--r-- | packages/backend/test/e2e/api.ts | 4 | ||||
| -rw-r--r-- | packages/backend/test/unit/server/api/drive/files/create.ts | 108 |
6 files changed, 234 insertions, 65 deletions
diff --git a/packages/backend/package.json b/packages/backend/package.json index faf331ed85..c4de44df18 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -222,6 +222,7 @@ "@types/semver": "7.7.0", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", + "@types/supertest": "6.0.3", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", @@ -238,6 +239,7 @@ "jest-mock": "29.7.0", "nodemon": "3.1.10", "pid-port": "1.0.2", - "simple-oauth2": "5.1.0" + "simple-oauth2": "5.1.0", + "supertest": "7.1.0" } } diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 355d7ca08e..7decdd2c10 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -73,7 +73,7 @@ export class ServerService implements OnApplicationShutdown { } @bindThis - public async launch(): Promise<void> { + public async launch() { const fastify = Fastify({ trustProxy: true, logger: false, @@ -133,8 +133,8 @@ export class ServerService implements OnApplicationShutdown { reply.header('content-type', 'text/plain; charset=utf-8'); reply.header('link', `<${encodeURI(location)}>; rel="canonical"`); done(null, [ - "Refusing to relay remote ActivityPub object lookup.", - "", + 'Refusing to relay remote ActivityPub object lookup.', + '', `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`, ].join('\n')); }); @@ -301,6 +301,7 @@ export class ServerService implements OnApplicationShutdown { } await fastify.ready(); + return fastify; } @bindThis diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index a42fdaf730..960c7b5476 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -6,8 +6,11 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; +import { Transform } from 'node:stream'; +import { type MultipartFile } from '@fastify/multipart'; import { Inject, Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/node'; +import { AttachmentFile } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -16,7 +19,7 @@ import type Logger from '@/logger.js'; import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; +import { type RolePolicies, RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; @@ -200,18 +203,6 @@ export class ApiCallService implements OnApplicationShutdown { return; } - const [path, cleanup] = await createTemp(); - await stream.pipeline(multipartData.file, fs.createWriteStream(path)); - - // ファイルサイズが制限を超えていた場合 - // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある - if (multipartData.file.truncated) { - cleanup(); - reply.code(413); - reply.send(); - return; - } - const fields = {} as Record<string, unknown>; for (const [k, v] of Object.entries(multipartData.fields)) { fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; @@ -226,10 +217,7 @@ export class ApiCallService implements OnApplicationShutdown { return; } this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, { - name: multipartData.filename, - path: path, - }, request).then((res) => { + this.call(endpoint, user, app, fields, multipartData, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { this.#sendApiError(reply, err); @@ -294,10 +282,7 @@ export class ApiCallService implements OnApplicationShutdown { user: MiLocalUser | null | undefined, token: MiAccessToken | null | undefined, data: any, - file: { - name: string; - path: string; - } | null, + multipartFile: MultipartFile | null, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, ) { const isSecure = user != null && token == null; @@ -371,6 +356,37 @@ export class ApiCallService implements OnApplicationShutdown { } } + // Cast non JSON input + if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { + for (const k of Object.keys(ep.params.properties)) { + const param = ep.params.properties![k]; + if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { + try { + data[k] = JSON.parse(data[k]); + } catch (e) { + throw new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', + }, { + param: k, + reason: `cannot cast to ${param.type}`, + }); + } + } + } + } + + if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) + || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + kind: 'permission', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { @@ -404,47 +420,89 @@ export class ApiCallService implements OnApplicationShutdown { } } - if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) - || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { - throw new ApiError({ - message: 'Your app does not have the necessary permissions to use this endpoint.', - code: 'PERMISSION_DENIED', - kind: 'permission', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', - }); - } - - // Cast non JSON input - if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { - for (const k of Object.keys(ep.params.properties)) { - const param = ep.params.properties![k]; - if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { - try { - data[k] = JSON.parse(data[k]); - } catch (e) { - throw new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', - }, { - param: k, - reason: `cannot cast to ${param.type}`, - }); - } - } - } + let attachmentFile: AttachmentFile | null = null; + let cleanup = () => {}; + if (ep.meta.requireFile && request.method === 'POST' && multipartFile) { + const policies = await this.roleService.getUserPolicies(user!.id); + const result = await this.handleAttachmentFile( + Math.min((policies.maxFileSizeMb * 1024 * 1024), this.config.maxFileSize), + multipartFile, + ); + attachmentFile = result.attachmentFile; + cleanup = result.cleanup; } // API invoking if (this.config.sentryForBackend) { return await Sentry.startSpan({ name: 'API: ' + ep.name, - }, () => ep.exec(data, user, token, file, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); + }, () => { + return ep.exec(data, user, token, attachmentFile, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) + .finally(() => cleanup()); + }); } else { - return await ep.exec(data, user, token, file, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); + return await ep.exec(data, user, token, attachmentFile, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) + .finally(() => cleanup()); + } + } + + @bindThis + private async handleAttachmentFile( + fileSizeLimit: number, + multipartFile: MultipartFile, + ) { + function createTooLongError() { + return new ApiError({ + httpStatusCode: 413, + kind: 'client', + message: 'File size is too large.', + code: 'FILE_SIZE_TOO_LARGE', + id: 'ff827ce8-9b4b-4808-8511-422222a3362f', + }); + } + + function createLimitStream(limit: number) { + let total = 0; + + return new Transform({ + transform(chunk, _, callback) { + total += chunk.length; + if (total > limit) { + callback(createTooLongError()); + } else { + callback(null, chunk); + } + }, + }); } + + const [path, cleanup] = await createTemp(); + try { + await stream.pipeline( + multipartFile.file, + createLimitStream(fileSizeLimit), + fs.createWriteStream(path), + ); + + // ファイルサイズが制限を超えていた場合 + // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある + if (multipartFile.file.truncated) { + throw createTooLongError(); + } + } catch (err) { + cleanup(); + throw err; + } + + return { + attachmentFile: { + name: multipartFile.filename, + path, + }, + cleanup, + }; } @bindThis diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index e061aa3a8e..b063487305 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -21,23 +21,23 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); export type Response = Record<string, any> | void; -type File = { +export type AttachmentFile = { name: string | null; path: string; }; // TODO: paramsの型をT['params']のスキーマ定義から推論する type Executor<T extends IEndpointMeta, Ps extends Schema> = - (params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => - Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; + (params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => + Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> { - public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>; + public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>; constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) { const validate = ajv.compile(paramDef); - this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => { + this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => { let cleanup: undefined | (() => void) = undefined; if (meta.requireFile) { diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index 49c6a0636b..f9e65aaa84 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -159,8 +159,8 @@ describe('API', () => { user: { token: application3 }, }, { status: 403, - code: 'ROLE_PERMISSION_DENIED', - id: 'c3d38592-54c0-429d-be96-5636b0431a61', + code: 'PERMISSION_DENIED', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', }); await failedApiCall({ diff --git a/packages/backend/test/unit/server/api/drive/files/create.ts b/packages/backend/test/unit/server/api/drive/files/create.ts new file mode 100644 index 0000000000..b98892fa03 --- /dev/null +++ b/packages/backend/test/unit/server/api/drive/files/create.ts @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { S3Client } from '@aws-sdk/client-s3'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mockClient } from 'aws-sdk-client-mock'; +import { FastifyInstance } from 'fastify'; +import request from 'supertest'; +import { CoreModule } from '@/core/CoreModule.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { MiUser } from '@/models/User.js'; +import { ServerModule } from '@/server/ServerModule.js'; +import { ServerService } from '@/server/ServerService.js'; + +describe('/drive/files/create', () => { + let module: TestingModule; + let server: FastifyInstance; + const s3Mock = mockClient(S3Client); + let roleService: RoleService; + + let root: MiUser; + let role_tinyAttachment: MiRole; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule, ServerModule], + }).compile(); + module.enableShutdownHooks(); + + const serverService = module.get<ServerService>(ServerService); + server = await serverService.launch(); + + const usersRepository = module.get<UsersRepository>(DI.usersRepository); + root = await usersRepository.insert({ + id: 'root', + username: 'root', + usernameLower: 'root', + token: '1234567890123456', + }).then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + const userProfilesRepository = module.get<UserProfilesRepository>(DI.userProfilesRepository); + await userProfilesRepository.insert({ + userId: root.id, + }); + + roleService = module.get<RoleService>(RoleService); + role_tinyAttachment = await roleService.create({ + name: 'test-role001', + description: 'Test role001 description', + target: 'manual', + policies: { + maxFileSizeMb: { + useDefault: false, + priority: 1, + // 10byte + value: 10 / 1024 / 1024, + }, + }, + }); + }); + + beforeEach(async () => { + s3Mock.reset(); + await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {}); + }); + + afterAll(async () => { + await server.close(); + await module.close(); + }); + + test('200 ok', async () => { + const result = await request(server.server) + .post('/api/drive/files/create') + .set('Content-Type', 'multipart/form-data') + .set('Authorization', `Bearer ${root.token}`) + .attach('file', Buffer.from('a'.repeat(1024 * 1024))); + expect(result.statusCode).toBe(200); + }); + + test('200 ok(with role)', async () => { + await roleService.assign(root.id, role_tinyAttachment.id); + + const result = await request(server.server) + .post('/api/drive/files/create') + .set('Content-Type', 'multipart/form-data') + .set('Authorization', `Bearer ${root.token}`) + .attach('file', Buffer.from('a'.repeat(10))); + expect(result.statusCode).toBe(200); + }); + + test('413 too large', async () => { + await roleService.assign(root.id, role_tinyAttachment.id); + + const result = await request(server.server) + .post('/api/drive/files/create') + .set('Content-Type', 'multipart/form-data') + .set('Authorization', `Bearer ${root.token}`) + .attach('file', Buffer.from('a'.repeat(11))); + expect(result.statusCode).toBe(413); + expect(result.body.error.code).toBe('FILE_SIZE_TOO_LARGE'); + }); +}); |