Fastify (#9106)
* wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * Update SignupApiService.ts * wip * wip * Update ClientServerService.ts * wip * wip * wip * Update WellKnownServerService.ts * wip * wip * update des * wip * Update ApiServerService.ts * wip * update deps * Update WellKnownServerService.ts * wip * update deps * Update ApiCallService.ts * Update ApiCallService.ts * Update ApiServerService.ts
This commit is contained in:
		| @@ -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 { | ||||
|   | ||||
| @@ -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])); | ||||
| 			} 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.handleMultipartRequest(ep, request, reply); | ||||
| 				}); | ||||
| 			} else { | ||||
| 				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.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)); | ||||
|  | ||||
| 		router.use(this.discordServerService.create().routes()); | ||||
| 		router.use(this.githubServerService.create().routes()); | ||||
| 		router.use(this.twitterServerService.create().routes()); | ||||
| 		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)); | ||||
|  | ||||
| 		router.get('/v1/instance/peers', async ctx => { | ||||
| 		fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiServiceService.signupPending(request, reply)); | ||||
|  | ||||
| 		fastify.register(this.discordServerService.create); | ||||
| 		fastify.register(this.githubServerService.create); | ||||
| 		fastify.register(this.twitterServerService.create); | ||||
|  | ||||
| 		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(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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]; | ||||
| 		} | ||||
|   | ||||
| @@ -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 | ||||
| 	} | ||||
|   | ||||
| @@ -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)); | ||||
| 		})(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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({ | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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)); | ||||
| 	} | ||||
|   | ||||
| @@ -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)); | ||||
| 	} | ||||
|   | ||||
| @@ -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)); | ||||
| 	} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 syuilo
					syuilo