diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-03 19:42:05 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-12-03 19:42:05 +0900 |
| commit | 3a7182bfb5734599321fc03ea77c48b4dbc326d5 (patch) | |
| tree | c96c46e0a9662809c40381d833e1ed1ca28de873 /packages/backend | |
| parent | Update CHANGELOG.md (diff) | |
| download | sharkey-3a7182bfb5734599321fc03ea77c48b4dbc326d5.tar.gz sharkey-3a7182bfb5734599321fc03ea77c48b4dbc326d5.tar.bz2 sharkey-3a7182bfb5734599321fc03ea77c48b4dbc326d5.zip | |
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
Diffstat (limited to 'packages/backend')
30 files changed, 1017 insertions, 1032 deletions
diff --git a/packages/backend/package.json b/packages/backend/package.json index 9e50d4d9fa..81e7888b84 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -21,20 +21,19 @@ "@tensorflow/tfjs-node": "4.1.0" }, "dependencies": { - "@bull-board/api": "4.3.1", - "@bull-board/koa": "4.3.1", - "@bull-board/ui": "4.3.1", "@discordapp/twemoji": "14.0.2", - "@elastic/elasticsearch": "7.17.0", - "@koa/cors": "3.3.0", - "@koa/multer": "3.0.0", - "@koa/router": "9.0.1", + "@fastify/accepts": "4.0.1", + "@fastify/cors": "8.2.0", + "@fastify/multipart": "7.3.0", + "@fastify/static": "6.5.0", + "@fastify/view": "7.1.2", "@nestjs/common": "9.2.0", "@nestjs/core": "9.2.0", "@nestjs/testing": "9.2.0", "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "10.0.0", "@syuilo/aiscript": "0.11.1", + "accepts": "^1.3.8", "ajv": "8.11.2", "archiver": "5.3.1", "autobind-decorator": "2.4.0", @@ -54,6 +53,7 @@ "date-fns": "2.29.3", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", + "fastify": "4.10.0", "feed": "4.2.2", "file-type": "18.0.0", "fluent-ffmpeg": "2.1.2", @@ -69,20 +69,10 @@ "json5-loader": "4.0.1", "jsonld": "8.1.0", "jsrsasign": "10.6.1", - "koa": "2.13.4", - "koa-bodyparser": "4.3.0", - "koa-favicon": "2.1.0", - "koa-json-body": "5.3.0", - "koa-logger": "3.2.1", - "koa-mount": "4.0.0", - "koa-send": "5.0.1", - "koa-slow": "2.1.0", - "koa-views": "7.0.2", "mfm-js": "0.23.0", "mime-types": "2.1.35", "misskey-js": "0.0.14", "ms": "3.0.0-canary.1", - "multer": "1.4.4", "nested-property": "4.0.0", "node-fetch": "3.3.0", "nodemailer": "6.8.0", @@ -129,6 +119,7 @@ "ulid": "2.3.0", "unzipper": "0.10.11", "uuid": "9.0.0", + "vary": "1.1.2", "web-push": "3.5.0", "websocket": "1.0.34", "ws": "8.11.0", @@ -138,6 +129,7 @@ "@redocly/openapi-core": "1.0.0-beta.114", "@swc/core": "1.3.20", "@swc/jest": "0.2.23", + "@types/accepts": "1.3.5", "@types/archiver": "5.3.1", "@types/bcryptjs": "2.4.2", "@types/bull": "4.10.0", @@ -149,17 +141,6 @@ "@types/jsdom": "20.0.1", "@types/jsonld": "1.5.8", "@types/jsrsasign": "10.5.4", - "@types/koa": "2.13.5", - "@types/koa-bodyparser": "4.3.8", - "@types/koa-cors": "0.0.2", - "@types/koa-favicon": "2.0.21", - "@types/koa-logger": "3.1.2", - "@types/koa-mount": "4.0.1", - "@types/koa-send": "4.1.3", - "@types/koa-views": "7.0.0", - "@types/koa__cors": "3.3.0", - "@types/koa__multer": "2.0.4", - "@types/koa__router": "8.0.11", "@types/mime-types": "2.1.1", "@types/node": "18.11.9", "@types/node-fetch": "3.0.3", @@ -182,6 +163,7 @@ "@types/tmp": "0.2.3", "@types/unzipper": "0.10.5", "@types/uuid": "8.3.4", + "@types/vary": "1.1.0", "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.3", diff --git a/packages/backend/src/@types/koa-json-body.d.ts b/packages/backend/src/@types/koa-json-body.d.ts deleted file mode 100644 index 2971807d15..0000000000 --- a/packages/backend/src/@types/koa-json-body.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare module 'koa-json-body' { - import type { Middleware } from 'koa'; - - interface IKoaJsonBodyOptions { - strict: boolean; - limit: string; - fallback: boolean; - } - - function koaJsonBody(opt?: IKoaJsonBodyOptions): Middleware; - - namespace koaJsonBody {} // Hack - - export = koaJsonBody; -} diff --git a/packages/backend/src/@types/koa-slow.d.ts b/packages/backend/src/@types/koa-slow.d.ts deleted file mode 100644 index d048822efe..0000000000 --- a/packages/backend/src/@types/koa-slow.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare module 'koa-slow' { - import type { Middleware } from 'koa'; - - interface ISlowOptions { - url?: RegExp; - delay?: number; - } - - function slow(options?: ISlowOptions): Middleware; - - namespace slow {} // Hack - - export = slow; -} diff --git a/packages/backend/src/boot/index.ts b/packages/backend/src/boot/index.ts index fbf9e73e09..f4daf30690 100644 --- a/packages/backend/src/boot/index.ts +++ b/packages/backend/src/boot/index.ts @@ -52,6 +52,7 @@ if (!envOption.quiet) { process.on('uncaughtException', err => { try { logger.error(err); + console.trace(err); } catch { } }); diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index acfa7d5910..b2bc24ac2c 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -45,9 +45,13 @@ export class CaptchaService { return await res.json() as CaptchaResponse; } - public async verifyRecaptcha(secret: string, response: string): Promise<void> { - const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => { - throw `recaptcha-request-failed: ${e}`; + public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> { + if (response == null) { + throw 'recaptcha-failed: no response provided'; + } + + const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { + throw `recaptcha-request-failed: ${err}`; }); if (result.success !== true) { @@ -56,9 +60,13 @@ export class CaptchaService { } } - public async verifyHcaptcha(secret: string, response: string): Promise<void> { - const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => { - throw `hcaptcha-request-failed: ${e}`; + public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> { + if (response == null) { + throw 'hcaptcha-failed: no response provided'; + } + + const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { + throw `hcaptcha-request-failed: ${err}`; }); if (result.success !== true) { @@ -67,9 +75,13 @@ export class CaptchaService { } } - public async verifyTurnstile(secret: string, response: string): Promise<void> { - const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(e => { - throw `turnstile-request-failed: ${e}`; + public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> { + if (response == null) { + throw 'turnstile-failed: no response provided'; + } + + const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { + throw `turnstile-request-failed: ${err}`; }); if (result.success !== true) { diff --git a/packages/backend/src/core/remote/activitypub/ApRendererService.ts b/packages/backend/src/core/remote/activitypub/ApRendererService.ts index 38850fd127..38a92567c3 100644 --- a/packages/backend/src/core/remote/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/remote/activitypub/ApRendererService.ts @@ -674,7 +674,7 @@ export class ApRendererService { * @param last URL of last page (optional) * @param orderedItems attached objects (optional) */ - public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: Record<string, unknown>[]) { + public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: IObject[]) { const page: any = { id, type: 'OrderedCollection', diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index 1b678edc44..d7c8304b47 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -6,7 +6,6 @@ const envOption = { verbose: false, withLogTime: false, quiet: false, - slow: false, }; for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts index fa88769de0..429977669e 100644 --- a/packages/backend/src/misc/create-temp.ts +++ b/packages/backend/src/misc/create-temp.ts @@ -18,7 +18,7 @@ export function createTempDir(): Promise<[string, () => void]> { (e, path, cleanup) => { if (e) return rej(e); res([path, cleanup]); - } + }, ); }); } diff --git a/packages/backend/src/misc/fastify-reply-error.ts b/packages/backend/src/misc/fastify-reply-error.ts new file mode 100644 index 0000000000..4e987175e2 --- /dev/null +++ b/packages/backend/src/misc/fastify-reply-error.ts @@ -0,0 +1,11 @@ +// https://www.fastify.io/docs/latest/Reference/Reply/#async-await-and-promises +export class FastifyReplyError extends Error { + public message: string; + public statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.message = message; + this.statusCode = statusCode; + } +} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 2e7bd4dcb2..9689a623fd 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -1,8 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; -import Router from '@koa/router'; -import json from 'koa-json-body'; +import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; +import fastifyAccepts from '@fastify/accepts'; import httpSignature from '@peertube/http-signature'; import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; +import accepts from 'accepts'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; import * as url from '@/misc/prelude/url.js'; @@ -56,14 +57,15 @@ export class ActivityPubServerService { private userKeypairStoreService: UserKeypairStoreService, private queryService: QueryService, ) { + this.createServer = this.createServer.bind(this); } - private setResponseType(ctx: Router.RouterContext) { - const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); + private setResponseType(request: FastifyRequest, reply: FastifyReply): void { + const accept = request.accepts().type([ACTIVITY_JSON, LD_JSON]); if (accept === LD_JSON) { - ctx.response.type = LD_JSON; + reply.type(LD_JSON); } else { - ctx.response.type = ACTIVITY_JSON; + reply.type(ACTIVITY_JSON); } } @@ -80,31 +82,34 @@ export class ActivityPubServerService { return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); } - private inbox(ctx: Router.RouterContext) { + private inbox(request: FastifyRequest, reply: FastifyReply) { let signature; try { - signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); + signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); } catch (e) { - ctx.status = 401; + reply.code(401); return; } - this.queueService.inbox(ctx.request.body, signature); + this.queueService.inbox(request.body, signature); - ctx.status = 202; + reply.code(202); } - private async followers(ctx: Router.RouterContext) { - const userId = ctx.params.user; + private async followers( + request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, + reply: FastifyReply, + ) { + const userId = request.params.user; - const cursor = ctx.request.query.cursor; + const cursor = request.query.cursor; if (cursor != null && typeof cursor !== 'string') { - ctx.status = 400; + reply.code(400); return; } - const page = ctx.request.query.page === 'true'; + const page = request.query.page === 'true'; const user = await this.usersRepository.findOneBy({ id: userId, @@ -112,7 +117,7 @@ export class ActivityPubServerService { }); if (user == null) { - ctx.status = 404; + reply.code(404); return; } @@ -120,12 +125,12 @@ export class ActivityPubServerService { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.ffVisibility === 'private') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); return; } else if (profile.ffVisibility === 'followers') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); return; } //#endregion @@ -168,27 +173,30 @@ export class ActivityPubServerService { })}` : undefined, ); - ctx.body = this.apRendererService.renderActivity(rendered); - this.setResponseType(ctx); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); } else { // index page const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); - ctx.body = this.apRendererService.renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); } } - private async following(ctx: Router.RouterContext) { - const userId = ctx.params.user; + private async following( + request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, + reply: FastifyReply, + ) { + const userId = request.params.user; - const cursor = ctx.request.query.cursor; + const cursor = request.query.cursor; if (cursor != null && typeof cursor !== 'string') { - ctx.status = 400; + reply.code(400); return; } - const page = ctx.request.query.page === 'true'; + const page = request.query.page === 'true'; const user = await this.usersRepository.findOneBy({ id: userId, @@ -196,7 +204,7 @@ export class ActivityPubServerService { }); if (user == null) { - ctx.status = 404; + reply.code(404); return; } @@ -204,12 +212,12 @@ export class ActivityPubServerService { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.ffVisibility === 'private') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); return; } else if (profile.ffVisibility === 'followers') { - ctx.status = 403; - ctx.set('Cache-Control', 'public, max-age=30'); + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); return; } //#endregion @@ -252,19 +260,19 @@ export class ActivityPubServerService { })}` : undefined, ); - ctx.body = this.apRendererService.renderActivity(rendered); - this.setResponseType(ctx); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); } else { // index page const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); - ctx.body = this.apRendererService.renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); } } - private async featured(ctx: Router.RouterContext) { - const userId = ctx.params.user; + private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ id: userId, @@ -272,7 +280,7 @@ export class ActivityPubServerService { }); if (user == null) { - ctx.status = 404; + reply.code(404); return; } @@ -291,30 +299,36 @@ export class ActivityPubServerService { renderedNotes.length, undefined, undefined, renderedNotes, ); - ctx.body = this.apRendererService.renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); } - private async outbox(ctx: Router.RouterContext) { - const userId = ctx.params.user; + private async outbox( + request: FastifyRequest<{ + Params: { user: string; }; + Querystring: { since_id?: string; until_id?: string; page?: string; }; + }>, + reply: FastifyReply, + ) { + const userId = request.params.user; - const sinceId = ctx.request.query.since_id; + const sinceId = request.query.since_id; if (sinceId != null && typeof sinceId !== 'string') { - ctx.status = 400; + reply.code(400); return; } - const untilId = ctx.request.query.until_id; + const untilId = request.query.until_id; if (untilId != null && typeof untilId !== 'string') { - ctx.status = 400; + reply.code(400); return; } - const page = ctx.request.query.page === 'true'; + const page = request.query.page === 'true'; if (countIf(x => x != null, [sinceId, untilId]) > 1) { - ctx.status = 400; + reply.code(400); return; } @@ -324,7 +338,7 @@ export class ActivityPubServerService { }); if (user == null) { - ctx.status = 404; + reply.code(404); return; } @@ -362,110 +376,130 @@ export class ActivityPubServerService { })}` : undefined, ); - ctx.body = this.apRendererService.renderActivity(rendered); - this.setResponseType(ctx); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); } else { // index page const rendered = this.apRendererService.renderOrderedCollection(partOf, user.notesCount, `${partOf}?page=true`, `${partOf}?page=true&since_id=000000000000000000000000`, ); - ctx.body = this.apRendererService.renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); } } - private async userInfo(ctx: Router.RouterContext, user: User | null) { + private async userInfo(request: FastifyRequest, reply: FastifyReply, user: User | null) { if (user == null) { - ctx.status = 404; + reply.code(404); return; } - ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderPerson(user as ILocalUser)); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderPerson(user as ILocalUser))); } - public createRouter() { - // Init router - const router = new Router(); + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.addConstraintStrategy({ + name: 'apOrHtml', + storage() { + const store = {}; + return { + get(key) { + return store[key] ?? null; + }, + set(key, value) { + store[key] = value; + }, + }; + }, + deriveConstraint(request, ctx) { + const accepted = accepts(request).type(['html', ACTIVITY_JSON, LD_JSON]); + const isAp = typeof accepted === 'string' && !accepted.match(/html/); + return isAp ? 'ap' : 'html'; + }, + }); - //#region Routing - function isActivityPubReq(ctx: Router.RouterContext) { - ctx.response.vary('Accept'); - const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); - return typeof accepted === 'string' && !accepted.match(/html/); - } + fastify.register(fastifyAccepts); + //#region Routing // inbox - router.post('/inbox', json(), ctx => this.inbox(ctx)); - router.post('/users/:user/inbox', json(), ctx => this.inbox(ctx)); + fastify.post('/inbox', async (request, reply) => await this.inbox(request, reply)); + fastify.post('/users/:user/inbox', async (request, reply) => await this.inbox(request, reply)); // note - router.get('/notes/:note', async (ctx, next) => { - if (!isActivityPubReq(ctx)) return await next(); - + fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { const note = await this.notesRepository.findOneBy({ - id: ctx.params.note, + id: request.params.note, visibility: In(['public' as const, 'home' as const]), localOnly: false, }); if (note == null) { - ctx.status = 404; + reply.code(404); return; } // リモートだったらリダイレクト if (note.userHost != null) { if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) { - ctx.status = 500; + reply.code(500); return; } - ctx.redirect(note.uri); + reply.redirect(note.uri); return; } - ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderNote(note, false)); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderNote(note, false))); }); // note activity - router.get('/notes/:note/activity', async ctx => { + fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { const note = await this.notesRepository.findOneBy({ - id: ctx.params.note, + id: request.params.note, userHost: IsNull(), visibility: In(['public' as const, 'home' as const]), localOnly: false, }); if (note == null) { - ctx.status = 404; + reply.code(404); return; } - ctx.body = this.apRendererService.renderActivity(await this.packActivity(note)); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.packActivity(note))); }); // outbox - router.get('/users/:user/outbox', (ctx) => this.outbox(ctx)); + fastify.get<{ + Params: { user: string; }; + Querystring: { since_id?: string; until_id?: string; page?: string; }; + }>('/users/:user/outbox', async (request, reply) => await this.outbox(request, reply)); // followers - router.get('/users/:user/followers', (ctx) => this.followers(ctx)); + fastify.get<{ + Params: { user: string; }; + Querystring: { cursor?: string; page?: string; }; + }>('/users/:user/followers', async (request, reply) => await this.followers(request, reply)); // following - router.get('/users/:user/following', (ctx) => this.following(ctx)); + fastify.get<{ + Params: { user: string; }; + Querystring: { cursor?: string; page?: string; }; + }>('/users/:user/following', async (request, reply) => await this.following(request, reply)); // featured - router.get('/users/:user/collections/featured', (ctx) => this.featured(ctx)); + fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply)); // publickey - router.get('/users/:user/publickey', async ctx => { - const userId = ctx.params.user; + fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ id: userId, @@ -473,25 +507,23 @@ export class ActivityPubServerService { }); if (user == null) { - ctx.status = 404; + reply.code(404); return; } const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); if (this.userEntityService.isLocalUser(user)) { - ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderKey(user, keypair)); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(this.apRendererService.renderKey(user, keypair))); } else { - ctx.status = 400; + reply.code(400); } }); - router.get('/users/:user', async (ctx, next) => { - if (!isActivityPubReq(ctx)) return await next(); - - const userId = ctx.params.user; + fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ id: userId, @@ -499,86 +531,84 @@ export class ActivityPubServerService { isSuspended: false, }); - await this.userInfo(ctx, user); + return await this.userInfo(request, reply, user); }); - router.get('/@:user', async (ctx, next) => { - if (!isActivityPubReq(ctx)) return await next(); - + fastify.get<{ Params: { user: string; } }>('/@:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { const user = await this.usersRepository.findOneBy({ - usernameLower: ctx.params.user.toLowerCase(), + usernameLower: request.params.user.toLowerCase(), host: IsNull(), isSuspended: false, }); - await this.userInfo(ctx, user); + return await this.userInfo(request, reply, user); }); //#endregion // emoji - router.get('/emojis/:emoji', async ctx => { + fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { const emoji = await this.emojisRepository.findOneBy({ host: IsNull(), - name: ctx.params.emoji, + name: request.params.emoji, }); if (emoji == null) { - ctx.status = 404; + reply.code(404); return; } - ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderEmoji(emoji)); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderEmoji(emoji))); }); // like - router.get('/likes/:like', async ctx => { - const reaction = await this.noteReactionsRepository.findOneBy({ id: ctx.params.like }); + fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => { + const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); if (reaction == null) { - ctx.status = 404; + reply.code(404); return; } const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); if (note == null) { - ctx.status = 404; + reply.code(404); return; } - ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderLike(reaction, note)); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderLike(reaction, note))); }); // follow - router.get('/follows/:follower/:followee', async ctx => { + fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { // This may be used before the follow is completed, so we do not // check if the following exists. const [follower, followee] = await Promise.all([ this.usersRepository.findOneBy({ - id: ctx.params.follower, + id: request.params.follower, host: IsNull(), }), this.usersRepository.findOneBy({ - id: ctx.params.followee, + id: request.params.followee, host: Not(IsNull()), }), ]); if (follower == null || followee == null) { - ctx.status = 404; + reply.code(404); return; } - ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee)); - ctx.set('Cache-Control', 'public, max-age=180'); - this.setResponseType(ctx); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee))); }); - return router; + done(); } } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index dc073e34ac..088e780d69 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -2,10 +2,8 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; -import Koa from 'koa'; -import cors from '@koa/cors'; -import Router from '@koa/router'; -import send from 'koa-send'; +import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; +import fastifyStatic from '@fastify/static'; import rename from 'rename'; import type { Config } from '@/config.js'; import type { DriveFilesRepository } from '@/models/index.js'; @@ -46,45 +44,44 @@ export class FileServerService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray', false); + + this.createServer = this.createServer.bind(this); } - public commonReadableHandlerGenerator(ctx: Koa.Context) { - return (e: Error): void => { - this.logger.error(e); - ctx.status = 500; - ctx.set('Cache-Control', 'max-age=300'); + public commonReadableHandlerGenerator(reply: FastifyReply) { + return (err: Error): void => { + this.logger.error(err); + reply.code(500); + reply.header('Cache-Control', 'max-age=300'); }; } - public createServer() { - const app = new Koa(); - app.use(cors()); - app.use(async (ctx, next) => { - ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); - await next(); + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + done(); }); - // Init router - const router = new Router(); + fastify.register(fastifyStatic, { + root: _dirname, + serve: false, + }); - router.get('/app-default.jpg', ctx => { + fastify.get('/app-default.jpg', (request, reply) => { const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); - ctx.body = file; - ctx.set('Content-Type', 'image/jpeg'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Type', 'image/jpeg'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + return reply.send(file); }); - router.get('/:key', ctx => this.sendDriveFile(ctx)); - router.get('/:key/(.*)', ctx => this.sendDriveFile(ctx)); - - // Register router - app.use(router.routes()); + fastify.get<{ Params: { key: string; } }>('/:key', async (request, reply) => await this.sendDriveFile(request, reply)); + fastify.get<{ Params: { key: string; } }>('/:key/*', async (request, reply) => await this.sendDriveFile(request, reply)); - return app; + done(); } - private async sendDriveFile(ctx: Koa.Context) { - const key = ctx.params.key; + private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) { + const key = request.params.key; // Fetch drive file const file = await this.driveFilesRepository.createQueryBuilder('file') @@ -94,10 +91,9 @@ export class FileServerService { .getOne(); if (file == null) { - ctx.status = 404; - ctx.set('Cache-Control', 'max-age=86400'); - await send(ctx as any, '/dummy.png', { root: assets }); - return; + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', assets); } const isThumbnail = file.thumbnailAccessKey === key; @@ -135,18 +131,18 @@ export class FileServerService { }; const image = await convertFile(); - ctx.body = image.data; - ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + return image.data; } catch (err) { this.logger.error(`${err}`); if (err instanceof StatusError && err.isClientError) { - ctx.status = err.statusCode; - ctx.set('Cache-Control', 'max-age=86400'); + reply.code(err.statusCode); + reply.header('Cache-Control', 'max-age=86400'); } else { - ctx.status = 500; - ctx.set('Cache-Control', 'max-age=300'); + reply.code(500); + reply.header('Cache-Control', 'max-age=300'); } } finally { cleanup(); @@ -154,8 +150,8 @@ export class FileServerService { return; } - ctx.status = 204; - ctx.set('Cache-Control', 'max-age=86400'); + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); return; } @@ -166,18 +162,17 @@ export class FileServerService { extname: ext ? `.${ext}` : undefined, }).toString(); - ctx.body = this.internalStorageService.read(key); - ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); - ctx.set('Content-Disposition', contentDisposition('inline', filename)); + reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', filename)); + return this.internalStorageService.read(key); } else { const readable = this.internalStorageService.read(file.accessKey!); - readable.on('error', this.commonReadableHandlerGenerator(ctx)); - ctx.body = readable; - ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream'); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); - ctx.set('Content-Disposition', contentDisposition('inline', file.name)); + readable.on('error', this.commonReadableHandlerGenerator(reply)); + reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', file.name)); + return readable; } } } - diff --git a/packages/backend/src/server/MediaProxyServerService.ts b/packages/backend/src/server/MediaProxyServerService.ts index 31841d39df..4d7bbdf599 100644 --- a/packages/backend/src/server/MediaProxyServerService.ts +++ b/packages/backend/src/server/MediaProxyServerService.ts @@ -1,8 +1,6 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; -import Koa from 'koa'; -import cors from '@koa/cors'; -import Router from '@koa/router'; +import { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify'; import sharp from 'sharp'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -31,32 +29,29 @@ export class MediaProxyServerService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray', false); + + this.createServer = this.createServer.bind(this); } - public createServer() { - const app = new Koa(); - app.use(cors()); - app.use(async (ctx, next) => { - ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); - await next(); + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + done(); }); - // Init router - const router = new Router(); - - router.get('/:url*', ctx => this.handler(ctx)); - - // Register router - app.use(router.routes()); + fastify.get<{ + Params: { url: string; }; + Querystring: { url?: string; }; + }>('/:url*', async (request, reply) => await this.handler(request, reply)); - return app; + done(); } - private async handler(ctx: Koa.Context) { - const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; + private async handler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { + const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; if (typeof url !== 'string') { - ctx.status = 400; + reply.code(400); return; } @@ -71,11 +66,11 @@ export class MediaProxyServerService { let image: IImage; - if ('static' in ctx.query && isConvertibleImage) { + if ('static' in request.query && isConvertibleImage) { image = await this.imageProcessingService.convertToWebp(path, 498, 280); - } else if ('preview' in ctx.query && isConvertibleImage) { + } else if ('preview' in request.query && isConvertibleImage) { image = await this.imageProcessingService.convertToWebp(path, 200, 200); - } else if ('badge' in ctx.query) { + } else if ('badge' in request.query) { if (!isConvertibleImage) { // 画像でないなら404でお茶を濁す throw new StatusError('Unexpected mime', 404); @@ -122,16 +117,16 @@ export class MediaProxyServerService { }; } - ctx.set('Content-Type', image.type); - ctx.set('Cache-Control', 'max-age=31536000, immutable'); - ctx.body = image.data; + reply.header('Content-Type', image.type); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + return image.data; } catch (err) { this.logger.error(`${err}`); if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { - ctx.status = err.statusCode; + reply.code(err.statusCode); } else { - ctx.status = 500; + reply.code(500); } } finally { cleanup(); diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index ef4ec74a35..b85925f53e 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Router from '@koa/router'; +import { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify'; import { IsNull, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UsersRepository } from '@/models/index.js'; @@ -27,6 +27,7 @@ export class NodeinfoServerService { private userEntityService: UserEntityService, private metaService: MetaService, ) { + this.createServer = this.createServer.bind(this); } public getLinks() { @@ -39,9 +40,7 @@ export class NodeinfoServerService { }]; } - public createRouter() { - const router = new Router(); - + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { const nodeinfo2 = async () => { const now = Date.now(); const [ @@ -108,22 +107,22 @@ export class NodeinfoServerService { const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); - router.get(nodeinfo2_1path, async ctx => { + fastify.get(nodeinfo2_1path, async (request, reply) => { const base = await cache.fetch(null, () => nodeinfo2()); - ctx.body = { version: '2.1', ...base }; - ctx.set('Cache-Control', 'public, max-age=600'); + reply.header('Cache-Control', 'public, max-age=600'); + return { version: '2.1', ...base }; }); - router.get(nodeinfo2_0path, async ctx => { + fastify.get(nodeinfo2_0path, async (request, reply) => { const base = await cache.fetch(null, () => nodeinfo2()); delete (base as any).software.repository; - ctx.body = { version: '2.0', ...base }; - ctx.set('Cache-Control', 'public, max-age=600'); + reply.header('Cache-Control', 'public, max-age=600'); + return { version: '2.0', ...base }; }); - return router; + done(); } } diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index d42972614f..96159cfc53 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -2,11 +2,7 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; import * as http from 'node:http'; import { Inject, Injectable } from '@nestjs/common'; -import Koa from 'koa'; -import Router from '@koa/router'; -import mount from 'koa-mount'; -import koaLogger from 'koa-logger'; -import * as slow from 'koa-slow'; +import Fastify from 'fastify'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; @@ -58,47 +54,29 @@ export class ServerService { } public launch() { - // Init app - const koa = new Koa(); - koa.proxy = true; - - if (!['production', 'test'].includes(process.env.NODE_ENV ?? '')) { - // Logger - koa.use(koaLogger(str => { - this.logger.info(str); - })); - - // Delay - if (envOption.slow) { - koa.use(slow({ - delay: 3000, - })); - } - } + const fastify = Fastify({ + trustProxy: true, + logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''), + }); // HSTS // 6months (15552000sec) if (this.config.url.startsWith('https') && !this.config.disableHsts) { - koa.use(async (ctx, next) => { - ctx.set('strict-transport-security', 'max-age=15552000; preload'); - await next(); + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('strict-transport-security', 'max-age=15552000; preload'); + done(); }); } - koa.use(mount('/api', this.apiServerService.createApiServer(koa))); - koa.use(mount('/files', this.fileServerService.createServer())); - koa.use(mount('/proxy', this.mediaProxyServerService.createServer())); + fastify.register(this.apiServerService.createServer, { prefix: '/api' }); + fastify.register(this.fileServerService.createServer, { prefix: '/files' }); + fastify.register(this.mediaProxyServerService.createServer, { prefix: '/proxy' }); + fastify.register(this.activityPubServerService.createServer); + fastify.register(this.nodeinfoServerService.createServer); + fastify.register(this.wellKnownServerService.createServer); - // Init router - const router = new Router(); - - // Routing - router.use(this.activityPubServerService.createRouter().routes()); - router.use(this.nodeinfoServerService.createRouter().routes()); - router.use(this.wellKnownServerService.createRouter().routes()); - - router.get('/avatar/@:acct', async ctx => { - const { username, host } = Acct.parse(ctx.params.acct); + fastify.get<{ Params: { acct: string } }>('/avatar/@:acct', async (request, reply) => { + const { username, host } = Acct.parse(request.params.acct); const user = await this.usersRepository.findOne({ where: { usernameLower: username.toLowerCase(), @@ -109,28 +87,25 @@ export class ServerService { }); if (user) { - ctx.redirect(this.userEntityService.getAvatarUrlSync(user)); + reply.redirect(this.userEntityService.getAvatarUrlSync(user)); } else { - ctx.redirect('/static-assets/user-unknown.png'); + reply.redirect('/static-assets/user-unknown.png'); } }); - router.get('/identicon/:x', async ctx => { + fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => { const [temp, cleanup] = await createTemp(); - await genIdenticon(ctx.params.x, fs.createWriteStream(temp)); - ctx.set('Content-Type', 'image/png'); - ctx.body = fs.createReadStream(temp).on('close', () => cleanup()); + await genIdenticon(request.params.x, fs.createWriteStream(temp)); + reply.header('Content-Type', 'image/png'); + return fs.createReadStream(temp).on('close', () => cleanup()); }); - router.get('/verify-email/:code', async ctx => { + fastify.get<{ Params: { code: string } }>('/verify-email/:code', async (request, reply) => { const profile = await this.userProfilesRepository.findOneBy({ - emailVerifyCode: ctx.params.code, + emailVerifyCode: request.params.code, }); if (profile != null) { - ctx.body = 'Verify succeeded!'; - ctx.status = 200; - await this.userProfilesRepository.update({ userId: profile.userId }, { emailVerified: true, emailVerifyCode: null, @@ -140,21 +115,19 @@ export class ServerService { detail: true, includeSecrets: true, })); + + reply.code(200); + return 'Verify succeeded!'; } else { - ctx.status = 404; + reply.code(404); } }); - // Register router - koa.use(router.routes()); - - koa.use(mount(this.clientServerService.createApp())); - - const server = http.createServer(koa.callback()); + fastify.register(this.clientServerService.createServer); - this.streamingApiServerService.attachStreamingApi(server); + this.streamingApiServerService.attachStreamingApi(fastify.server); - server.on('error', err => { + fastify.server.on('error', err => { switch ((err as any).code) { case 'EACCES': this.logger.error(`You do not have permission to listen on port ${this.config.port}.`); @@ -168,13 +141,13 @@ export class ServerService { } if (cluster.isWorker) { - process.send!('listenFailed'); + process.send!('listenFailed'); } else { - // disableClustering + // disableClustering process.exit(1); } }); - server.listen(this.config.port); + fastify.listen({ port: this.config.port }); } } diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index f2eee88e09..412c608313 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import Router from '@koa/router'; +import { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify'; import { IsNull, MoreThan } from 'typeorm'; +import vary from 'vary'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -21,11 +22,10 @@ export class WellKnownServerService { private nodeinfoServerService: NodeinfoServerService, ) { + this.createServer = this.createServer.bind(this); } - public createRouter() { - const router = new Router(); - + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { const XRD = (...x: { element: string, value?: string, attributes?: Record<string, string> }[]) => `<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">${x.map(({ element, value, attributes }) => `<${ @@ -34,37 +34,35 @@ export class WellKnownServerService { typeof value === 'string' ? `>${escapeValue(value)}</${element}` : '/' }>`).reduce((a, c) => a + c, '')}</XRD>`; - const allPath = '/.well-known/(.*)'; + const allPath = '/.well-known/*'; const webFingerPath = '/.well-known/webfinger'; const jrd = 'application/jrd+json'; const xrd = 'application/xrd+xml'; - router.use(allPath, async (ctx, next) => { - ctx.set({ - 'Access-Control-Allow-Headers': 'Accept', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Expose-Headers': 'Vary', - }); - await next(); + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('Access-Control-Allow-Headers', 'Accept'); + reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Expose-Headers', 'Vary'); + done(); }); - router.options(allPath, async ctx => { - ctx.status = 204; + fastify.options(allPath, async (request, reply) => { + reply.code(204); }); - router.get('/.well-known/host-meta', async ctx => { - ctx.set('Content-Type', xrd); - ctx.body = XRD({ element: 'Link', attributes: { + fastify.get('/.well-known/host-meta', async (request, reply) => { + reply.header('Content-Type', xrd); + return XRD({ element: 'Link', attributes: { rel: 'lrdd', type: xrd, template: `${this.config.url}${webFingerPath}?resource={uri}`, } }); }); - router.get('/.well-known/host-meta.json', async ctx => { - ctx.set('Content-Type', jrd); - ctx.body = { + fastify.get('/.well-known/host-meta.json', async (request, reply) => { + reply.header('Content-Type', jrd); + return { links: [{ rel: 'lrdd', type: jrd, @@ -73,16 +71,16 @@ export class WellKnownServerService { }; }); - router.get('/.well-known/nodeinfo', async ctx => { - ctx.body = { links: this.nodeinfoServerService.getLinks() }; + fastify.get('/.well-known/nodeinfo', async (request, reply) => { + return { links: this.nodeinfoServerService.getLinks() }; }); /* TODO -router.get('/.well-known/change-password', async ctx => { +fastify.get('/.well-known/change-password', async (request, reply) => { }); */ - router.get(webFingerPath, async ctx => { + fastify.get<{ Querystring: { resource: string } }>(webFingerPath, async (request, reply) => { const fromId = (id: User['id']): FindOptionsWhere<User> => ({ id, host: IsNull(), @@ -104,22 +102,22 @@ router.get('/.well-known/change-password', async ctx => { isSuspended: false, } : 422; - if (typeof ctx.query.resource !== 'string') { - ctx.status = 400; + if (typeof request.query.resource !== 'string') { + reply.code(400); return; } - const query = generateQuery(ctx.query.resource.toLowerCase()); + const query = generateQuery(request.query.resource.toLowerCase()); if (typeof query === 'number') { - ctx.status = query; + reply.code(query); return; } const user = await this.usersRepository.findOneBy(query); if (user == null) { - ctx.status = 404; + reply.code(404); return; } @@ -139,30 +137,25 @@ router.get('/.well-known/change-password', async ctx => { template: `${this.config.url}/authorize-follow?acct={uri}`, }; - if (ctx.accepts(jrd, xrd) === xrd) { - ctx.body = XRD( + vary(reply.raw, 'Accept'); + reply.header('Cache-Control', 'public, max-age=180'); + + if (request.accepts().type([jrd, xrd]) === xrd) { + reply.type(xrd); + return XRD( { element: 'Subject', value: subject }, { element: 'Link', attributes: self }, { element: 'Link', attributes: profilePage }, { element: 'Link', attributes: subscribe }); - ctx.type = xrd; } else { - ctx.body = { + reply.type(jrd); + return { subject, links: [self, profilePage, subscribe], }; - ctx.type = jrd; } - - ctx.vary('Accept'); - ctx.set('Cache-Control', 'public, max-age=180'); - }); - - // Return 404 for other .well-known - router.all(allPath, async ctx => { - ctx.status = 404; }); - return router; + done(); } } 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)); } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 8957a91309..4c3f2bfd36 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -3,16 +3,12 @@ import { fileURLToPath } from 'node:url'; import { PathOrFileDescriptor, readFileSync } from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import Koa from 'koa'; -import Router from '@koa/router'; -import send from 'koa-send'; -import favicon from 'koa-favicon'; -import views from 'koa-views'; import sharp from 'sharp'; -import { createBullBoard } from '@bull-board/api'; -import { BullAdapter } from '@bull-board/api/bullAdapter.js'; -import { KoaAdapter } from '@bull-board/koa'; +import pug from 'pug'; import { In, IsNull } from 'typeorm'; +import { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fastifyView from '@fastify/view'; import type { Config } from '@/config.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import { DI } from '@/di-symbols.js'; @@ -84,9 +80,10 @@ export class ClientServerService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) { + this.createServer = this.createServer.bind(this); } - private async manifestHandler(ctx: Koa.Context) { + private async manifestHandler(reply: FastifyReply) { const res = deepClone(manifest); const instance = await this.metaService.fetch(true); @@ -95,27 +92,26 @@ export class ClientServerService { res.name = instance.name ?? 'Misskey'; if (instance.themeColor) res.theme_color = instance.themeColor; - ctx.set('Cache-Control', 'max-age=300'); - ctx.body = res; + reply.header('Cache-Control', 'max-age=300'); + return (res); } - public createApp() { - const app = new Koa(); - + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + /* TODO //#region Bull Dashboard const bullBoardPath = '/queue'; // Authenticate - app.use(async (ctx, next) => { + app.use(async (request, reply) => { if (ctx.path === bullBoardPath || ctx.path.startsWith(bullBoardPath + '/')) { const token = ctx.cookies.get('token'); if (token == null) { - ctx.status = 401; + reply.code(401); return; } const user = await this.usersRepository.findOneBy({ token }); if (user == null || !(user.isAdmin || user.isModerator)) { - ctx.status = 403; + reply.code(403); return; } } @@ -140,83 +136,84 @@ export class ClientServerService { serverAdapter.setBasePath(bullBoardPath); app.use(serverAdapter.registerPlugin()); //#endregion + */ - // Init renderer - app.use(views(_dirname + '/views', { - extension: 'pug', - options: { + fastify.register(fastifyView, { + root: _dirname + '/views', + engine: { + pug: pug, + }, + defaultContext: { version: this.config.version, getClientEntry: () => process.env.NODE_ENV === 'production' ? this.config.clientEntry : JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'], config: this.config, }, - })); - - // Serve favicon - app.use(favicon(`${_dirname}/../../../assets/favicon.ico`)); - - // Common request handler - app.use(async (ctx, next) => { - // IFrameの中に入れられないようにする - ctx.set('X-Frame-Options', 'DENY'); - await next(); }); - // Init router - const router = new Router(); + fastify.addHook('onRequest', (request, reply, done) => { + // クリックジャッキング防止のためiFrameの中に入れられないようにする + reply.header('X-Frame-Options', 'DENY'); + done(); + }); //#region static assets - router.get('/static-assets/(.*)', async ctx => { - await send(ctx as any, ctx.path.replace('/static-assets/', ''), { - root: staticAssets, - maxage: ms('7 days'), - }); + fastify.register(fastifyStatic, { + root: _dirname, + serve: false, }); - router.get('/client-assets/(.*)', async ctx => { - await send(ctx as any, ctx.path.replace('/client-assets/', ''), { - root: clientAssets, - maxage: ms('7 days'), - }); + fastify.register(fastifyStatic, { + root: staticAssets, + prefix: '/static-assets/', + maxAge: ms('7 days'), + decorateReply: false, }); - router.get('/assets/(.*)', async ctx => { - await send(ctx as any, ctx.path.replace('/assets/', ''), { - root: assets, - maxage: ms('7 days'), - }); + fastify.register(fastifyStatic, { + root: clientAssets, + prefix: '/client-assets/', + maxAge: ms('7 days'), + decorateReply: false, }); - // Apple touch icon - router.get('/apple-touch-icon.png', async ctx => { - await send(ctx as any, '/apple-touch-icon.png', { - root: staticAssets, - }); + fastify.register(fastifyStatic, { + root: assets, + prefix: '/assets/', + maxAge: ms('7 days'), + decorateReply: false, + }); + + fastify.get('/favicon.ico', async (request, reply) => { + return reply.sendFile('/favicon.ico', staticAssets); + }); + + fastify.get('/apple-touch-icon.png', async (request, reply) => { + return reply.sendFile('/apple-touch-icon.png', staticAssets); }); - router.get('/twemoji/(.*)', async ctx => { - const path = ctx.path.replace('/twemoji/', ''); + fastify.get<{ Params: { path: string } }>('/twemoji/:path(.*)', async (request, reply) => { + const path = request.params.path; if (!path.match(/^[0-9a-f-]+\.svg$/)) { - ctx.status = 404; + reply.code(404); return; } - ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - await send(ctx as any, path, { - root: `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, - maxage: ms('30 days'), + return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, { + maxAge: ms('30 days'), }); }); - router.get('/twemoji-badge/(.*)', async ctx => { - const path = ctx.path.replace('/twemoji-badge/', ''); + fastify.get<{ Params: { path: string } }>('/twemoji-badge/:path(.*)', async (request, reply) => { + const path = request.params.path; if (!path.match(/^[0-9a-f-]+\.png$/)) { - ctx.status = 404; + reply.code(404); return; } @@ -249,44 +246,43 @@ export class ClientServerService { .png() .toBuffer(); - ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - ctx.set('Cache-Control', 'max-age=2592000'); - ctx.set('Content-Type', 'image/png'); - ctx.body = buffer; + reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + reply.header('Cache-Control', 'max-age=2592000'); + reply.header('Content-Type', 'image/png'); + return buffer; }); // ServiceWorker - router.get('/sw.js', async ctx => { - await send(ctx as any, '/sw.js', { - root: swAssets, - maxage: ms('10 minutes'), + fastify.get('/sw.js', async (request, reply) => { + return await reply.sendFile('/sw.js', swAssets, { + maxAge: ms('10 minutes'), }); }); // Manifest - router.get('/manifest.json', ctx => this.manifestHandler(ctx)); + fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply)); - router.get('/robots.txt', async ctx => { - await send(ctx as any, '/robots.txt', { - root: staticAssets, - }); + fastify.get('/robots.txt', async (request, reply) => { + return await reply.sendFile('/robots.txt', staticAssets); }); //#endregion - // Docs - router.get('/api-doc', async ctx => { - await send(ctx as any, '/redoc.html', { - root: staticAssets, + const renderBase = async (reply: FastifyReply) => { + const meta = await this.metaService.fetch(); + reply.header('Cache-Control', 'public, max-age=15'); + return await reply.view('base', { + img: meta.bannerUrl, + title: meta.name ?? 'Misskey', + instanceName: meta.name ?? 'Misskey', + desc: meta.description, + icon: meta.iconUrl, + themeColor: meta.themeColor, }); - }); + }; // URL preview endpoint - router.get('/url', ctx => this.urlPreviewService.handle(ctx)); - - router.get('/api.json', async ctx => { - ctx.body = genOpenapiSpec(); - }); + fastify.get<{ Querystring: { url: string; lang: string; } }>('/url', (request, reply) => this.urlPreviewService.handle(request, reply)); const getFeed = async (acct: string) => { const { username, host } = Acct.parse(acct); @@ -300,45 +296,45 @@ export class ClientServerService { }; // Atom - router.get('/@:user.atom', async ctx => { - const feed = await getFeed(ctx.params.user); + fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => { + const feed = await getFeed(request.params.user); if (feed) { - ctx.set('Content-Type', 'application/atom+xml; charset=utf-8'); - ctx.body = feed.atom1(); + reply.header('Content-Type', 'application/atom+xml; charset=utf-8'); + return feed.atom1(); } else { - ctx.status = 404; + reply.code(404); } }); // RSS - router.get('/@:user.rss', async ctx => { - const feed = await getFeed(ctx.params.user); + fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => { + const feed = await getFeed(request.params.user); if (feed) { - ctx.set('Content-Type', 'application/rss+xml; charset=utf-8'); - ctx.body = feed.rss2(); + reply.header('Content-Type', 'application/rss+xml; charset=utf-8'); + return feed.rss2(); } else { - ctx.status = 404; + reply.code(404); } }); // JSON - router.get('/@:user.json', async ctx => { - const feed = await getFeed(ctx.params.user); + fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => { + const feed = await getFeed(request.params.user); if (feed) { - ctx.set('Content-Type', 'application/json; charset=utf-8'); - ctx.body = feed.json1(); + reply.header('Content-Type', 'application/json; charset=utf-8'); + return feed.json1(); } else { - ctx.status = 404; + reply.code(404); } }); //#region SSR (for crawlers) // User - router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { - const { username, host } = Acct.parse(ctx.params.user); + fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => { + const { username, host } = Acct.parse(request.params.user); const user = await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: host ?? IsNull(), @@ -354,41 +350,41 @@ export class ClientServerService { .map(field => field.value) : []; - await ctx.render('user', { + reply.header('Cache-Control', 'public, max-age=15'); + return await reply.view('user', { user, profile, me, avatarUrl: await this.userEntityService.getAvatarUrl(user), - sub: ctx.params.sub, + sub: request.params.sub, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, }); - ctx.set('Cache-Control', 'public, max-age=15'); } else { // リモートユーザーなので // モデレータがAPI経由で参照可能にするために404にはしない - await next(); + return await renderBase(reply); } }); - router.get('/users/:user', async ctx => { + fastify.get<{ Params: { user: string; } }>('/users/:user', async (request, reply) => { const user = await this.usersRepository.findOneBy({ - id: ctx.params.user, + id: request.params.user, host: IsNull(), isSuspended: false, }); if (user == null) { - ctx.status = 404; + reply.code(404); return; } - ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); + reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); }); // Note - router.get('/notes/:note', async (ctx, next) => { + fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => { const note = await this.notesRepository.findOneBy({ - id: ctx.params.note, + id: request.params.note, visibility: In(['public', 'home']), }); @@ -396,7 +392,8 @@ export class ClientServerService { const _note = await this.noteEntityService.pack(note); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); const meta = await this.metaService.fetch(); - await ctx.render('note', { + reply.header('Cache-Control', 'public, max-age=15'); + return await reply.view('note', { note: _note, profile, avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })), @@ -406,18 +403,14 @@ export class ClientServerService { icon: meta.iconUrl, themeColor: meta.themeColor, }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; + } else { + return await renderBase(reply); } - - await next(); }); // Page - router.get('/@:user/pages/:page', async (ctx, next) => { - const { username, host } = Acct.parse(ctx.params.user); + fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => { + const { username, host } = Acct.parse(request.params.user); const user = await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: host ?? IsNull(), @@ -426,7 +419,7 @@ export class ClientServerService { if (user == null) return; const page = await this.pagesRepository.findOneBy({ - name: ctx.params.page, + name: request.params.page, userId: user.id, }); @@ -434,7 +427,12 @@ export class ClientServerService { const _page = await this.pageEntityService.pack(page); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId }); const meta = await this.metaService.fetch(); - await ctx.render('page', { + if (['public'].includes(page.visibility)) { + reply.header('Cache-Control', 'public, max-age=15'); + } else { + reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); + } + return await reply.view('page', { page: _page, profile, avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })), @@ -442,31 +440,24 @@ export class ClientServerService { icon: meta.iconUrl, themeColor: meta.themeColor, }); - - if (['public'].includes(page.visibility)) { - ctx.set('Cache-Control', 'public, max-age=15'); - } else { - ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); - } - - return; + } else { + return await renderBase(reply); } - - await next(); }); // Clip // TODO: 非publicなclipのハンドリング - router.get('/clips/:clip', async (ctx, next) => { + fastify.get<{ Params: { clip: string; } }>('/clips/:clip', async (request, reply) => { const clip = await this.clipsRepository.findOneBy({ - id: ctx.params.clip, + id: request.params.clip, }); if (clip) { const _clip = await this.clipEntityService.pack(clip); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId }); const meta = await this.metaService.fetch(); - await ctx.render('clip', { + reply.header('Cache-Control', 'public, max-age=15'); + return await reply.view('clip', { clip: _clip, profile, avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })), @@ -474,24 +465,21 @@ export class ClientServerService { icon: meta.iconUrl, themeColor: meta.themeColor, }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; + } else { + return await renderBase(reply); } - - await next(); }); // Gallery post - router.get('/gallery/:post', async (ctx, next) => { - const post = await this.galleryPostsRepository.findOneBy({ id: ctx.params.post }); + fastify.get<{ Params: { post: string; } }>('/gallery/:post', async (request, reply) => { + const post = await this.galleryPostsRepository.findOneBy({ id: request.params.post }); if (post) { const _post = await this.galleryPostEntityService.pack(post); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId }); const meta = await this.metaService.fetch(); - await ctx.render('gallery-post', { + reply.header('Cache-Control', 'public, max-age=15'); + return await reply.view('gallery-post', { post: _post, profile, avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })), @@ -499,46 +487,39 @@ export class ClientServerService { icon: meta.iconUrl, themeColor: meta.themeColor, }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; + } else { + return await renderBase(reply); } - - await next(); }); // Channel - router.get('/channels/:channel', async (ctx, next) => { + fastify.get<{ Params: { channel: string; } }>('/channels/:channel', async (request, reply) => { const channel = await this.channelsRepository.findOneBy({ - id: ctx.params.channel, + id: request.params.channel, }); if (channel) { const _channel = await this.channelEntityService.pack(channel); const meta = await this.metaService.fetch(); - await ctx.render('channel', { + reply.header('Cache-Control', 'public, max-age=15'); + return await reply.view('channel', { channel: _channel, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, }); - - ctx.set('Cache-Control', 'public, max-age=15'); - - return; + } else { + return await renderBase(reply); } - - await next(); }); //#endregion - router.get('/_info_card_', async ctx => { + fastify.get('/_info_card_', async (request, reply) => { const meta = await this.metaService.fetch(true); - ctx.remove('X-Frame-Options'); + reply.removeHeader('X-Frame-Options'); - await ctx.render('info-card', { + return await reply.view('info-card', { version: this.config.version, host: this.config.host, meta: meta, @@ -547,14 +528,14 @@ export class ClientServerService { }); }); - router.get('/bios', async ctx => { - await ctx.render('bios', { + fastify.get('/bios', async (request, reply) => { + return await reply.view('bios', { version: this.config.version, }); }); - router.get('/cli', async ctx => { - await ctx.render('cli', { + fastify.get('/cli', async (request, reply) => { + return await reply.view('cli', { version: this.config.version, }); }); @@ -562,33 +543,21 @@ export class ClientServerService { const override = (source: string, target: string, depth = 0) => [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); - router.get('/flush', async ctx => { - await ctx.render('flush'); + fastify.get('/flush', async (request, reply) => { + return await reply.view('flush'); }); // streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる - router.get('/streaming', async ctx => { - ctx.status = 503; - ctx.set('Cache-Control', 'private, max-age=0'); + fastify.get('/streaming', async (request, reply) => { + reply.code(503); + reply.header('Cache-Control', 'private, max-age=0'); }); // Render base html for all requests - router.get('(.*)', async ctx => { - const meta = await this.metaService.fetch(); - await ctx.render('base', { - img: meta.bannerUrl, - title: meta.name ?? 'Misskey', - instanceName: meta.name ?? 'Misskey', - desc: meta.description, - icon: meta.iconUrl, - themeColor: meta.themeColor, - }); - ctx.set('Cache-Control', 'public, max-age=15'); + fastify.get('*', async (request, reply) => { + return await renderBase(reply); }); - // Register router - app.use(router.routes()); - - return app; + done(); } } diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index f5dddd2db7..69f52cc2f2 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import summaly from 'summaly'; +import { FastifyRequest, FastifyReply } from 'fastify'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -8,7 +9,6 @@ import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; -import type Koa from 'koa'; @Injectable() export class UrlPreviewService { @@ -39,16 +39,19 @@ export class UrlPreviewService { : null; } - public async handle(ctx: Koa.Context) { - const url = ctx.query.url; + public async handle( + request: FastifyRequest<{ Querystring: { url: string; lang: string; } }>, + reply: FastifyReply, + ) { + const url = request.query.url; if (typeof url !== 'string') { - ctx.status = 400; + reply.code(400); return; } - const lang = ctx.query.lang; + const lang = request.query.lang; if (Array.isArray(lang)) { - ctx.status = 400; + reply.code(400); return; } @@ -73,14 +76,14 @@ export class UrlPreviewService { summary.thumbnail = this.wrap(summary.thumbnail); // Cache 7days - ctx.set('Cache-Control', 'max-age=604800, immutable'); + reply.header('Cache-Control', 'max-age=604800, immutable'); - ctx.body = summary; + return summary; } catch (err) { this.logger.warn(`Failed to get preview of ${url}: ${err}`); - ctx.status = 200; - ctx.set('Cache-Control', 'max-age=86400, immutable'); - ctx.body = '{}'; + reply.code(200); + reply.header('Cache-Control', 'max-age=86400, immutable'); + return {}; } } } diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js index d06dee801a..c2ce5c3814 100644 --- a/packages/backend/src/server/web/bios.js +++ b/packages/backend/src/server/web/bios.js @@ -10,7 +10,7 @@ window.onload = async () => { if (i) data.i = i; // Send request - fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { + window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { method: 'POST', body: JSON.stringify(data), credentials: 'omit', diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 2aef689d3f..ffd8b8941c 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -42,7 +42,7 @@ } } - const res = await fetch(`/assets/locales/${lang}.${v}.json`); + const res = await window.fetch(`/assets/locales/${lang}.${v}.json`); if (res.status === 200) { localStorage.setItem('lang', lang); localStorage.setItem('locale', await res.text()); @@ -290,9 +290,13 @@ // eslint-disable-next-line no-inner-declarations async function checkUpdate() { try { - const res = await fetch('/api/meta', { + const res = await window.fetch('/api/meta', { method: 'POST', - cache: 'no-cache' + cache: 'no-cache', + body: '{}', + headers: { + 'Content-Type': 'application/json', + }, }); const meta = await res.json(); |