diff options
Diffstat (limited to 'packages/backend/src/server/api/ApiCallService.ts')
| -rw-r--r-- | packages/backend/src/server/api/ApiCallService.ts | 229 |
1 files changed, 146 insertions, 83 deletions
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index c3ce12e0c3..2e72cdf9f8 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -1,19 +1,25 @@ import { performance } from 'perf_hooks'; +import { pipeline } from 'node:stream'; +import * as fs from 'node:fs'; +import { promisify } from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; +import { FastifyRequest, FastifyReply } from 'fastify'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; -import type { CacheableLocalUser, User } from '@/models/entities/User.js'; +import type { CacheableLocalUser, ILocalUser, User } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import type Logger from '@/logger.js'; import type { UserIpsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; +import { createTemp } from '@/misc/create-temp.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; import type { IEndpointMeta, IEndpoint } from './endpoints.js'; -import type Koa from 'koa'; + +const pump = promisify(pipeline); const accessDenied = { message: 'Access denied.', @@ -44,92 +50,149 @@ export class ApiCallService implements OnApplicationShutdown { }, 1000 * 60 * 60); } - public handleRequest(endpoint: IEndpoint, exec: any, ctx: Koa.Context) { - return new Promise<void>((res) => { - const body = ctx.is('multipart/form-data') - ? (ctx.request as any).body - : ctx.method === 'GET' - ? ctx.query - : ctx.request.body; - - const reply = (x?: any, y?: ApiError) => { - if (x == null) { - ctx.status = 204; - } else if (typeof x === 'number' && y) { - ctx.status = x; - ctx.body = { - error: { - message: y!.message, - code: y!.code, - id: y!.id, - kind: y!.kind, - ...(y!.info ? { info: y!.info } : {}), - }, - }; - } else { - // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない - ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; - } - res(); - }; - - // Authentication - this.authenticateService.authenticate(body['i']).then(([user, app]) => { - // API invoking - this.call(endpoint, exec, user, app, body, ctx).then((res: any) => { - if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { - ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); - } - reply(res); - }).catch((e: ApiError) => { - reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); - }); - - // Log IP - if (user) { - this.metaService.fetch().then(meta => { - if (!meta.enableIpLogging) return; - const ip = ctx.ip; - const ips = this.userIpHistories.get(user.id); - if (ips == null || !ips.has(ip)) { - if (ips == null) { - this.userIpHistories.set(user.id, new Set([ip])); - } else { - ips.add(ip); - } - - try { - this.userIpsRepository.createQueryBuilder().insert().values({ - createdAt: new Date(), - userId: user.id, - ip: ip, - }).orIgnore(true).execute(); - } catch { - } - } - }); - } - }).catch(e => { - if (e instanceof AuthenticationError) { - reply(403, new ApiError({ - message: 'Authentication failed. Please ensure your token is correct.', - code: 'AUTHENTICATION_FAILED', - id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', - })); - } else { - reply(500, new ApiError()); + public handleRequest( + endpoint: IEndpoint & { exec: any }, + request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>, + reply: FastifyReply, + ) { + const body = request.method === 'GET' + ? request.query + : request.body; + + const token = body['i']; + if (token != null && typeof token !== 'string') { + reply.code(400); + return; + } + this.authenticateService.authenticate(token).then(([user, app]) => { + this.call(endpoint, user, app, body, null, request).then((res) => { + if (request.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { + reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); } + this.send(reply, res); + }).catch((err: ApiError) => { + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); }); + + if (user) { + this.logIp(request, user); + } + }).catch(err => { + if (err instanceof AuthenticationError) { + this.send(reply, 403, new ApiError({ + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + })); + } else { + this.send(reply, 500, new ApiError()); + } }); } + public async handleMultipartRequest( + endpoint: IEndpoint & { exec: any }, + request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>, + reply: FastifyReply, + ) { + const multipartData = await request.file(); + if (multipartData == null) { + reply.code(400); + return; + } + + const [path] = await createTemp(); + await pump(multipartData.file, fs.createWriteStream(path)); + + const fields = {} as Record<string, string | undefined>; + for (const [k, v] of Object.entries(multipartData.fields)) { + fields[k] = v.value; + } + + const token = fields['i']; + if (token != null && typeof token !== 'string') { + reply.code(400); + return; + } + this.authenticateService.authenticate(token).then(([user, app]) => { + this.call(endpoint, user, app, fields, { + name: multipartData.filename, + path: path, + }, request).then((res) => { + this.send(reply, res); + }).catch((err: ApiError) => { + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + }); + + if (user) { + this.logIp(request, user); + } + }).catch(err => { + if (err instanceof AuthenticationError) { + this.send(reply, 403, new ApiError({ + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + })); + } else { + this.send(reply, 500, new ApiError()); + } + }); + } + + private send(reply: FastifyReply, x?: any, y?: ApiError) { + if (x == null) { + reply.code(204); + } else if (typeof x === 'number' && y) { + reply.code(x); + reply.send({ + error: { + message: y!.message, + code: y!.code, + id: y!.id, + kind: y!.kind, + ...(y!.info ? { info: y!.info } : {}), + }, + }); + } else { + // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない + reply.send(typeof x === 'string' ? JSON.stringify(x) : x); + } + } + + private async logIp(request: FastifyRequest, user: ILocalUser) { + const meta = await this.metaService.fetch(); + if (!meta.enableIpLogging) return; + const ip = request.ip; + const ips = this.userIpHistories.get(user.id); + if (ips == null || !ips.has(ip)) { + if (ips == null) { + this.userIpHistories.set(user.id, new Set([ip])); + } else { + ips.add(ip); + } + + try { + this.userIpsRepository.createQueryBuilder().insert().values({ + createdAt: new Date(), + userId: user.id, + ip: ip, + }).orIgnore(true).execute(); + } catch { + } + } + } + private async call( - ep: IEndpoint, - exec: any, + ep: IEndpoint & { exec: any }, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, - ctx?: Koa.Context, + file: { + name: string; + path: string; + } | null, + request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>, ) { const isSecure = user != null && token == null; const isModerator = user != null && (user.isModerator || user.isAdmin); @@ -144,7 +207,7 @@ export class ApiCallService implements OnApplicationShutdown { if (user) { limitActor = user.id; } else { - limitActor = getIpHash(ctx!.ip); + limitActor = getIpHash(request.ip); } const limit = Object.assign({}, ep.meta.limit); @@ -154,7 +217,7 @@ export class ApiCallService implements OnApplicationShutdown { } // Rate limit - await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => { + await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(err => { throw new ApiError({ message: 'Rate limit exceeded. Please try again later.', code: 'RATE_LIMIT_EXCEEDED', @@ -199,7 +262,7 @@ export class ApiCallService implements OnApplicationShutdown { } // Cast non JSON input - if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) { + 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') { @@ -221,7 +284,7 @@ export class ApiCallService implements OnApplicationShutdown { // API invoking const before = performance.now(); - return await exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((err: Error) => { + return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { if (err instanceof ApiError) { throw err; } else { |