diff options
Diffstat (limited to 'packages/backend/src/server/api')
11 files changed, 477 insertions, 429 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 { diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 52654dbaee..cf3f2deebf 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -1,15 +1,13 @@ import { Inject, Injectable } from '@nestjs/common'; -import Koa from 'koa'; -import Router from '@koa/router'; -import multer from '@koa/multer'; -import bodyParser from 'koa-bodyparser'; -import cors from '@koa/cors'; -import { ModuleRef } from '@nestjs/core'; +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import cors from '@fastify/cors'; +import multipart from '@fastify/multipart'; +import { ModuleRef, repl } from '@nestjs/core'; import type { Config } from '@/config.js'; import type { UsersRepository, InstancesRepository, AccessTokensRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import endpoints from './endpoints.js'; +import endpoints, { IEndpoint } from './endpoints.js'; import { ApiCallService } from './ApiCallService.js'; import { SignupApiService } from './SignupApiService.js'; import { SigninApiService } from './SigninApiService.js'; @@ -42,92 +40,107 @@ export class ApiServerService { private discordServerService: DiscordServerService, private twitterServerService: TwitterServerService, ) { + this.createServer = this.createServer.bind(this); } - public createApiServer() { - const handlers: Record<string, any> = {}; - - for (const endpoint of endpoints) { - handlers[endpoint.name] = this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec; - } - - // Init app - const apiServer = new Koa(); - - apiServer.use(cors({ + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.register(cors, { origin: '*', - })); - - // No caching - apiServer.use(async (ctx, next) => { - ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); - await next(); }); - apiServer.use(bodyParser({ - // リクエストが multipart/form-data でない限りはJSONだと見なす - detectJSON: ctx => !ctx.is('multipart/form-data'), - })); - - // Init multer instance - const upload = multer({ - storage: multer.diskStorage({}), + fastify.register(multipart, { limits: { fileSize: this.config.maxFileSize ?? 262144000, files: 1, }, }); - // Init router - const router = new Router(); + // Prevent cache + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); + done(); + }); - /** - * Register endpoint handlers - */ for (const endpoint of endpoints) { + const ep = { + name: endpoint.name, + meta: endpoint.meta, + params: endpoint.params, + exec: this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec, + }; + if (endpoint.meta.requireFile) { - router.post(`/${endpoint.name}`, upload.single('file'), this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); + fastify.all<{ + Params: { endpoint: string; }, + Body: Record<string, unknown>, + Querystring: Record<string, unknown>, + }>('/' + endpoint.name, (request, reply) => { + if (request.method === 'GET' && !endpoint.meta.allowGet) { + reply.code(405); + return; + } + + this.apiCallService.handleMultipartRequest(ep, request, reply); + }); } else { - // 後方互換性のため - if (endpoint.name.includes('-')) { - router.post(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); - - if (endpoint.meta.allowGet) { - router.get(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); - } else { - router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; }); + fastify.all<{ + Params: { endpoint: string; }, + Body: Record<string, unknown>, + Querystring: Record<string, unknown>, + }>('/' + endpoint.name, (request, reply) => { + if (request.method === 'GET' && !endpoint.meta.allowGet) { + reply.code(405); + return; } - } - - router.post(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); - - if (endpoint.meta.allowGet) { - router.get(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); - } else { - router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); - } + + this.apiCallService.handleRequest(ep, request, reply); + }); } } - router.post('/signup', ctx => this.signupApiServiceService.signup(ctx)); - router.post('/signin', ctx => this.signinApiServiceService.signin(ctx)); - router.post('/signup-pending', ctx => this.signupApiServiceService.signupPending(ctx)); + fastify.post<{ + Body: { + username: string; + password: string; + host?: string; + invitationCode?: string; + emailAddress?: string; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + } + }>('/signup', (request, reply) => this.signupApiServiceService.signup(request, reply)); + + fastify.post<{ + Body: { + username: string; + password: string; + token?: string; + signature?: string; + authenticatorData?: string; + clientDataJSON?: string; + credentialId?: string; + challengeId?: string; + }; + }>('/signin', (request, reply) => this.signinApiServiceService.signin(request, reply)); + + fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiServiceService.signupPending(request, reply)); - router.use(this.discordServerService.create().routes()); - router.use(this.githubServerService.create().routes()); - router.use(this.twitterServerService.create().routes()); + fastify.register(this.discordServerService.create); + fastify.register(this.githubServerService.create); + fastify.register(this.twitterServerService.create); - router.get('/v1/instance/peers', async ctx => { + fastify.get('/v1/instance/peers', async (request, reply) => { const instances = await this.instancesRepository.find({ select: ['host'], }); - ctx.body = instances.map(instance => instance.host); + return instances.map(instance => instance.host); }); - router.post('/miauth/:session/check', async ctx => { + fastify.post<{ Params: { session: string; } }>('/miauth/:session/check', async (request, reply) => { const token = await this.accessTokensRepository.findOneBy({ - session: ctx.params.session, + session: request.params.session, }); if (token && token.session != null && !token.fetched) { @@ -135,26 +148,18 @@ export class ApiServerService { fetched: true, }); - ctx.body = { + return { ok: true, token: token.token, user: await this.userEntityService.pack(token.userId, null, { detail: true }), }; } else { - ctx.body = { + return { ok: false, }; } }); - // Return 404 for unknown API - router.all('(.*)', async ctx => { - ctx.status = 404; - }); - - // Register router - apiServer.use(router.routes()); - - return apiServer; + done(); } } diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 4ce9b91f42..ad387c4732 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -34,7 +34,7 @@ export class AuthenticateService { this.appCache = new Cache<App>(Infinity); } - public async authenticate(token: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> { + public async authenticate(token: string | null | undefined): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> { if (token == null) { return [null, null]; } diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index a5e2b09012..8b3d86e5a6 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -3,6 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import * as speakeasy from 'speakeasy'; import { IsNull } from 'typeorm'; +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { DI } from '@/di-symbols.js'; import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -12,7 +13,6 @@ import { IdService } from '@/core/IdService.js'; import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; -import type Koa from 'koa'; @Injectable() export class SigninApiService { @@ -42,47 +42,60 @@ export class SigninApiService { ) { } - public async signin(ctx: Koa.Context) { - ctx.set('Access-Control-Allow-Origin', this.config.url); - ctx.set('Access-Control-Allow-Credentials', 'true'); + public async signin( + request: FastifyRequest<{ + Body: { + username: string; + password: string; + token?: string; + signature?: string; + authenticatorData?: string; + clientDataJSON?: string; + credentialId?: string; + challengeId?: string; + }; + }>, + reply: FastifyReply, + ) { + reply.header('Access-Control-Allow-Origin', this.config.url); + reply.header('Access-Control-Allow-Credentials', 'true'); - const body = ctx.request.body as any; + const body = request.body; const username = body['username']; const password = body['password']; const token = body['token']; function error(status: number, error: { id: string }) { - ctx.status = status; - ctx.body = { error }; + reply.code(status); + return { error }; } try { // not more than 1 attempt per second and not more than 10 attempts per hour - await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip)); + await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); } catch (err) { - ctx.status = 429; - ctx.body = { + reply.code(429); + return { error: { message: 'Too many failed attempts to sign in. Try again later.', code: 'TOO_MANY_AUTHENTICATION_FAILURES', id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', }, }; - return; } if (typeof username !== 'string') { - ctx.status = 400; + reply.code(400); return; } if (typeof password !== 'string') { - ctx.status = 400; + reply.code(400); return; } if (token != null && typeof token !== 'string') { - ctx.status = 400; + reply.code(400); return; } @@ -93,17 +106,15 @@ export class SigninApiService { }) as ILocalUser; if (user == null) { - error(404, { + return error(404, { id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', }); - return; } if (user.isSuspended) { - error(403, { + return error(403, { id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', }); - return; } const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); @@ -117,32 +128,29 @@ export class SigninApiService { id: this.idService.genId(), createdAt: new Date(), userId: user.id, - ip: ctx.ip, - headers: ctx.headers, + ip: request.ip, + headers: request.headers, success: false, }); - error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); + return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); }; if (!profile.twoFactorEnabled) { if (same) { - this.signinService.signin(ctx, user); - return; + return this.signinService.signin(request, reply, user); } else { - await fail(403, { + return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); - return; } } if (token) { if (!same) { - await fail(403, { + return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); - return; } const verified = (speakeasy as any).totp.verify({ @@ -153,20 +161,17 @@ export class SigninApiService { }); if (verified) { - this.signinService.signin(ctx, user); - return; + return this.signinService.signin(request, reply, user); } else { - await fail(403, { + return await fail(403, { id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', }); - return; } - } else if (body.credentialId) { + } else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) { if (!same && !profile.usePasswordLessLogin) { - await fail(403, { + return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); - return; } const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); @@ -179,10 +184,9 @@ export class SigninApiService { }); if (!challenge) { - await fail(403, { + return await fail(403, { id: '2715a88a-2125-4013-932f-aa6fe72792da', }); - return; } await this.attestationChallengesRepository.delete({ @@ -191,10 +195,9 @@ export class SigninApiService { }); if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { - await fail(403, { + return await fail(403, { id: '2715a88a-2125-4013-932f-aa6fe72792da', }); - return; } const securityKey = await this.userSecurityKeysRepository.findOneBy({ @@ -207,10 +210,9 @@ export class SigninApiService { }); if (!securityKey) { - await fail(403, { + return await fail(403, { id: '66269679-aeaf-4474-862b-eb761197e046', }); - return; } const isValid = this.twoFactorAuthenticationService.verifySignin({ @@ -223,20 +225,17 @@ export class SigninApiService { }); if (isValid) { - this.signinService.signin(ctx, user); - return; + return this.signinService.signin(request, reply, user); } else { - await fail(403, { + return await fail(403, { id: '93b86c4b-72f9-40eb-9815-798928603d1e', }); - return; } } else { if (!same && !profile.usePasswordLessLogin) { - await fail(403, { + return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); - return; } const keys = await this.userSecurityKeysRepository.findBy({ @@ -244,10 +243,9 @@ export class SigninApiService { }); if (keys.length === 0) { - await fail(403, { + return await fail(403, { id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4', }); - return; } // 32 byte challenge @@ -266,15 +264,14 @@ export class SigninApiService { registrationChallenge: false, }); - ctx.body = { + reply.code(200); + return { challenge, challengeId, securityKeys: keys.map(key => ({ id: key.id, })), }; - ctx.status = 200; - return; } // never get here } diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index 3b96dfee6f..18a1d6c088 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -1,13 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { DI } from '@/di-symbols.js'; -import type { SigninsRepository } from '@/models/index.js'; -import type { UsersRepository } from '@/models/index.js'; +import type { SigninsRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { IdService } from '@/core/IdService.js'; import type { ILocalUser } from '@/models/entities/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; -import type Koa from 'koa'; @Injectable() export class SigninService { @@ -24,10 +23,25 @@ export class SigninService { ) { } - public signin(ctx: Koa.Context, user: ILocalUser, redirect = false) { + public signin(request: FastifyRequest, reply: FastifyReply, user: ILocalUser, redirect = false) { + setImmediate(async () => { + // Append signin history + const record = await this.signinsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + ip: request.ip, + headers: request.headers, + success: true, + }).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0])); + + // Publish signin event + this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); + }); + if (redirect) { //#region Cookie - ctx.cookies.set('igi', user.token!, { + reply.cookies.set('igi', user.token!, { path: '/', // SEE: https://github.com/koajs/koa/issues/974 // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header @@ -36,29 +50,14 @@ export class SigninService { }); //#endregion - ctx.redirect(this.config.url); + reply.redirect(this.config.url); } else { - ctx.body = { + reply.code(200); + return { id: user.id, i: user.token, }; - ctx.status = 200; } - - (async () => { - // Append signin history - const record = await this.signinsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - userId: user.id, - ip: ctx.ip, - headers: ctx.headers, - success: true, - }).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0])); - - // Publish signin event - this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); - })(); } } diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index edb8e4e8e6..771858d091 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { DI } from '@/di-symbols.js'; import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -11,8 +12,8 @@ import { SignupService } from '@/core/SignupService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { EmailService } from '@/core/EmailService.js'; import { ILocalUser } from '@/models/entities/User.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { SigninService } from './SigninService.js'; -import type Koa from 'koa'; @Injectable() export class SignupApiService { @@ -42,8 +43,22 @@ export class SignupApiService { ) { } - public async signup(ctx: Koa.Context) { - const body = ctx.request.body; + public async signup( + request: FastifyRequest<{ + Body: { + username: string; + password: string; + host?: string; + invitationCode?: string; + emailAddress?: string; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + } + }>, + reply: FastifyReply, + ) { + const body = request.body; const instance = await this.metaService.fetch(true); @@ -51,20 +66,20 @@ export class SignupApiService { // ただしテスト時はこの機構は障害となるため無効にする if (process.env.NODE_ENV !== 'test') { if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { - await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => { - ctx.throw(400, e); + await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); }); } if (instance.enableRecaptcha && instance.recaptchaSecretKey) { - await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => { - ctx.throw(400, e); + await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); }); } if (instance.enableTurnstile && instance.turnstileSecretKey) { - await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(e => { - ctx.throw(400, e); + await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(err => { + throw new FastifyReplyError(400, err); }); } } @@ -77,20 +92,20 @@ export class SignupApiService { if (instance.emailRequiredForSignup) { if (emailAddress == null || typeof emailAddress !== 'string') { - ctx.status = 400; + reply.code(400); return; } - const available = await this.emailService.validateEmailForAccount(emailAddress); - if (!available) { - ctx.status = 400; + const res = await this.emailService.validateEmailForAccount(emailAddress); + if (!res.available) { + reply.code(400); return; } } if (instance.disableRegistration) { if (invitationCode == null || typeof invitationCode !== 'string') { - ctx.status = 400; + reply.code(400); return; } @@ -99,7 +114,7 @@ export class SignupApiService { }); if (ticket == null) { - ctx.status = 400; + reply.code(400); return; } @@ -117,18 +132,18 @@ export class SignupApiService { id: this.idService.genId(), createdAt: new Date(), code, - email: emailAddress, + email: emailAddress!, username: username, password: hash, }); const link = `${this.config.url}/signup-complete/${code}`; - this.emailService.sendEmail(emailAddress, 'Signup', + this.emailService.sendEmail(emailAddress!, 'Signup', `To complete signup, please click this link:<br><a href="${link}">${link}</a>`, `To complete signup, please click this link: ${link}`); - ctx.status = 204; + reply.code(204); } else { try { const { account, secret } = await this.signupService.signup({ @@ -140,17 +155,18 @@ export class SignupApiService { includeSecrets: true, }); - (res as any).token = secret; - - ctx.body = res; - } catch (e) { - ctx.throw(400, e); + return { + ...res, + token: secret, + }; + } catch (err) { + throw new FastifyReplyError(400, err); } } } - public async signupPending(ctx: Koa.Context) { - const body = ctx.request.body; + public async signupPending(request: FastifyRequest<{ Body: { code: string; } }>, reply: FastifyReply) { + const body = request.body; const code = body['code']; @@ -174,9 +190,9 @@ export class SignupApiService { emailVerifyCode: null, }); - this.signinService.signin(ctx, account as ILocalUser); - } catch (e) { - ctx.throw(400, e); + this.signinService.signin(request, reply, account as ILocalUser); + } catch (err) { + throw new FastifyReplyError(400, err); } } } diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index 0a7f9b3008..b27329b9a9 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -14,23 +14,28 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); export type Response = Record<string, any> | void; +type File = { + 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 ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => + (params: SchemaType<Ps>, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: File, 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 ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>; + public exec: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: File, 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 ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => { + this.exec = (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => { let cleanup: undefined | (() => void) = undefined; if (meta.requireFile) { cleanup = () => { - fs.unlink(file.path, () => {}); + if (file) fs.unlink(file.path, () => {}); }; if (file == null) return Promise.reject(new ApiError({ diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index d394f5c3da..3f4485fc8b 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -78,8 +78,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ) { super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => { // Get 'name' parameter - let name = ps.name ?? file.originalname; - if (name !== undefined && name !== null) { + let name = ps.name ?? file!.name ?? null; + if (name != null) { name = name.trim(); if (name.length === 0) { name = null; @@ -88,8 +88,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { } else if (!this.driveFileEntityService.validateFileName(name)) { throw new ApiError(meta.errors.invalidFileName); } - } else { - name = null; } const meta = await this.metaService.fetch(); @@ -98,7 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // Create file const driveFile = await this.driveService.addFile({ user: me, - path: file.path, + path: file!.path, name, comment: ps.comment, folderId: ps.folderId, diff --git a/packages/backend/src/server/api/integration/DiscordServerService.ts b/packages/backend/src/server/api/integration/DiscordServerService.ts index 1fd103797c..93c22a6c0b 100644 --- a/packages/backend/src/server/api/integration/DiscordServerService.ts +++ b/packages/backend/src/server/api/integration/DiscordServerService.ts @@ -1,9 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import Router from '@koa/router'; import { OAuth2 } from 'oauth'; import { v4 as uuid } from 'uuid'; import { IsNull } from 'typeorm'; +import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { Config } from '@/config.js'; import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; @@ -12,8 +12,8 @@ import type { ILocalUser } from '@/models/entities/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MetaService } from '@/core/MetaService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { SigninService } from '../SigninService.js'; -import type Koa from 'koa'; @Injectable() export class DiscordServerService { @@ -36,21 +36,18 @@ export class DiscordServerService { private metaService: MetaService, private signinService: SigninService, ) { + this.create = this.create.bind(this); } - public create() { - const router = new Router(); - - router.get('/disconnect/discord', async ctx => { - if (!this.compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; + public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.get('/disconnect/discord', async (request, reply) => { + if (!this.compareOrigin(request)) { + throw new FastifyReplyError(400, 'invalid origin'); } - const userToken = this.getUserToken(ctx); + const userToken = this.getUserToken(request); if (!userToken) { - ctx.throw(400, 'signin required'); - return; + throw new FastifyReplyError(400, 'signin required'); } const user = await this.usersRepository.findOneByOrFail({ @@ -66,13 +63,13 @@ export class DiscordServerService { integrations: profile.integrations, }); - ctx.body = 'Discordの連携を解除しました :v:'; - // Publish i updated event this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { detail: true, includeSecrets: true, })); + + return 'Discordの連携を解除しました :v:'; }); const getOAuth2 = async () => { @@ -90,16 +87,14 @@ export class DiscordServerService { } }; - router.get('/connect/discord', async ctx => { - if (!this.compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; + fastify.get('/connect/discord', async (request, reply) => { + if (!this.compareOrigin(request)) { + throw new FastifyReplyError(400, 'invalid origin'); } - const userToken = this.getUserToken(ctx); + const userToken = this.getUserToken(request); if (!userToken) { - ctx.throw(400, 'signin required'); - return; + throw new FastifyReplyError(400, 'signin required'); } const params = { @@ -112,10 +107,10 @@ export class DiscordServerService { this.redisClient.set(userToken, JSON.stringify(params)); const oauth2 = await getOAuth2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); + reply.redirect(oauth2!.getAuthorizeUrl(params)); }); - router.get('/signin/discord', async ctx => { + fastify.get('/signin/discord', async (request, reply) => { const sessid = uuid(); const params = { @@ -125,7 +120,7 @@ export class DiscordServerService { response_type: 'code', }; - ctx.cookies.set('signin_with_discord_sid', sessid, { + reply.cookies.set('signin_with_discord_sid', sessid, { path: '/', secure: this.config.url.startsWith('https'), httpOnly: true, @@ -134,27 +129,25 @@ export class DiscordServerService { this.redisClient.set(sessid, JSON.stringify(params)); const oauth2 = await getOAuth2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); + reply.redirect(oauth2!.getAuthorizeUrl(params)); }); - router.get('/dc/cb', async ctx => { - const userToken = this.getUserToken(ctx); + fastify.get('/dc/cb', async (request, reply) => { + const userToken = this.getUserToken(request); const oauth2 = await getOAuth2(); if (!userToken) { - const sessid = ctx.cookies.get('signin_with_discord_sid'); + const sessid = request.cookies.get('signin_with_discord_sid'); if (!sessid) { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } - const code = ctx.query.code; + const code = request.query.code; if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const { redirect_uri, state } = await new Promise<any>((res, rej) => { @@ -164,9 +157,8 @@ export class DiscordServerService { }); }); - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; + if (request.query.state !== state) { + throw new FastifyReplyError(400, 'invalid session'); } const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) => @@ -192,8 +184,7 @@ export class DiscordServerService { })) as Record<string, unknown>; if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const profile = await this.userProfilesRepository.createQueryBuilder() @@ -202,8 +193,7 @@ export class DiscordServerService { .getOne(); if (profile == null) { - ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`); - return; + throw new FastifyReplyError(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`); } await this.userProfilesRepository.update(profile.userId, { @@ -220,13 +210,12 @@ export class DiscordServerService { }, }); - this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: profile.userId }) as ILocalUser, true); + return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: profile.userId }) as ILocalUser, true); } else { - const code = ctx.query.code; + const code = request.query.code; if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const { redirect_uri, state } = await new Promise<any>((res, rej) => { @@ -236,9 +225,8 @@ export class DiscordServerService { }); }); - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; + if (request.query.state !== state) { + throw new FastifyReplyError(400, 'invalid session'); } const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) => @@ -263,8 +251,7 @@ export class DiscordServerService { 'Authorization': `Bearer ${accessToken}`, })) as Record<string, unknown>; if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const user = await this.usersRepository.findOneByOrFail({ @@ -288,29 +275,29 @@ export class DiscordServerService { }, }); - ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; - // Publish i updated event this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { detail: true, includeSecrets: true, })); + + return `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; } }); - return router; + done(); } - private getUserToken(ctx: Koa.BaseContext): string | null { - return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; + private getUserToken(request: FastifyRequest): string | null { + return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; } - private compareOrigin(ctx: Koa.BaseContext): boolean { + private compareOrigin(request: FastifyRequest): boolean { function normalizeUrl(url?: string): string { return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; } - const referer = ctx.headers['referer']; + const referer = request.headers['referer']; return (normalizeUrl(referer) === normalizeUrl(this.config.url)); } diff --git a/packages/backend/src/server/api/integration/GithubServerService.ts b/packages/backend/src/server/api/integration/GithubServerService.ts index 98d6230749..2fd20bf831 100644 --- a/packages/backend/src/server/api/integration/GithubServerService.ts +++ b/packages/backend/src/server/api/integration/GithubServerService.ts @@ -1,9 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import Router from '@koa/router'; import { OAuth2 } from 'oauth'; import { v4 as uuid } from 'uuid'; import { IsNull } from 'typeorm'; +import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { Config } from '@/config.js'; import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; @@ -12,8 +12,8 @@ import type { ILocalUser } from '@/models/entities/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MetaService } from '@/core/MetaService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { SigninService } from '../SigninService.js'; -import type Koa from 'koa'; @Injectable() export class GithubServerService { @@ -36,21 +36,18 @@ export class GithubServerService { private metaService: MetaService, private signinService: SigninService, ) { + this.create = this.create.bind(this); } - public create() { - const router = new Router(); - - router.get('/disconnect/github', async ctx => { - if (!this.compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; + public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.get('/disconnect/github', async (request, reply) => { + if (!this.compareOrigin(request)) { + throw new FastifyReplyError(400, 'invalid origin'); } - const userToken = this.getUserToken(ctx); + const userToken = this.getUserToken(request); if (!userToken) { - ctx.throw(400, 'signin required'); - return; + throw new FastifyReplyError(400, 'signin required'); } const user = await this.usersRepository.findOneByOrFail({ @@ -66,13 +63,13 @@ export class GithubServerService { integrations: profile.integrations, }); - ctx.body = 'GitHubの連携を解除しました :v:'; - // Publish i updated event this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { detail: true, includeSecrets: true, })); + + return 'GitHubの連携を解除しました :v:'; }); const getOath2 = async () => { @@ -90,16 +87,14 @@ export class GithubServerService { } }; - router.get('/connect/github', async ctx => { - if (!this.compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; + fastify.get('/connect/github', async (request, reply) => { + if (!this.compareOrigin(request)) { + throw new FastifyReplyError(400, 'invalid origin'); } - const userToken = this.getUserToken(ctx); + const userToken = this.getUserToken(request); if (!userToken) { - ctx.throw(400, 'signin required'); - return; + throw new FastifyReplyError(400, 'signin required'); } const params = { @@ -111,10 +106,10 @@ export class GithubServerService { this.redisClient.set(userToken, JSON.stringify(params)); const oauth2 = await getOath2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); + reply.redirect(oauth2!.getAuthorizeUrl(params)); }); - router.get('/signin/github', async ctx => { + fastify.get('/signin/github', async (request, reply) => { const sessid = uuid(); const params = { @@ -123,7 +118,7 @@ export class GithubServerService { state: uuid(), }; - ctx.cookies.set('signin_with_github_sid', sessid, { + reply.cookies.set('signin_with_github_sid', sessid, { path: '/', secure: this.config.url.startsWith('https'), httpOnly: true, @@ -132,27 +127,25 @@ export class GithubServerService { this.redisClient.set(sessid, JSON.stringify(params)); const oauth2 = await getOath2(); - ctx.redirect(oauth2!.getAuthorizeUrl(params)); + reply.redirect(oauth2!.getAuthorizeUrl(params)); }); - router.get('/gh/cb', async ctx => { - const userToken = this.getUserToken(ctx); + fastify.get('/gh/cb', async (request, reply) => { + const userToken = this.getUserToken(request); const oauth2 = await getOath2(); if (!userToken) { - const sessid = ctx.cookies.get('signin_with_github_sid'); + const sessid = request.cookies.get('signin_with_github_sid'); if (!sessid) { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } - const code = ctx.query.code; + const code = request.query.code; if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const { redirect_uri, state } = await new Promise<any>((res, rej) => { @@ -162,9 +155,8 @@ export class GithubServerService { }); }); - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; + if (request.query.state !== state) { + throw new FastifyReplyError(400, 'invalid session'); } const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) => @@ -184,8 +176,7 @@ export class GithubServerService { 'Authorization': `bearer ${accessToken}`, })) as Record<string, unknown>; if (typeof login !== 'string' || typeof id !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const link = await this.userProfilesRepository.createQueryBuilder() @@ -194,17 +185,15 @@ export class GithubServerService { .getOne(); if (link == null) { - ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); - return; + throw new FastifyReplyError(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); } - this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true); + return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true); } else { - const code = ctx.query.code; + const code = request.query.code; if (!code || typeof code !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const { redirect_uri, state } = await new Promise<any>((res, rej) => { @@ -214,9 +203,8 @@ export class GithubServerService { }); }); - if (ctx.query.state !== state) { - ctx.throw(400, 'invalid session'); - return; + if (request.query.state !== state) { + throw new FastifyReplyError(400, 'invalid session'); } const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) => @@ -238,8 +226,7 @@ export class GithubServerService { })) as Record<string, unknown>; if (typeof login !== 'string' || typeof id !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const user = await this.usersRepository.findOneByOrFail({ @@ -260,29 +247,29 @@ export class GithubServerService { }, }); - ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; - // Publish i updated event this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { detail: true, includeSecrets: true, })); + + return `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; } }); - return router; + done(); } - private getUserToken(ctx: Koa.BaseContext): string | null { - return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; + private getUserToken(request: FastifyRequest): string | null { + return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; } - private compareOrigin(ctx: Koa.BaseContext): boolean { + private compareOrigin(request: FastifyRequest): boolean { function normalizeUrl(url?: string): string { return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; } - const referer = ctx.headers['referer']; + const referer = request.headers['referer']; return (normalizeUrl(referer) === normalizeUrl(this.config.url)); } diff --git a/packages/backend/src/server/api/integration/TwitterServerService.ts b/packages/backend/src/server/api/integration/TwitterServerService.ts index 57c977bc54..a8447f9d49 100644 --- a/packages/backend/src/server/api/integration/TwitterServerService.ts +++ b/packages/backend/src/server/api/integration/TwitterServerService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import Router from '@koa/router'; +import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import { v4 as uuid } from 'uuid'; import { IsNull } from 'typeorm'; import autwh from 'autwh'; @@ -12,8 +12,8 @@ import type { ILocalUser } from '@/models/entities/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MetaService } from '@/core/MetaService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { SigninService } from '../SigninService.js'; -import type Koa from 'koa'; @Injectable() export class TwitterServerService { @@ -36,21 +36,18 @@ export class TwitterServerService { private metaService: MetaService, private signinService: SigninService, ) { + this.create = this.create.bind(this); } - public create() { - const router = new Router(); - - router.get('/disconnect/twitter', async ctx => { - if (!this.compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; + public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.get('/disconnect/twitter', async (request, reply) => { + if (!this.compareOrigin(request)) { + throw new FastifyReplyError(400, 'invalid origin'); } - const userToken = this.getUserToken(ctx); + const userToken = this.getUserToken(request); if (userToken == null) { - ctx.throw(400, 'signin required'); - return; + throw new FastifyReplyError(400, 'signin required'); } const user = await this.usersRepository.findOneByOrFail({ @@ -66,13 +63,13 @@ export class TwitterServerService { integrations: profile.integrations, }); - ctx.body = 'Twitterの連携を解除しました :v:'; - // Publish i updated event this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { detail: true, includeSecrets: true, })); + + return 'Twitterの連携を解除しました :v:'; }); const getTwAuth = async () => { @@ -89,25 +86,23 @@ export class TwitterServerService { } }; - router.get('/connect/twitter', async ctx => { - if (!this.compareOrigin(ctx)) { - ctx.throw(400, 'invalid origin'); - return; + fastify.get('/connect/twitter', async (request, reply) => { + if (!this.compareOrigin(request)) { + throw new FastifyReplyError(400, 'invalid origin'); } - const userToken = this.getUserToken(ctx); + const userToken = this.getUserToken(request); if (userToken == null) { - ctx.throw(400, 'signin required'); - return; + throw new FastifyReplyError(400, 'signin required'); } const twAuth = await getTwAuth(); const twCtx = await twAuth!.begin(); this.redisClient.set(userToken, JSON.stringify(twCtx)); - ctx.redirect(twCtx.url); + reply.redirect(twCtx.url); }); - router.get('/signin/twitter', async ctx => { + fastify.get('/signin/twitter', async (request, reply) => { const twAuth = await getTwAuth(); const twCtx = await twAuth!.begin(); @@ -115,26 +110,25 @@ export class TwitterServerService { this.redisClient.set(sessid, JSON.stringify(twCtx)); - ctx.cookies.set('signin_with_twitter_sid', sessid, { + reply.cookies.set('signin_with_twitter_sid', sessid, { path: '/', secure: this.config.url.startsWith('https'), httpOnly: true, }); - ctx.redirect(twCtx.url); + reply.redirect(twCtx.url); }); - router.get('/tw/cb', async ctx => { - const userToken = this.getUserToken(ctx); + fastify.get('/tw/cb', async (request, reply) => { + const userToken = this.getUserToken(request); const twAuth = await getTwAuth(); if (userToken == null) { - const sessid = ctx.cookies.get('signin_with_twitter_sid'); + const sessid = request.cookies.get('signin_with_twitter_sid'); if (sessid == null) { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const get = new Promise<any>((res, rej) => { @@ -145,10 +139,9 @@ export class TwitterServerService { const twCtx = await get; - const verifier = ctx.query.oauth_verifier; + const verifier = request.query.oauth_verifier; if (!verifier || typeof verifier !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const result = await twAuth!.done(JSON.parse(twCtx), verifier); @@ -159,17 +152,15 @@ export class TwitterServerService { .getOne(); if (link == null) { - ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); - return; + throw new FastifyReplyError(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); } - this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true); + return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true); } else { - const verifier = ctx.query.oauth_verifier; + const verifier = request.query.oauth_verifier; if (!verifier || typeof verifier !== 'string') { - ctx.throw(400, 'invalid session'); - return; + throw new FastifyReplyError(400, 'invalid session'); } const get = new Promise<any>((res, rej) => { @@ -201,29 +192,29 @@ export class TwitterServerService { }, }); - ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; - // Publish i updated event this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, { detail: true, includeSecrets: true, })); + + return `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; } }); - return router; + done(); } - private getUserToken(ctx: Koa.BaseContext): string | null { - return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; + private getUserToken(request: FastifyRequest): string | null { + return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1]; } - private compareOrigin(ctx: Koa.BaseContext): boolean { + private compareOrigin(request: FastifyRequest): boolean { function normalizeUrl(url?: string): string { return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; } - const referer = ctx.headers['referer']; + const referer = request.headers['referer']; return (normalizeUrl(referer) === normalizeUrl(this.config.url)); } |