diff --git a/packages/backend/package.json b/packages/backend/package.json index 58b4df7158d..fe232d94594 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -53,12 +53,8 @@ "dependencies": { "@aws-sdk/client-s3": "3.1073.0", "@aws-sdk/lib-storage": "3.1073.0", - "@fastify/accepts": "5.0.4", - "@fastify/cors": "11.2.0", - "@fastify/http-proxy": "11.5.0", - "@fastify/multipart": "10.0.0", - "@fastify/static": "9.1.3", - "@kitajs/html": "4.2.13", + "@fastify/proxy-addr": "5.1.0", + "@hono/node-server": "2.0.6", "@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/emoji-data": "17.0.3", "@misskey-dev/sharp-read-bmp": "1.3.1", @@ -88,12 +84,11 @@ "content-disposition": "2.0.1", "date-fns": "4.4.0", "deep-email-validator": "0.1.27", - "fastify": "5.8.5", - "fastify-raw-body": "5.0.0", "feed": "5.2.1", "file-type": "22.0.1", "fluent-ffmpeg": "2.1.3", "got": "15.0.5", + "hono": "4.12.27", "hpagent": "1.2.0", "http-link-header": "1.1.3", "i18n": "workspace:*", @@ -141,13 +136,11 @@ "tsc-alias": "1.8.17", "typeorm": "1.0.0", "ulid": "3.0.2", - "vary": "1.1.2", "web-push": "3.6.7", "ws": "8.21.0", "xev": "3.0.2" }, "devDependencies": { - "@kitajs/ts-html-plugin": "4.1.4", "@nestjs/platform-express": "11.1.27", "@rollup/plugin-esm-shim": "0.1.8", "@sentry/vue": "10.59.0", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index d67fe6fc6fa..92e5a012262 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -6,11 +6,12 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; -import { type FastifyServerOptions } from 'fastify'; import type * as Sentry from '@sentry/node'; import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; +type TrustProxyOption = boolean | string | string[] | ((address: string, hop: number) => boolean); + type RedisOptionsSource = Partial & { host: string; port: number; @@ -27,7 +28,7 @@ type Source = { url?: string; port?: number; socket?: string; - trustProxy?: FastifyServerOptions['trustProxy']; + trustProxy?: TrustProxyOption; chmodSocket?: string; enableIpRateLimit?: boolean; disableHsts?: boolean; @@ -121,7 +122,7 @@ export type Config = { url: string; port: number; socket: string | undefined; - trustProxy: NonNullable; + trustProxy: NonNullable chmodSocket: string | undefined; enableIpRateLimit: boolean; disableHsts: boolean | undefined; diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index a2b74d1ab23..2df236aece1 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -8,6 +8,7 @@ import * as stream from 'node:stream/promises'; import { Inject, Injectable } from '@nestjs/common'; import chalk from 'chalk'; import got, * as Got from 'got'; +import type { StatusCode } from 'hono/utils/http-status'; import { parse } from 'content-disposition'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -100,7 +101,7 @@ export class DownloadService { await stream.pipeline(req, fs.createWriteStream(path)); } catch (e) { if (e instanceof Got.HTTPError) { - throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage); + throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode as StatusCode, e.response.statusMessage); } else { throw e; } diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 5714bde8bf1..c8e93a9337c 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -9,6 +9,7 @@ import * as net from 'node:net'; import * as stream from 'node:stream'; import ipaddr from 'ipaddr.js'; import CacheableLookup from 'cacheable-lookup'; +import type { StatusCode } from 'hono/utils/http-status'; import fetch from 'node-fetch'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { Inject, Injectable } from '@nestjs/common'; @@ -344,7 +345,7 @@ export class HttpRequestService { }); if (!res.ok && extra.throwErrorWhenResponseNotOk) { - throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); + throw new StatusError(`${res.status} ${res.statusText}`, res.status as StatusCode, res.statusText); } if (res.ok) { diff --git a/packages/backend/src/misc/fastify-hook-handlers.ts b/packages/backend/src/misc/fastify-hook-handlers.ts deleted file mode 100644 index fa3ef0a267d..00000000000 --- a/packages/backend/src/misc/fastify-hook-handlers.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { onRequestHookHandler } from 'fastify'; - -export const handleRequestRedirectToOmitSearch: onRequestHookHandler = (request, reply, done) => { - const index = request.url.indexOf('?'); - if (~index) { - reply.redirect(request.url.slice(0, index), 301); - } - done(); -}; diff --git a/packages/backend/src/misc/hono-middleware-handlers.ts b/packages/backend/src/misc/hono-middleware-handlers.ts new file mode 100644 index 00000000000..2da28d40a1c --- /dev/null +++ b/packages/backend/src/misc/hono-middleware-handlers.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createMiddleware } from 'hono/factory'; + +export const handleRequestRedirectToOmitSearch = createMiddleware(async (c, next) => { + if (c.req.url.includes('?')) { + return c.redirect(c.req.path, 301); + } + + await next(); + return; +}); diff --git a/packages/backend/src/misc/hono-vary.ts b/packages/backend/src/misc/hono-vary.ts new file mode 100644 index 00000000000..95c9bbbb38b --- /dev/null +++ b/packages/backend/src/misc/hono-vary.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Context as HonoContext } from 'hono'; + +export function vary(c: HonoContext, field: string) { + const varyHeader = c.res.headers.get('Vary'); + if (varyHeader != null) { + const fields = varyHeader.split(',').map((f) => f.trim()); + if (!fields.includes(field)) { + c.res.headers.set('Vary', `${varyHeader}, ${field}`); + } + } else { + c.res.headers.set('Vary', field); + } +} diff --git a/packages/backend/src/misc/fastify-reply-error.ts b/packages/backend/src/misc/http-status-error.ts similarity index 69% rename from packages/backend/src/misc/fastify-reply-error.ts rename to packages/backend/src/misc/http-status-error.ts index e6c4e78d2f0..441b1e7d491 100644 --- a/packages/backend/src/misc/fastify-reply-error.ts +++ b/packages/backend/src/misc/http-status-error.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// https://www.fastify.io/docs/latest/Reference/Reply/#async-await-and-promises -export class FastifyReplyError extends Error { +export class HttpStatusError extends Error { public message: string; public statusCode: number; diff --git a/packages/backend/src/misc/status-error.ts b/packages/backend/src/misc/status-error.ts index c3533db6075..19f5f9050f7 100644 --- a/packages/backend/src/misc/status-error.ts +++ b/packages/backend/src/misc/status-error.ts @@ -2,14 +2,15 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import type { StatusCode } from 'hono/utils/http-status'; export class StatusError extends Error { - public statusCode: number; + public statusCode: StatusCode; public statusMessage?: string; public isClientError: boolean; public isRetryable: boolean; - constructor(message: string, statusCode: number, statusMessage?: string) { + constructor(message: string, statusCode: StatusCode, statusMessage?: string) { super(message); this.name = 'StatusError'; this.statusCode = statusCode; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 5dbdf4014b3..c959ca07b84 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -4,13 +4,12 @@ */ import * as crypto from 'node:crypto'; -import { IncomingMessage } from 'node:http'; +import type { IncomingMessage } from 'node:http'; import { Inject, Injectable } from '@nestjs/common'; -import fastifyAccepts from '@fastify/accepts'; +import { Hono } from 'hono'; +import { accepts } from 'hono/accepts'; import httpSignature from '@peertube/http-signature'; import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; -import accepts from 'accepts'; -import vary from 'vary'; import secureJson from 'secure-json-parse'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; @@ -27,15 +26,16 @@ import { QueryService } from '@/core/QueryService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { IActivity } from '@/core/activitypub/type.js'; +import type { IActivity } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import * as Acct from '@/misc/acct.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; -import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; +import { vary } from '@/misc/hono-vary.js'; +import type { Context as HonoContext } from 'hono'; import type { FindOptionsWhere } from 'typeorm'; -const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; -const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; +const ACTIVITY_JSON = 'application/activity+json'; +const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'; @Injectable() export class ActivityPubServerService { @@ -82,15 +82,35 @@ export class ActivityPubServerService { } @bindThis - private setResponseType(request: FastifyRequest, reply: FastifyReply): void { - const accept = request.accepts().type([ACTIVITY_JSON, LD_JSON]); - if (accept === LD_JSON) { - reply.type(LD_JSON); + private getRawRequest(ctx: HonoContext): IncomingMessage { + const raw = (ctx.env as { incoming?: IncomingMessage }).incoming; + if (raw == null) { + throw new Error('IncomingMessage is not available in ActivityPubServerService'); + } + + return raw; + } + + @bindThis + private setResponseType(ctx: HonoContext): void { + const accept = accepts(ctx, { + header: 'Accept', + supports: ['application/activity+json', 'application/ld+json'], + default: 'application/activity+json', + }); + + if (accept === 'application/ld+json') { + ctx.header('Content-Type', LD_JSON); } else { - reply.type(ACTIVITY_JSON); + ctx.header('Content-Type', ACTIVITY_JSON); } } + @bindThis + private renderActivityPub(ctx: HonoContext, payload: unknown): Response { + return ctx.body(JSON.stringify(payload)); + } + /** * Pack Create or Announce Activity * @param note Note @@ -106,39 +126,45 @@ export class ActivityPubServerService { } @bindThis - private inbox(request: FastifyRequest, reply: FastifyReply) { + private wantsActivityPub(ctx: HonoContext): boolean { + return accepts(ctx, { + header: 'Accept', + supports: ['text/html', 'application/activity+json', 'application/ld+json'], + default: 'text/html', + }) !== 'text/html'; + } + + @bindThis + private inbox(ctx: HonoContext, body: IActivity, rawBody: Buffer): Response { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } + const request = this.getRawRequest(ctx); + let signature; try { - signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); + signature = httpSignature.parseRequest(request, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); } catch (_) { - reply.code(401); - return; + return ctx.body(null, 401); } if (signature.params.headers.indexOf('host') === -1 || request.headers.host !== this.config.host) { // Host not specified or not match. - reply.code(401); - return; + return ctx.body(null, 401); } if (signature.params.headers.indexOf('digest') === -1) { // Digest not found. - reply.code(401); - return; + return ctx.body(null, 401); } else { const digest = request.headers.digest; if (typeof digest !== 'string') { // Huh? - reply.code(401); - return; + return ctx.body(null, 401); } const re = /^([a-zA-Z0-9\-]+)=(.+)$/; @@ -146,8 +172,7 @@ export class ActivityPubServerService { if (match == null) { // Invalid digest - reply.code(401); - return; + return ctx.body(null, 401); } const algo = match[1].toUpperCase(); @@ -155,59 +180,38 @@ export class ActivityPubServerService { if (algo !== 'SHA-256') { // Unsupported digest algorithm - reply.code(401); - return; + return ctx.body(null, 401); } - if (request.rawBody == null) { - // Bad request - reply.code(400); - return; - } - - const hash = crypto.createHash('sha256').update(request.rawBody).digest('base64'); + const hash = crypto.createHash('sha256').update(rawBody).digest('base64'); if (hash !== digestValue) { // Invalid digest - reply.code(401); - return; + return ctx.body(null, 401); } } - const body = request.body; - // Reject structurally invalid activities (e.g. missing actor) here instead // of letting them fail deep inside the inbox processor. An actor-less // activity can never be authenticated, so there is no point enqueueing it. if (typeof body !== 'object' || body == null || !('actor' in body) || body.actor == null) { - reply.code(400); - return; + return ctx.body(null, 400); } this.queueService.inbox(body as IActivity, signature); - reply.code(202); + return ctx.body(null, 202); } @bindThis - private async followers( - request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, - reply: FastifyReply, - ) { + private async followers(ctx: HonoContext): Promise { if (this.meta.federation === 'none') { - reply.code(403); - return; - } - - const userId = request.params.user; - - const cursor = request.query.cursor; - if (cursor != null && typeof cursor !== 'string') { - reply.code(400); - return; + return ctx.body(null, 403); } - const page = request.query.page === 'true'; + const userId = ctx.req.param('user'); + const cursor = ctx.req.query('cursor'); + const page = ctx.req.query('page') === 'true'; const user = await this.usersRepository.findOneBy({ id: userId, @@ -215,21 +219,18 @@ export class ActivityPubServerService { }); if (user == null) { - reply.code(404); - return; + return ctx.body(null, 404); } //#region Check ff visibility const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.followersVisibility === 'private') { - reply.code(403); - reply.header('Cache-Control', 'public, max-age=30'); - return; + ctx.header('Cache-Control', 'public, max-age=30'); + return ctx.body(null, 403); } else if (profile.followersVisibility === 'followers') { - reply.code(403); - reply.header('Cache-Control', 'public, max-age=30'); - return; + ctx.header('Cache-Control', 'public, max-age=30'); + return ctx.body(null, 403); } //#endregion @@ -271,8 +272,8 @@ export class ActivityPubServerService { })}` : undefined, ); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(rendered)); } else { // index page const rendered = this.apRendererService.renderOrderedCollection( @@ -280,31 +281,21 @@ export class ActivityPubServerService { user.followersCount, `${partOf}?page=true`, ); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(rendered)); } } @bindThis - private async following( - request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, - reply: FastifyReply, - ) { + private async following(ctx: HonoContext): Promise { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } - const userId = request.params.user; - - const cursor = request.query.cursor; - if (cursor != null && typeof cursor !== 'string') { - reply.code(400); - return; - } - - const page = request.query.page === 'true'; + const userId = ctx.req.param('user'); + const cursor = ctx.req.query('cursor'); + const page = ctx.req.query('page') === 'true'; const user = await this.usersRepository.findOneBy({ id: userId, @@ -312,21 +303,18 @@ export class ActivityPubServerService { }); if (user == null) { - reply.code(404); - return; + return ctx.body(null, 404); } //#region Check ff visibility const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.followingVisibility === 'private') { - reply.code(403); - reply.header('Cache-Control', 'public, max-age=30'); - return; + ctx.header('Cache-Control', 'public, max-age=30'); + return ctx.body(null, 403); } else if (profile.followingVisibility === 'followers') { - reply.code(403); - reply.header('Cache-Control', 'public, max-age=30'); - return; + ctx.header('Cache-Control', 'public, max-age=30'); + return ctx.body(null, 403); } //#endregion @@ -368,8 +356,8 @@ export class ActivityPubServerService { })}` : undefined, ); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(rendered)); } else { // index page const rendered = this.apRendererService.renderOrderedCollection( @@ -377,20 +365,19 @@ export class ActivityPubServerService { user.followingCount, `${partOf}?page=true`, ); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(rendered)); } } @bindThis - private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { + private async featured(ctx: HonoContext): Promise { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } - const userId = request.params.user; + const userId = ctx.req.param('user'); const user = await this.usersRepository.findOneBy({ id: userId, @@ -398,8 +385,7 @@ export class ActivityPubServerService { }); if (user == null) { - reply.code(404); - return; + return ctx.body(null, 404); } const pinings = await this.userNotePiningsRepository.find({ @@ -421,43 +407,24 @@ export class ActivityPubServerService { renderedNotes, ); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(rendered)); } @bindThis - private async outbox( - request: FastifyRequest<{ - Params: { user: string; }; - Querystring: { since_id?: string; until_id?: string; page?: string; }; - }>, - reply: FastifyReply, - ) { + private async outbox(ctx: HonoContext): Promise { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } - const userId = request.params.user; - - const sinceId = request.query.since_id; - if (sinceId != null && typeof sinceId !== 'string') { - reply.code(400); - return; - } - - const untilId = request.query.until_id; - if (untilId != null && typeof untilId !== 'string') { - reply.code(400); - return; - } - - const page = request.query.page === 'true'; + const userId = ctx.req.param('user'); + const sinceId = ctx.req.query('since_id'); + const untilId = ctx.req.query('until_id'); + const page = ctx.req.query('page') === 'true'; if (countIf(x => x != null, [sinceId, untilId]) > 1) { - reply.code(400); - return; + return ctx.body(null, 400); } const user = await this.usersRepository.findOneBy({ @@ -466,8 +433,7 @@ export class ActivityPubServerService { }); if (user == null) { - reply.code(404); - return; + return ctx.body(null, 404); } const limit = 20; @@ -527,8 +493,8 @@ export class ActivityPubServerService { })}` : undefined, ); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(rendered)); } else { // index page const rendered = this.apRendererService.renderOrderedCollection( @@ -537,9 +503,9 @@ export class ActivityPubServerService { `${partOf}?page=true`, `${partOf}?page=true&since_id=000000000000000000000000`, ); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(rendered)); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(rendered)); } } @@ -563,362 +529,332 @@ export class ActivityPubServerService { } @bindThis - private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) { + private async userInfo(ctx: HonoContext, user: MiUser | null): Promise { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } if (user == null) { - reply.code(404); - return; + return ctx.body(null, 404); } // リモートだったらリダイレクト if (user.host != null) { if (user.uri == null || this.utilityService.isSelfHost(user.host)) { - reply.code(500); - return; + return ctx.body(null, 500); } - reply.redirect(user.uri, 301); - return; + return ctx.redirect(user.uri, 301); } - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser))); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser))); } @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.addConstraintStrategy({ - name: 'apOrHtml', - storage() { - const store = {} as any; - return { - get(key: string) { - return store[key] ?? null; - }, - set(key: string, value: any) { - store[key] = value; - }, - }; - }, - deriveConstraint(request: IncomingMessage) { - const accepted = accepts(request).type(['html', ACTIVITY_JSON, LD_JSON]); - if (accepted === false) return null; - return accepted !== 'html' ? 'ap' : 'html'; - }, - }); + private async parseActivityPubBody(ctx: HonoContext): Promise<{ body: IActivity; rawBody: Buffer } | Response> { + const rawBody = Buffer.from(await ctx.req.arrayBuffer()); + if (rawBody.length === 0) { + return ctx.body(null, 400); + } - const almostDefaultJsonParser: FastifyBodyParser = function (request, rawBody, done) { - if (rawBody.length === 0) { - const err = new Error('Body cannot be empty!') as any; - err.statusCode = 400; - return done(err); - } + if (rawBody.length > 1024 * 64) { + return ctx.body(null, 413); + } - try { - const json = secureJson.parse(rawBody.toString('utf8'), null, { - protoAction: 'ignore', - constructorAction: 'ignore', - }); - done(null, json); - } catch (err: any) { - err.statusCode = 400; - return done(err); - } - }; - - fastify.register(fastifyAccepts); - fastify.addContentTypeParser('application/activity+json', { parseAs: 'buffer' }, almostDefaultJsonParser); - fastify.addContentTypeParser('application/ld+json', { parseAs: 'buffer' }, almostDefaultJsonParser); - - 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(); - }); + try { + const body = secureJson.parse(rawBody.toString('utf8'), null, { + protoAction: 'ignore', + constructorAction: 'ignore', + }) as IActivity; + return { body, rawBody }; + } catch { + return ctx.body(null, 400); + } + } - //#region Routing - // inbox (limit: 64kb) - fastify.post('/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); - fastify.post('/users/:user/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); + @bindThis + private async note(ctx: HonoContext): Promise { + if (this.meta.federation === 'none') { + return ctx.body(null, 403); + } - // note - fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - vary(reply.raw, 'Accept'); + const note = await this.notesRepository.findOneBy({ + id: ctx.req.param('note'), + visibility: In(['public', 'home']), + localOnly: false, + }); - if (this.meta.federation === 'none') { - reply.code(403); - return; + if (note == null) { + return ctx.body(null, 404); + } + + if (note.userHost != null) { + if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) { + return ctx.body(null, 500); } - const note = await this.notesRepository.findOneBy({ - id: request.params.note, - visibility: In(['public', 'home']), - localOnly: false, - }); + return ctx.redirect(note.uri); + } - if (note == null) { - reply.code(404); - return; - } + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(await this.apRendererService.renderNote(note, false))); + } - // リモートだったらリダイレクト - if (note.userHost != null) { - if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) { - reply.code(500); - return; - } - reply.redirect(note.uri); - return; - } + @bindThis + private async noteActivity(ctx: HonoContext): Promise { + if (this.meta.federation === 'none') { + return ctx.body(null, 403); + } - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return this.apRendererService.addContext(await this.apRendererService.renderNote(note, false)); + const note = await this.notesRepository.findOneBy({ + id: ctx.req.param('note'), + userHost: IsNull(), + visibility: In(['public', 'home']), + localOnly: false, }); - // note activity - fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { - vary(reply.raw, 'Accept'); - - if (this.meta.federation === 'none') { - reply.code(403); - return; - } + if (note == null) { + return ctx.body(null, 404); + } - const note = await this.notesRepository.findOneBy({ - id: request.params.note, - userHost: IsNull(), - visibility: In(['public', 'home']), - localOnly: false, - }); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(await this.packActivity(note))); + } - if (note == null) { - reply.code(404); - return; - } + @bindThis + private async publicKey(ctx: HonoContext): Promise { + if (this.meta.federation === 'none') { + return ctx.body(null, 403); + } - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.packActivity(note))); + const user = await this.usersRepository.findOneBy({ + id: ctx.req.param('user'), + host: IsNull(), }); - // outbox - 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 - fastify.get<{ - Params: { user: string; }; - Querystring: { cursor?: string; page?: string; }; - }>('/users/:user/followers', async (request, reply) => await this.followers(request, reply)); - - // following - fastify.get<{ - Params: { user: string; }; - Querystring: { cursor?: string; page?: string; }; - }>('/users/:user/following', async (request, reply) => await this.following(request, reply)); - - // featured - fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply)); - - // publickey - fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { - if (this.meta.federation === 'none') { - reply.code(403); - return; - } - - const userId = request.params.user; + if (user == null) { + return ctx.body(null, 404); + } - const user = await this.usersRepository.findOneBy({ - id: userId, - host: IsNull(), - }); + if (!this.userEntityService.isLocalUser(user)) { + return ctx.body(null, 400); + } - if (user == null) { - reply.code(404); - return; - } + const keypair = await this.userKeypairService.getUserKeypair(user.id); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); + } - const keypair = await this.userKeypairService.getUserKeypair(user.id); + @bindThis + private async emoji(ctx: HonoContext): Promise { + if (this.meta.federation === 'none') { + return ctx.body(null, 403); + } - if (this.userEntityService.isLocalUser(user)) { - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); - } else { - reply.code(400); - return; - } + const emoji = await this.emojisRepository.findOneBy({ + host: IsNull(), + name: ctx.req.param('emoji'), }); - fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - vary(reply.raw, 'Accept'); + if (emoji == null || emoji.localOnly) { + return ctx.body(null, 404); + } - if (this.meta.federation === 'none') { - reply.code(403); - return; - } + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji))); + } - const userId = request.params.user; + @bindThis + private async like(ctx: HonoContext): Promise { + if (this.meta.federation === 'none') { + return ctx.body(null, 403); + } - const user = await this.usersRepository.findOneBy({ - id: userId, - isSuspended: false, - }); + const reaction = await this.noteReactionsRepository.findOneBy({ id: ctx.req.param('like') }); + if (reaction == null) { + return ctx.body(null, 404); + } - return await this.userInfo(request, reply, user); - }); + const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); + if (note == null) { + return ctx.body(null, 404); + } - fastify.get<{ Params: { acct: string; } }>('/@:acct', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - vary(reply.raw, 'Accept'); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, note))); + } - if (this.meta.federation === 'none') { - reply.code(403); - return; - } + @bindThis + private async follow(ctx: HonoContext): Promise { + if (this.meta.federation === 'none') { + return ctx.body(null, 403); + } - const acct = Acct.parse(request.params.acct); - // normalize acct host - if (this.utilityService.isSelfHost(acct.host)) acct.host = null; + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneBy({ + id: ctx.req.param('follower'), + host: IsNull(), + }), + this.usersRepository.findOneBy({ + id: ctx.req.param('followee'), + host: Not(IsNull()), + }), + ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; + + if (follower == null || followee == null) { + return ctx.body(null, 404); + } - const user = await this.usersRepository.findOneBy({ - usernameLower: acct.username.toLowerCase(), - host: acct.host ?? IsNull(), - isSuspended: false, - }); + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); + } - return await this.userInfo(request, reply, user); + @bindThis + private async followRequest(ctx: HonoContext): Promise { + if (this.meta.federation === 'none') { + return ctx.body(null, 403); + } + + const followRequest = await this.followRequestsRepository.findOneBy({ + id: ctx.req.param('followRequestId'), }); - //#endregion - // emoji - fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { - if (this.meta.federation === 'none') { - reply.code(403); - return; - } + if (followRequest == null) { + return ctx.body(null, 404); + } - const emoji = await this.emojisRepository.findOneBy({ + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneBy({ + id: followRequest.followerId, host: IsNull(), - name: request.params.emoji, - }); + }), + this.usersRepository.findOneBy({ + id: followRequest.followeeId, + host: Not(IsNull()), + }), + ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; + + if (follower == null || followee == null) { + return ctx.body(null, 404); + } - if (emoji == null || emoji.localOnly) { - reply.code(404); - return; - } + ctx.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(ctx); + return this.renderActivityPub(ctx, this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); + } + + public createServer(): Hono { + const hono = new Hono(); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji))); + hono.use(async (ctx, next) => { + ctx.header('Access-Control-Allow-Headers', 'Accept'); + ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + ctx.header('Access-Control-Allow-Origin', '*'); + ctx.header('Access-Control-Expose-Headers', 'Vary'); + await next(); }); - // like - fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => { - if (this.meta.federation === 'none') { - reply.code(403); - return; - } + hono.options('*', (ctx) => ctx.body(null, 204)); - const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); + hono.post('/inbox', async (ctx) => { + const parsed = await this.parseActivityPubBody(ctx); + if (parsed instanceof Response) return parsed; + return this.inbox(ctx, parsed.body, parsed.rawBody); + }); + + hono.post('/users/:user/inbox', async (ctx) => { + const parsed = await this.parseActivityPubBody(ctx); + if (parsed instanceof Response) return parsed; + return this.inbox(ctx, parsed.body, parsed.rawBody); + }); - if (reaction == null) { - reply.code(404); + hono.get('/notes/:note', async (ctx, next) => { + vary(ctx, 'Accept'); + if (!this.wantsActivityPub(ctx)) { + await next(); return; } - const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); + return await this.note(ctx); + }); - if (note == null) { - reply.code(404); - return; - } + hono.get('/notes/:note/activity', async (ctx) => { + vary(ctx, 'Accept'); + return await this.noteActivity(ctx); + }); + + hono.get('/users/:user/outbox', async (ctx) => await this.outbox(ctx)); + hono.get('/users/:user/followers', async (ctx) => await this.followers(ctx)); + hono.get('/users/:user/following', async (ctx) => await this.following(ctx)); + hono.get('/users/:user/collections/featured', async (ctx) => await this.featured(ctx)); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, note))); + hono.get('/users/:user/publickey', async (ctx) => { + return await this.publicKey(ctx); }); - // follow - fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { - if (this.meta.federation === 'none') { - reply.code(403); + hono.get('/users/:user', async (ctx, next) => { + vary(ctx, 'Accept'); + if (!this.wantsActivityPub(ctx)) { + await next(); return; } - // 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: request.params.follower, - host: IsNull(), - }), - this.usersRepository.findOneBy({ - id: request.params.followee, - host: Not(IsNull()), - }), - ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; - - if (follower == null || followee == null) { - reply.code(404); - return; - } + const user = await this.usersRepository.findOneBy({ + id: ctx.req.param('user'), + isSuspended: false, + }); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); + return await this.userInfo(ctx, user); }); - // follow - fastify.get<{ Params: { followRequestId: string; } }>('/follows/:followRequestId', async (request, reply) => { - if (this.meta.federation === 'none') { - reply.code(403); + hono.get('/@:acct', async (ctx, next) => { + vary(ctx, 'Accept'); + if (!this.wantsActivityPub(ctx)) { + await next(); return; } - // This may be used before the follow is completed, so we do not - // check if the following exists and only check if the follow request exists. + const acctParam = ctx.req.param('acct'); + if (acctParam == null) { + return ctx.body(null, 404); + } - const followRequest = await this.followRequestsRepository.findOneBy({ - id: request.params.followRequestId, + const acct = Acct.parse(acctParam); + // normalize acct host + if (this.utilityService.isSelfHost(acct.host)) acct.host = null; + + const user = await this.usersRepository.findOneBy({ + usernameLower: acct.username.toLowerCase(), + host: acct.host ?? IsNull(), + isSuspended: false, }); - if (followRequest == null) { - reply.code(404); - return; - } + return await this.userInfo(ctx, user); + }); - const [follower, followee] = await Promise.all([ - this.usersRepository.findOneBy({ - id: followRequest.followerId, - host: IsNull(), - }), - this.usersRepository.findOneBy({ - id: followRequest.followeeId, - host: Not(IsNull()), - }), - ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; - - if (follower == null || followee == null) { - reply.code(404); - return; - } + hono.get('/emojis/:emoji', async (ctx) => { + return await this.emoji(ctx); + }); + + hono.get('/likes/:like', async (ctx) => { + return await this.like(ctx); + }); + + hono.get('/follows/:follower/:followee', async (ctx) => { + return await this.follow(ctx); + }); - reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); + hono.get('/follows/:followRequestId', async (ctx) => { + return await this.followRequest(ctx); }); - done(); + return hono; } } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 4a5ac799ad9..915bd61673a 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -3,9 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as fs from 'node:fs'; import { resolve } from 'node:path'; +import { promises as fsp } from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; +import { Hono } from 'hono'; +import type { Context as HonoContext } from 'hono'; +import { createMiddleware } from 'hono/factory'; +import { serveStatic } from '@hono/node-server/serve-static'; import type { Config } from '@/config.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -18,11 +22,11 @@ import { ImageProcessingService } from '@/core/ImageProcessingService.js'; import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; +import { handleRequestRedirectToOmitSearch } from '@/misc/hono-middleware-handlers.js'; +import { bufferToWebStream } from './file/FileServerUtils.js'; import { FileServerDriveHandler } from './file/FileServerDriveHandler.js'; import { FileServerFileResolver } from './file/FileServerFileResolver.js'; import { FileServerProxyHandler } from './file/FileServerProxyHandler.js'; -import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; @Injectable() export class FileServerService { @@ -72,61 +76,60 @@ export class FileServerService { } @bindThis - 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\''); + public createServer(): Hono { + const hono = new Hono(); + + const fileRouteMiddleware = createMiddleware(async (ctx, next) => { + ctx.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); if (process.env.NODE_ENV === 'development') { - reply.header('Access-Control-Allow-Origin', '*'); + ctx.header('Access-Control-Allow-Origin', '*'); } - done(); + await next(); }); - fastify.register((fastify, options, done) => { - fastify.addHook('onRequest', handleRequestRedirectToOmitSearch); - fastify.get('/files/app-default.jpg', (request, reply) => { - const file = fs.createReadStream(`${this.assets}/dummy.png`); - reply.header('Content-Type', 'image/jpeg'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - return reply.send(file); - }); + hono.use('/files/*', fileRouteMiddleware, handleRequestRedirectToOmitSearch); + hono.use('/proxy/*', fileRouteMiddleware); - fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => { - return await this.driveHandler.handle(request, reply) - .catch(err => this.errorHandler(request, reply, err)); - }); - fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { - return await reply.redirect(`${this.config.url}/files/${request.params.key}`, 301); - }); - done(); - }); + hono.get('/files/app-default.jpg', serveStatic({ + path: resolve(this.assets, 'dummy.png'), + onFound: (_, ctx) => { + ctx.header('Content-Type', 'image/jpeg'); + ctx.header('Cache-Control', 'max-age=31536000, immutable'); + }, + })); - fastify.get<{ - Params: { url: string; }; - Querystring: { url?: string; }; - }>('/proxy/:url*', async (request, reply) => { - return await this.proxyHandler.handle(request, reply) - .catch(err => this.errorHandler(request, reply, err)); + hono.get('/files/:key', this.driveHandler.handle); + hono.get('/files/:key/*', (ctx) => { + return ctx.redirect(`${this.config.url}/files/${ctx.req.param('key')}`, 301); }); - done(); + hono.get('/proxy/:url*', this.proxyHandler.handle); + + hono.onError(this.errorHandler); + + return hono; } @bindThis - private async errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Querystring?: { [x: string]: any }; }>, reply: FastifyReply, err?: any) { + private async errorHandler(err: any, ctx: HonoContext): Promise { this.logger.error(`${err}`); - reply.header('Cache-Control', 'max-age=300'); + ctx.header('Cache-Control', 'max-age=300'); - if (request.query && 'fallback' in request.query) { - return reply.sendFile('/dummy.png', this.assets); + if (ctx.req.query('static') != null) { + const fileBuffer = await fsp.readFile(resolve(this.assets, 'not-found.png')); + return ctx.body(bufferToWebStream(fileBuffer), 200, { + 'Content-Type': 'image/png', + 'Content-Length': fileBuffer.length.toString(), + }); } if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { - reply.code(err.statusCode); - return; + ctx.status(err.statusCode); + return ctx.text(err.message); } - reply.code(500); - return; + ctx.status(500); + return ctx.text('Internal Server Error'); } } diff --git a/packages/backend/src/server/HealthServerService.ts b/packages/backend/src/server/HealthServerService.ts index 7c9710c6939..68279185d18 100644 --- a/packages/backend/src/server/HealthServerService.ts +++ b/packages/backend/src/server/HealthServerService.ts @@ -4,12 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Hono } from 'hono'; import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import { readyRef } from '@/boot/ready.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import type { Meilisearch } from 'meilisearch'; @Injectable() @@ -38,9 +38,11 @@ export class HealthServerService { ) {} @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.get('/', async (request, reply) => { - reply.code(await Promise.all([ + public createServer(): Hono { + const hono = new Hono(); + + hono.get('/', async (ctx) => { + const status = await Promise.all([ new Promise((resolve, reject) => readyRef.value ? resolve() : reject()), this.redis.ping(), this.redisForPub.ping(), @@ -49,10 +51,13 @@ export class HealthServerService { this.redisForReactions.ping(), this.db.query('SELECT 1'), ...(this.meilisearch ? [this.meilisearch.health()] : []), - ]).then(() => 200, () => 503)); - reply.header('Cache-Control', 'no-store'); + ]).then(() => 200 as const, () => 503 as const); + + ctx.status(status); + ctx.header('Cache-Control', 'no-store'); + return ctx.body(null); }); - done(); + return hono; } } diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 93c36f53653..840138706b5 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Hono } from 'hono'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; @@ -14,7 +15,6 @@ import NotesChart from '@/core/chart/charts/notes.js'; import UsersChart from '@/core/chart/charts/users.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; const nodeinfo2_1path = '/nodeinfo/2.1'; const nodeinfo2_0path = '/nodeinfo/2.0'; @@ -46,13 +46,12 @@ export class NodeinfoServerService { } @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - const nodeinfo2 = async (version: number) => { - const notesChart = await this.notesChart.getChart('hour', 1, null); - const localPosts = notesChart.local.total[0]; + private async generateNodeinfoDocument(version: number) { + const notesChart = await this.notesChart.getChart('hour', 1, null); + const localPosts = notesChart.local.total[0]; - const usersChart = await this.usersChart.getChart('hour', 1, null); - const total = usersChart.local.total[0]; + const usersChart = await this.usersChart.getChart('hour', 1, null); + const total = usersChart.local.total[0]; const [ meta, @@ -65,107 +64,101 @@ export class NodeinfoServerService { //this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }), ]); - const activeHalfyear = null; - const activeMonth = null; - - const proxyAccount = await this.systemAccountService.fetch('proxy'); - - const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const document: any = { - software: { - name: 'misskey', - version: this.config.version, - homepage: nodeinfo_homepage, - repository: meta.repositoryUrl, - }, - protocols: ['activitypub'], - services: { - inbound: [] as string[], - outbound: ['atom1.0', 'rss2.0'], - }, - openRegistrations: !meta.disableRegistration, - usage: { - users: { total, activeHalfyear, activeMonth }, - localPosts, - localComments: 0, - }, - metadata: { - nodeName: meta.name, - nodeDescription: meta.description, - nodeAdmins: [{ - name: meta.maintainerName, - email: meta.maintainerEmail, - }], - // deprecated - maintainer: { - name: meta.maintainerName, - email: meta.maintainerEmail, - }, - langs: meta.langs, - tosUrl: meta.termsOfServiceUrl, - privacyPolicyUrl: meta.privacyPolicyUrl, - inquiryUrl: meta.inquiryUrl, - impressumUrl: meta.impressumUrl, - repositoryUrl: meta.repositoryUrl, - feedbackUrl: meta.feedbackUrl, - disableRegistration: meta.disableRegistration, - disableLocalTimeline: !basePolicies.ltlAvailable, - disableGlobalTimeline: !basePolicies.gtlAvailable, - emailRequiredForSignup: meta.emailRequiredForSignup, - enableHcaptcha: meta.enableHcaptcha, - enableRecaptcha: meta.enableRecaptcha, - enableMcaptcha: meta.enableMcaptcha, - enableTurnstile: meta.enableTurnstile, - maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, - enableEmail: meta.enableEmail, - enableServiceWorker: meta.enableServiceWorker, - proxyAccountName: proxyAccount.username, - themeColor: meta.themeColor ?? '#86b300', + const activeHalfyear = null; + const activeMonth = null; + + const proxyAccount = await this.systemAccountService.fetch('proxy'); + const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const document: any = { + software: { + name: 'misskey', + version: this.config.version, + homepage: nodeinfo_homepage, + repository: meta.repositoryUrl, + }, + protocols: ['activitypub'], + services: { + inbound: [] as string[], + outbound: ['atom1.0', 'rss2.0'], + }, + openRegistrations: !meta.disableRegistration, + usage: { + users: { total, activeHalfyear, activeMonth }, + localPosts, + localComments: 0, + }, + metadata: { + nodeName: meta.name, + nodeDescription: meta.description, + nodeAdmins: [{ + name: meta.maintainerName, + email: meta.maintainerEmail, + }], + maintainer: { + name: meta.maintainerName, + email: meta.maintainerEmail, }, - }; - if (version >= 21) { - document.software.repository = meta.repositoryUrl; - document.software.homepage = meta.repositoryUrl; - } - return document; + langs: meta.langs, + tosUrl: meta.termsOfServiceUrl, + privacyPolicyUrl: meta.privacyPolicyUrl, + inquiryUrl: meta.inquiryUrl, + impressumUrl: meta.impressumUrl, + repositoryUrl: meta.repositoryUrl, + feedbackUrl: meta.feedbackUrl, + disableRegistration: meta.disableRegistration, + disableLocalTimeline: !basePolicies.ltlAvailable, + disableGlobalTimeline: !basePolicies.gtlAvailable, + emailRequiredForSignup: meta.emailRequiredForSignup, + enableHcaptcha: meta.enableHcaptcha, + enableRecaptcha: meta.enableRecaptcha, + enableMcaptcha: meta.enableMcaptcha, + enableTurnstile: meta.enableTurnstile, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, + enableEmail: meta.enableEmail, + enableServiceWorker: meta.enableServiceWorker, + proxyAccountName: proxyAccount.username, + themeColor: meta.themeColor ?? '#86b300', + }, }; - const cache = new MemorySingleCache>>(1000 * 60 * 10); // 10m - - fastify.get(nodeinfo2_1path, async (request, reply) => { - const base = await cache.fetch(() => nodeinfo2(21)); - - reply - .type( - 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"', - ) - .header('Cache-Control', 'public, max-age=600') - .header('Access-Control-Allow-Headers', 'Accept') - .header('Access-Control-Allow-Methods', 'GET, OPTIONS') - .header('Access-Control-Allow-Origin', '*') - .header('Access-Control-Expose-Headers', 'Vary'); - return { version: '2.1', ...base }; - }); + if (version >= 21) { + document.software.repository = meta.repositoryUrl; + document.software.homepage = meta.repositoryUrl; + } - fastify.get(nodeinfo2_0path, async (request, reply) => { - const base = await cache.fetch(() => nodeinfo2(20)); + return document; + } - delete (base as any).software.repository; + @bindThis + public createServer(): Hono { + const hono = new Hono(); + const cache = new MemorySingleCache>>(1000 * 60 * 10); + + hono.get(nodeinfo2_1path, async (ctx) => { + const base = await cache.fetch(() => this.generateNodeinfoDocument(21)); + ctx.header('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"'); + ctx.header('Cache-Control', 'public, max-age=600'); + ctx.header('Access-Control-Allow-Headers', 'Accept'); + ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + ctx.header('Access-Control-Allow-Origin', '*'); + ctx.header('Access-Control-Expose-Headers', 'Vary'); + return ctx.json({ version: '2.1', ...base }); + }); - reply - .type( - 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"', - ) - .header('Cache-Control', 'public, max-age=600') - .header('Access-Control-Allow-Headers', 'Accept') - .header('Access-Control-Allow-Methods', 'GET, OPTIONS') - .header('Access-Control-Allow-Origin', '*') - .header('Access-Control-Expose-Headers', 'Vary'); - return { version: '2.0', ...base }; + hono.get(nodeinfo2_0path, async (ctx) => { + const base = await cache.fetch(() => this.generateNodeinfoDocument(20)); + delete (base as any).software.repository; + ctx.header('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"'); + ctx.header('Cache-Control', 'public, max-age=600'); + ctx.header('Access-Control-Allow-Headers', 'Accept'); + ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + ctx.header('Access-Control-Allow-Origin', '*'); + ctx.header('Access-Control-Expose-Headers', 'Vary'); + return ctx.json({ version: '2.0', ...base }); }); - done(); + return hono; } } diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 23ead0febac..52b1c0c1376 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -3,17 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import cluster from 'node:cluster'; import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import cluster from 'node:cluster'; +import type { IncomingMessage } from 'node:http'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Fastify, { type FastifyInstance } from 'fastify'; -import fastifyStatic from '@fastify/static'; -import fastifyRawBody from 'fastify-raw-body'; +import { createAdaptorServer } from '@hono/node-server'; +import type { ServerType } from '@hono/node-server'; +import proxyAddr from '@fastify/proxy-addr'; +import { Hono } from 'hono'; import { IsNull } from 'typeorm'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; -import type { EmojisRepository, MiMeta, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { EmojisRepository, MiMeta, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; @@ -31,13 +31,13 @@ import { HealthServerService } from './HealthServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; - -const _dirname = fileURLToPath(new URL('.', import.meta.url)); +import { ApiEnv } from './api/ApiServerTypes.js'; @Injectable() export class ServerService implements OnApplicationShutdown { private logger: Logger; - #fastify: FastifyInstance; + #honoNodeServer: ServerType | null = null; + #trustProxyChecker: ((address: string, hop: number) => boolean) | undefined; constructor( @Inject(DI.config) @@ -49,9 +49,6 @@ export class ServerService implements OnApplicationShutdown { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, @@ -65,7 +62,6 @@ export class ServerService implements OnApplicationShutdown { private fileServerService: FileServerService, private healthServerService: HealthServerService, private clientServerService: ClientServerService, - private globalEventService: GlobalEventService, private loggerService: LoggerService, private oauth2ProviderService: OAuth2ProviderService, ) { @@ -74,142 +70,112 @@ export class ServerService implements OnApplicationShutdown { @bindThis public async launch(): Promise { - const fastify = Fastify({ - trustProxy: this.config.trustProxy, - logger: false, + this.#trustProxyChecker = this.createTrustProxyChecker(); + + const hono = new Hono(); + + hono.use(async (ctx, next) => { + const incoming = (ctx.env as { incoming?: IncomingMessage }).incoming; + if (incoming != null) { + const ips = this.resolveClientIps(incoming); + ctx.set('ips', ips); + ctx.set('ip', ips.at(-1) ?? incoming.socket.remoteAddress ?? ''); + } + await next(); }); - this.#fastify = fastify; - // HSTS - // 6months (15552000sec) if (this.config.url.startsWith('https') && !this.config.disableHsts) { - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('strict-transport-security', 'max-age=15552000; preload'); - done(); + hono.use(async (ctx, next) => { + ctx.header('strict-transport-security', 'max-age=15552000; preload'); + await next(); }); } - // Register raw-body parser for ActivityPub HTTP signature validation. - await fastify.register(fastifyRawBody, { - global: false, - encoding: null, - runFirst: true, - }); - - // Register non-serving static server so that the child services can use reply.sendFile. - // `root` here is just a placeholder and each call must use its own `rootPath`. - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - - // if the requester looks like to be performing an ActivityPub object lookup, reject all external redirects - // - // this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com - // - // this is not required by standard but protect us from peers that did not validate final URL. if (!this.meta.allowExternalApRedirect) { const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i; - fastify.addHook('onSend', (request, reply, _, done) => { - const location = reply.getHeader('location'); - if (reply.statusCode < 300 || reply.statusCode >= 400 || typeof location !== 'string') { - done(); + hono.use(async (ctx, next) => { + await next(); + + const location = ctx.res.headers.get('location'); + if (ctx.res.status < 300 || ctx.res.status >= 400 || location == null) { return; } - if (!maybeApLookupRegex.test(request.headers.accept ?? '')) { - done(); + if (!maybeApLookupRegex.test(ctx.req.header('accept') ?? '')) { return; } const effectiveLocation = process.env.NODE_ENV === 'production' ? location : location.replace(/^http:\/\//, 'https://'); if (effectiveLocation.startsWith(`https://${this.config.host}/`)) { - done(); return; } - reply.status(406); - reply.removeHeader('location'); - reply.header('content-type', 'text/plain; charset=utf-8'); - reply.header('link', `<${encodeURI(location)}>; rel="canonical"`); - done(null, [ + const headers = new Headers(ctx.res.headers); + headers.delete('location'); + headers.set('content-type', 'text/plain; charset=utf-8'); + headers.set('link', `<${encodeURI(location)}>; rel="canonical"`); + return new Response([ 'Refusing to relay remote ActivityPub object lookup.', '', `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`, - ].join('\n')); + ].join('\n'), { status: 406, headers }); }); } - fastify.register(this.apiServerService.createServer, { prefix: '/api' }); - fastify.register(this.openApiServerService.createServer); - fastify.register(this.fileServerService.createServer); - fastify.register(this.activityPubServerService.createServer); - fastify.register(this.nodeinfoServerService.createServer); - fastify.register(this.wellKnownServerService.createServer); - fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' }); - fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' }); - fastify.register(this.healthServerService.createServer, { prefix: '/healthz' }); - - fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { - const path = request.params.path; - - reply.header('Cache-Control', 'public, max-age=86400'); + hono.get('/emoji/:path{.*}', async (ctx) => { + const path = ctx.req.param('path'); + ctx.header('Cache-Control', 'public, max-age=86400'); if (!path.match(/^[a-zA-Z0-9\-_@\.]+?\.webp$/)) { - reply.code(404); - return; + return ctx.body(null, 404); } const emojiPath = path.replace(/\.webp$/i, ''); const pathChunks = emojiPath.split('@'); - if (pathChunks.length > 2) { - reply.code(400); - return; + return ctx.body(null, 400); } const name = pathChunks.shift(); const host = pathChunks.pop(); - const emoji = await this.emojisRepository.findOneBy({ - // `@.` is the spec of ReactionService.decodeReaction host: (host === undefined || host === '.') ? IsNull() : host, - name: name, + name, }); - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + ctx.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); if (emoji == null) { - if ('fallback' in request.query) { - return await reply.redirect('/static-assets/emoji-unknown.png'); - } else { - reply.code(404); - return; + if (ctx.req.query('fallback') != null) { + return ctx.redirect('/static-assets/emoji-unknown.png'); } + return ctx.body(null, 404); } let url: URL; - if ('badge' in request.query) { + if (ctx.req.query('badge') != null) { url = new URL(`${this.config.mediaProxy}/emoji.png`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); url.searchParams.set('badge', '1'); } else { url = new URL(`${this.config.mediaProxy}/emoji.webp`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); url.searchParams.set('emoji', '1'); - if ('static' in request.query) url.searchParams.set('static', '1'); + if (ctx.req.query('static') != null) { + url.searchParams.set('static', '1'); + } } - return await reply.redirect( - url.toString(), - 301, - ); + return ctx.redirect(url.toString(), 301); }); - fastify.get<{ Params: { acct: string } }>('/avatar/@:acct', async (request, reply) => { - const { username, host } = Acct.parse(request.params.acct); + hono.get('/avatar/@:acct', async (ctx) => { + const acct = ctx.req.param('acct'); + if (acct == null) { + return ctx.body(null, 400); + } + + const { username, host } = Acct.parse(acct); const user = await this.usersRepository.findOne({ where: { usernameLower: username.toLowerCase(), @@ -218,30 +184,52 @@ export class ServerService implements OnApplicationShutdown { }, }); - reply.header('Cache-Control', 'public, max-age=86400'); + ctx.header('Cache-Control', 'public, max-age=86400'); + if (user != null) { + return ctx.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user)); + } + return ctx.redirect('/static-assets/user-unknown.png'); + }); - if (user) { - reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user)); - } else { - reply.redirect('/static-assets/user-unknown.png'); + hono.get('/identicon/:x', async (ctx) => { + ctx.header('Content-Type', 'image/png'); + ctx.header('Cache-Control', 'public, max-age=86400'); + if (this.meta.enableIdenticonGeneration) { + const image = await genIdenticon(ctx.req.param('x')); + return ctx.body(new Uint8Array(image)); } + return ctx.redirect('/static-assets/avatar.png'); + }); + + hono.route('/api', this.apiServerService.createServer()); + hono.route('/', this.openApiServerService.createServer()); + hono.route('/', this.nodeinfoServerService.createServer()); + hono.route('/', this.wellKnownServerService.createServer()); + hono.route('/healthz', this.healthServerService.createServer()); + hono.route('/', this.activityPubServerService.createServer()); + hono.route('/', this.fileServerService.createServer()); + hono.route('/', this.clientServerService.createServer()); + hono.route('/oauth', this.oauth2ProviderService.createServer()); + + this.#honoNodeServer = createAdaptorServer({ + fetch: hono.fetch, }); - fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => { - reply.header('Content-Type', 'image/png'); - reply.header('Cache-Control', 'public, max-age=86400'); + // WebSocket + this.#honoNodeServer.on('upgrade', (req, socket, head) => { + const url = new URL(req.url ?? '', `http://${req.headers['host'] ?? 'localhost'}`); - if (this.meta.enableIdenticonGeneration) { - return await genIdenticon(request.params.x); + if (url.pathname === '/streaming') { + this.streamingApiServerService.handleUpgrade(req, socket, head); } else { - return reply.redirect('/static-assets/avatar.png'); + socket.destroy(); } }); - fastify.register(this.clientServerService.createServer); - - this.streamingApiServerService.attach(fastify.server); + await this.listen(); + } + private listen() { const handleListenError = (err: unknown): void => { switch ((err as NodeJS.ErrnoException).code) { case 'EACCES': @@ -263,44 +251,82 @@ export class ServerService implements OnApplicationShutdown { } }; - try { + return new Promise((resolve, reject) => { if (this.config.socket) { if (fs.existsSync(this.config.socket)) { fs.unlinkSync(this.config.socket); } - await fastify.listen({ path: this.config.socket }); - if (this.config.chmodSocket) { - fs.chmodSync(this.config.socket, this.config.chmodSocket); - } + this.#honoNodeServer!.listen(this.config.socket, () => { + if (this.config.chmodSocket) { + fs.chmodSync(this.config.socket!, this.config.chmodSocket); + } + this.logger.info(`Server is listening on socket ${this.config.socket}`); + resolve(); + }); } else { - await fastify.listen({ port: this.config.port, host: '0.0.0.0' }); + this.#honoNodeServer!.listen(this.config.port, '0.0.0.0', () => { + this.logger.info(`Server is listening on port ${this.config.port}`); + resolve(); + }); } - await fastify.ready(); - } catch (err) { + }).catch((err) => { handleListenError(err); - return; + }); + } + + private createTrustProxyChecker(): ((address: string, hop: number) => boolean) | undefined { + const trustProxy = this.config.trustProxy; + if (trustProxy === false) { + return undefined; + } + + if (trustProxy === true) { + return () => true; + } + + if (typeof trustProxy === 'number') { + return (_address, hop) => hop < trustProxy; + } + + if (typeof trustProxy === 'function') { + return trustProxy; + } + + return proxyAddr.compile(trustProxy); + } + + private resolveClientIps(request: IncomingMessage): string[] { + const socketAddress = request.socket.remoteAddress; + if (this.#trustProxyChecker == null) { + return socketAddress == null ? [] : [socketAddress]; + } + + try { + return proxyAddr.all(request, this.#trustProxyChecker); + } catch { + return socketAddress == null ? [] : [socketAddress]; } } @bindThis public async dispose(): Promise { await this.streamingApiServerService.detach(); - // fastify@5 close() waits for upgraded WebSocket connections to drain. - // streamingApiServerService.attach() adds raw ws.Server upgrades that - // fastify does not track in its connection registry, so close() can hang - // forever during OnApplicationShutdown. Cap at 5s so PM2/systemd/k8s - // shutdown timeouts aren't held hostage. - await Promise.race([ - this.#fastify.close(), - new Promise(resolve => setTimeout(resolve, 5_000)), - ]).catch(err => this.logger.error('fastify.close() failed', err as Error)); - } + if (this.#honoNodeServer != null && this.#honoNodeServer.listening) { + const close = () => new Promise((resolve, reject) => { + this.#honoNodeServer!.close((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); - /** - * Get the Fastify instance for testing. - */ - public get fastify(): FastifyInstance { - return this.#fastify; + await Promise.race([ + close(), + new Promise(resolve => setTimeout(resolve, 5_000)), + ]).catch(err => this.logger.error('Server close failed', err as Error)); + } } @bindThis diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index ebfd1a421d9..a607efa9b74 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Hono } from 'hono'; import { IsNull } from 'typeorm'; -import vary from 'vary'; -import fastifyAccepts from '@fastify/accepts'; import { DI } from '@/di-symbols.js'; import type { MiMeta, UsersRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; +import { vary } from '@/misc/hono-vary.js'; import type { MiUser } from '@/models/User.js'; import * as Acct from '@/misc/acct.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -18,7 +18,6 @@ import { bindThis } from '@/decorators.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import type { FindOptionsWhere } from 'typeorm'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() export class WellKnownServerService { @@ -40,7 +39,7 @@ export class WellKnownServerService { } @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + public createServer(): Hono { const XRD = (...x: { element: string, value?: string, attributes?: Record }[]) => `${x.map(({ element, value, attributes }) => `<${ @@ -49,77 +48,69 @@ export class WellKnownServerService { typeof value === 'string' ? `>${escapeValue(value)}`).reduce((a, c) => a + c, '')}`; - const allPath = '/.well-known/*'; const webFingerPath = '/.well-known/webfinger'; const jrd = 'application/jrd+json'; const xrd = 'application/xrd+xml'; - - fastify.register(fastifyAccepts); - - 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(); + const hono = new Hono(); + + hono.use('/.well-known/*', async (ctx, next) => { + ctx.header('Access-Control-Allow-Headers', 'Accept'); + ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + ctx.header('Access-Control-Allow-Origin', '*'); + ctx.header('Access-Control-Expose-Headers', 'Vary'); + await next(); }); - fastify.options(allPath, async (request, reply) => { - reply.code(204); - }); + hono.options('/.well-known/*', (ctx) => ctx.body(null, 204)); - fastify.get('/.well-known/host-meta', async (request, reply) => { + hono.get('/.well-known/host-meta', async (ctx) => { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } - reply.header('Content-Type', xrd); - return XRD({ element: 'Link', attributes: { + ctx.header('Content-Type', xrd); + return ctx.body(XRD({ element: 'Link', attributes: { rel: 'lrdd', type: xrd, template: `${this.config.url}${webFingerPath}?resource={uri}`, - } }); + } })); }); - fastify.get('/.well-known/host-meta.json', async (request, reply) => { + hono.get('/.well-known/host-meta.json', async (ctx) => { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } - reply.header('Content-Type', 'application/json'); - return { + ctx.header('Content-Type', 'application/json'); + return ctx.json({ links: [{ rel: 'lrdd', type: jrd, template: `${this.config.url}${webFingerPath}?resource={uri}`, }], - }; + }); }); - fastify.get('/.well-known/nodeinfo', async (request, reply) => { + hono.get('/.well-known/nodeinfo', async (ctx) => { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } - return { links: this.nodeinfoServerService.getLinks() }; + return ctx.json({ links: this.nodeinfoServerService.getLinks() }); }); - fastify.get('/.well-known/oauth-authorization-server', async () => { - return this.oauth2ProviderService.generateRFC8414(); + hono.get('/.well-known/oauth-authorization-server', async (ctx) => { + return ctx.json(this.oauth2ProviderService.generateRFC8414()); }); /* TODO -fastify.get('/.well-known/change-password', async (request, reply) => { +hono.get('/.well-known/change-password', async (ctx) => { }); */ - fastify.get<{ Querystring: { resource: string } }>(webFingerPath, async (request, reply) => { + hono.get(webFingerPath, async (ctx) => { if (this.meta.federation === 'none') { - reply.code(403); - return; + return ctx.body(null, 403); } const fromId = (id: MiUser['id']): FindOptionsWhere => ({ @@ -143,23 +134,22 @@ fastify.get('/.well-known/change-password', async (request, reply) => { isSuspended: false, } : 422; - if (typeof request.query.resource !== 'string') { - reply.code(400); - return; + const resource = ctx.req.query('resource'); + if (resource == null) { + return ctx.body(null, 400); } - const query = generateQuery(request.query.resource.toLowerCase()); + const query = generateQuery(resource.toLowerCase()); if (typeof query === 'number') { - reply.code(query); - return; + ctx.status(422); + return ctx.body(null); } const user = await this.usersRepository.findOneBy(query); if (user == null) { - reply.code(404); - return; + return ctx.body(null, 404); } const subject = `acct:${user.username}@${this.config.host}`; @@ -178,25 +168,26 @@ fastify.get('/.well-known/change-password', async (request, reply) => { template: `${this.config.url}/authorize-follow?acct={uri}`, }; - vary(reply.raw, 'Accept'); - reply.header('Cache-Control', 'public, max-age=180'); + vary(ctx, 'Accept'); + ctx.header('Cache-Control', 'public, max-age=180'); - if (request.accepts().type([jrd, xrd]) === xrd) { - reply.type(xrd); - return XRD( + const accepted = ctx.req.header('accept') ?? ''; + if (accepted.includes(xrd)) { + ctx.header('Content-Type', xrd); + return ctx.body(XRD( { element: 'Subject', value: subject }, { element: 'Link', attributes: self }, { element: 'Link', attributes: profilePage }, - { element: 'Link', attributes: subscribe }); + { element: 'Link', attributes: subscribe })); } else { - reply.type(jrd); - return { + ctx.header('Content-Type', jrd); + return ctx.json({ subject, links: [self, profilePage, subscribe], - }; + }); } }); - done(); + return hono; } } diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 0ccb3df6318..6a3925110f3 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -21,9 +21,9 @@ import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; import type { OnApplicationShutdown } from '@nestjs/common'; import type { IEndpointMeta, IEndpoint } from './endpoints.js'; +import type { ApiContext, ApiMultipartData } from './ApiServerTypes.js'; const accessDenied = { message: 'Access denied.', @@ -67,46 +67,46 @@ export class ApiCallService implements OnApplicationShutdown { } } - #sendApiError(reply: FastifyReply, err: ApiError): void { + #sendApiError(ctx: ApiContext, err: ApiError): Response { let statusCode = err.httpStatusCode; if (err.httpStatusCode === 401) { - reply.header('WWW-Authenticate', 'Bearer realm="Misskey"'); + ctx.header('WWW-Authenticate', 'Bearer realm="Misskey"'); } else if (err.code === 'RATE_LIMIT_EXCEEDED') { const info: unknown = err.info; const unixEpochInSeconds = Date.now(); if (typeof(info) === 'object' && info && 'resetMs' in info && typeof(info.resetMs) === 'number') { const cooldownInSeconds = Math.ceil((info.resetMs - unixEpochInSeconds) / 1000); // もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく - reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10)); + ctx.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10)); } else { this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); } } else if (err.kind === 'client') { - reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); + ctx.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); statusCode = statusCode ?? 400; } else if (err.kind === 'permission') { // (ROLE_PERMISSION_DENIEDは関係ない) if (err.code === 'PERMISSION_DENIED') { - reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); + ctx.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); } statusCode = statusCode ?? 403; } else if (!statusCode) { statusCode = 500; } - this.send(reply, statusCode, err); + return this.send(ctx, statusCode, err); } - #sendAuthenticationError(reply: FastifyReply, err: unknown): void { + #sendAuthenticationError(ctx: ApiContext, err: unknown): Response { if (err instanceof AuthenticationError) { const message = 'Authentication failed. Please ensure your token is correct.'; - reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_token", error_description="${message}"`); - this.send(reply, 401, new ApiError({ + ctx.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_token", error_description="${message}"`); + return this.send(ctx, 401, 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()); + return this.send(ctx, 500, new ApiError()); } } @@ -156,107 +156,106 @@ export class ApiCallService implements OnApplicationShutdown { } @bindThis - public handleRequest( + public async handleRequest( endpoint: IEndpoint & { exec: any }, - request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, - reply: FastifyReply, - ): void { - const body = request.method === 'GET' - ? request.query - : request.body; + ctx: ApiContext, + bodyData?: Record, + ): Promise { + const body = ctx.req.method === 'GET' + ? ctx.req.query() + : bodyData; // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) + const authorization = ctx.req.header('authorization'); + const token = authorization?.startsWith('Bearer ') + ? authorization.slice(7) : body?.['i']; if (token != null && typeof token !== 'string') { - reply.code(400); - return; + return ctx.body(null, 400); } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, body, null, request).then((res) => { - if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { - reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); - } - this.send(reply, res); - }).catch((err: ApiError) => { - this.#sendApiError(reply, err); - }); + try { + const [user, app] = await this.authenticateService.authenticate(token); + const res = await this.call(endpoint, user, app, body, null, ctx); + if (ctx.req.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { + ctx.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); + } if (user) { - this.logIp(request, user); + this.logIp(ctx.var.ip, user); } - }).catch(err => { - this.#sendAuthenticationError(reply, err); - }); + return this.send(ctx, res); + } catch (err) { + if (err instanceof ApiError) { + return this.#sendApiError(ctx, err); + } + + return this.#sendAuthenticationError(ctx, err); + } } @bindThis public async handleMultipartRequest( endpoint: IEndpoint & { exec: any }, - request: FastifyRequest<{ Body: Record, Querystring: Record }>, - reply: FastifyReply, - ): Promise { - const multipartData = await request.file().catch(() => { - /* Fastify throws if the remote didn't send multipart data. Return 400 below. */ - }); + ctx: ApiContext, + multipartData: ApiMultipartData | null, + ): Promise { if (multipartData == null) { - reply.code(400); - reply.send(); - return; + return ctx.body(null, 400); } const [path, cleanup] = await createTemp(); await stream.pipeline(multipartData.file, fs.createWriteStream(path)); - // ファイルサイズが制限を超えていた場合 - // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある - if (multipartData.file.truncated) { + if (multipartData.truncated) { cleanup(); - reply.code(413); - reply.send(); - return; + return ctx.body(null, 413); } const fields = {} as Record; for (const [k, v] of Object.entries(multipartData.fields)) { - fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; + fields[k] = v; } // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) + const authorization = ctx.req.header('authorization'); + const token = authorization?.startsWith('Bearer ') + ? authorization.slice(7) : fields['i']; if (token != null && typeof token !== 'string') { - reply.code(400); - return; + return ctx.body(null, 400); } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, { + + try { + const [user, app] = await this.authenticateService.authenticate(token); + const res = await this.call(endpoint, user, app, fields, { name: multipartData.filename, path: path, - }, request).then((res) => { - this.send(reply, res); - }).catch((err: ApiError) => { - this.#sendApiError(reply, err); - }); - + }, ctx); if (user) { - this.logIp(request, user); + this.logIp(ctx.var.ip, user); + } + + return this.send(ctx, res); + } catch (err) { + cleanup(); + if (err instanceof ApiError) { + return this.#sendApiError(ctx, err); } - }).catch(err => { - this.#sendAuthenticationError(reply, err); - }); + + return this.#sendAuthenticationError(ctx, err); + } } @bindThis - private send(reply: FastifyReply, x?: any, y?: ApiError) { + private send(ctx: ApiContext, x?: any, y?: ApiError): Response { + if (x instanceof Response) { + return x; + } + if (x == null) { - reply.code(204); - reply.send(); + return ctx.body(null, 204); } else if (typeof x === 'number' && y) { - reply.code(x); - reply.send({ + return ctx.json({ error: { message: y!.message, code: y!.code, @@ -264,17 +263,21 @@ export class ApiCallService implements OnApplicationShutdown { kind: y!.kind, ...(y!.info ? { info: y!.info } : {}), }, - }); + }, x as never); } else { // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない - reply.send(typeof x === 'string' ? JSON.stringify(x) : x); + if (typeof x === 'string') { + ctx.header('Content-Type', 'application/json'); + return ctx.body(JSON.stringify(x)); + } + + return ctx.json(x); } } @bindThis - private logIp(request: FastifyRequest, user: MiLocalUser) { + private logIp(ip: string, user: MiLocalUser) { if (!this.meta.enableIpLogging) return; - const ip = request.ip; const ips = this.userIpHistories.get(user.id); if (ips == null || !ips.has(ip)) { if (ips == null) { @@ -304,7 +307,7 @@ export class ApiCallService implements OnApplicationShutdown { name: string; path: string; } | null, - request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, + ctx: ApiContext, ) { const isSecure = user != null && token == null; @@ -312,16 +315,18 @@ export class ApiCallService implements OnApplicationShutdown { throw new ApiError(accessDenied); } + const ip = ctx.var.ip; + if (ep.meta.limit) { let limitActor: string | null = null; if (user) { limitActor = user.id; } else if (this.config.enableIpRateLimit) { - if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + if (process.env.NODE_ENV === 'production' && (ip === '::1' || ip === '127.0.0.1')) { this.logger.warn('Recieved API request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); } - limitActor = getIpHash(request.ip); + limitActor = getIpHash(ip); } const limit = Object.assign({}, ep.meta.limit); @@ -420,7 +425,7 @@ export class ApiCallService implements OnApplicationShutdown { } // Cast non JSON input - if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { + if ((ep.meta.requireFile || ctx.req.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') { @@ -444,10 +449,10 @@ export class ApiCallService implements OnApplicationShutdown { if (this.Sentry != null) { return await this.Sentry.startSpan({ name: 'API: ' + ep.name, - }, () => ep.exec(data, user, token, file, request.ip, request.headers) + }, () => ep.exec(data, user, token, file, ip, ctx.req.header()) .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); } else { - return await ep.exec(data, user, token, file, request.ip, request.headers) + return await ep.exec(data, user, token, file, ip, ctx.req.header()) .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); } } diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index b88edaf1563..8ed91bf4c8a 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -3,11 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { Readable } from 'node:stream'; import { Inject, Injectable } from '@nestjs/common'; -import cors from '@fastify/cors'; -import multipart from '@fastify/multipart'; import { ModuleRef } from '@nestjs/core'; -import type { AuthenticationResponseJSON } from '@simplewebauthn/server'; +import { Hono } from 'hono'; +import { TrieRouter } from 'hono/router/trie-router'; +import type { Handler } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { cors } from 'hono/cors'; +import { HttpStatusError } from '@/misc/http-status-error.js'; import type { Config } from '@/config.js'; import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -18,7 +22,8 @@ import { ApiCallService } from './ApiCallService.js'; import { SignupApiService } from './SignupApiService.js'; import { SigninApiService } from './SigninApiService.js'; import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import type { ApiContext, ApiEnv, ApiMultipartData } from './ApiServerTypes.js'; +import type { IEndpoint } from './endpoints.js'; @Injectable() export class ApiServerService { @@ -44,106 +49,195 @@ export class ApiServerService { } @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.register(cors, { - origin: '*', - }); + private async parseJsonBody(ctx: ApiContext): Promise | Response> { + try { + const parsed = await ctx.req.json(); + if (parsed == null || Array.isArray(parsed) || typeof parsed !== 'object') { + return ctx.body(null, 400); + } - fastify.register(multipart, { - limits: { - fileSize: this.config.maxFileSize, - files: 1, - }, - }); + return parsed as Record; + } catch { + return ctx.body(null, 400); + } + } - // Prevent cache - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - done(); - }); + @bindThis + private async parseMultipartBody(ctx: ApiContext): Promise { + try { + const body = await ctx.req.parseBody({ all: true }); + let file: File | null = null; + const fields: Record = {}; - 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, + for (const [key, rawValue] of Object.entries(body)) { + const values = Array.isArray(rawValue) ? rawValue : [rawValue]; + const files = values.filter((value): value is File => value instanceof File); + if (files.length > 0) { + if (file != null || files.length !== 1 || values.length !== 1) { + return ctx.body(null, 400); + } + + file = files[0]; + continue; + } + + fields[key] = values.length === 1 ? values[0] : values; + } + + if (file == null) { + return null; + } + + return { + filename: file.name, + file: Readable.fromWeb(file.stream()), + truncated: false, + fields, }; + } catch { + return ctx.body(null, 400); + } + } - if (endpoint.meta.requireFile) { - fastify.all<{ - Params: { endpoint: string; }, - Body: Record, - Querystring: Record, - }>('/' + endpoint.name, async (request, reply) => { - if (request.method === 'GET' && !endpoint.meta.allowGet) { - reply.code(405); - reply.send(); - return; - } + @bindThis + private finalize(ctx: ApiContext, result: unknown): Response { + if (result instanceof Response) { + return result; + } - // Await so that any error can automatically be translated to HTTP 500 - await this.apiCallService.handleMultipartRequest(ep, request, reply); - return reply; - }); - } else { - fastify.all<{ - Params: { endpoint: string; }, - Body: Record, - Querystring: Record, - }>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => { - if (request.method === 'GET' && !endpoint.meta.allowGet) { - reply.code(405); - reply.send(); - return; - } + const status = ctx.res.status === 200 ? 200 : ctx.res.status; - // Await so that any error can automatically be translated to HTTP 500 - await this.apiCallService.handleRequest(ep, request, reply); - return reply; - }); + if (result == null) { + return ctx.body(null, status as never); + } + + if (typeof result === 'string') { + if (ctx.res.headers.get('Content-Type') == null) { + ctx.header('Content-Type', 'application/json'); } + return ctx.body(result, status as never); } - fastify.post<{ - Body: { - username: string; - password: string; - host?: string; - invitationCode?: string; - emailAddress?: string; - 'hcaptcha-response'?: string; - 'g-recaptcha-response'?: string; - 'turnstile-response'?: string; - 'm-captcha-response'?: string; - 'testcaptcha-response'?: string; + return ctx.json(result, status as never); + } + + @bindThis + private async invoke(ctx: ApiContext, handler: () => Promise): Promise { + try { + return this.finalize(ctx, await handler()); + } catch (err) { + if (err instanceof HttpStatusError) { + return ctx.body(err.message, err.statusCode as never); + } + + throw err; + } + } + + @bindThis + public createServer(): Hono { + const hono = new Hono({ + router: new TrieRouter(), + }); + + const jsonBodyLimit = bodyLimit({ + maxSize: 1024 * 1024, + onError: (ctx) => ctx.body(null, 413), + }); + + const multipartBodyLimit = bodyLimit({ + maxSize: this.config.maxFileSize, + onError: (ctx) => ctx.body(null, 413), + }); + + hono.use('*', cors({ + origin: '*', + })); + + hono.use('*', async (ctx, next) => { + ctx.header('Cache-Control', 'private, max-age=0, must-revalidate'); + await next(); + }); + + hono.use('*', async (ctx, next) => { + if (ctx.req.method === 'GET') { + return await next(); + } + + const contentType = ctx.req.header('Content-Type') || ''; + + if (contentType.includes('multipart/form-data')) { + return await multipartBodyLimit(ctx, next); + } else { + return await jsonBodyLimit(ctx, next); } - }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); - - fastify.post<{ - Body: { - username: string; - password?: string; - token?: string; - credential?: AuthenticationResponseJSON; - 'hcaptcha-response'?: string; - 'g-recaptcha-response'?: string; - 'turnstile-response'?: string; - 'm-captcha-response'?: string; - 'testcaptcha-response'?: string; + }); + + for (const endpoint of endpoints) { + const handler = async (ctx: ApiContext) => { + if (ctx.req.method === 'GET' && !endpoint.meta.allowGet) { + return ctx.body(null, 405); + } + + const exec = this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec; + const ep = { + name: endpoint.name, + meta: endpoint.meta, + params: endpoint.params, + exec, + } satisfies IEndpoint & { exec: any }; + + if (endpoint.meta.requireFile) { + const multipartData = await this.parseMultipartBody(ctx); + if (multipartData instanceof Response) return multipartData; + if (multipartData == null) return ctx.body(null, 400); + + return await this.apiCallService.handleMultipartRequest(ep, ctx, multipartData); + } else { + const parsedBody = ctx.req.method === 'GET' ? undefined : await this.parseJsonBody(ctx); + if (parsedBody instanceof Response) return parsedBody; + + return await this.apiCallService.handleRequest(ep, ctx, parsedBody); + } }; - }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); - fastify.post<{ - Body: { - credential?: AuthenticationResponseJSON; - context?: string; + const registerRoute = (path: string, handler: Handler) => { + hono.post(path, handler); + + // GET が許可されている場合のみ GET も登録 + if (endpoint.meta.allowGet) { + hono.get(path, handler); + } }; - }>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply)); - fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply)); + registerRoute('/' + endpoint.name, handler); + } + + hono.post('/signup', jsonBodyLimit, async (ctx) => { + const body = await this.parseJsonBody(ctx); + if (body instanceof Response) return body; + return await this.invoke(ctx, async () => await this.signupApiService.signup(ctx, body)); + }); + + hono.post('/signin-flow', jsonBodyLimit, async (ctx) => { + const body = await this.parseJsonBody(ctx); + if (body instanceof Response) return body; + return await this.invoke(ctx, async () => await this.signinApiService.signin(ctx, body)); + }); + + hono.post('/signin-with-passkey', jsonBodyLimit, async (ctx) => { + const body = await this.parseJsonBody(ctx); + if (body instanceof Response) return body; + return await this.invoke(ctx, async () => await this.signinWithPasskeyApiService.signin(ctx, body)); + }); - fastify.get('/v1/instance/peers', async (request, reply) => { + hono.post('/signup-pending', jsonBodyLimit, async (ctx) => { + const body = await this.parseJsonBody(ctx); + if (body instanceof Response) return body; + return await this.invoke(ctx, async () => await this.signupApiService.signupPending(ctx, body)); + }); + + hono.get('/v1/instance/peers', async (ctx) => { const instances = await this.instancesRepository.find({ select: { host: true }, where: { @@ -151,12 +245,12 @@ export class ApiServerService { }, }); - return instances.map(instance => instance.host); + return ctx.json(instances.map(instance => instance.host)); }); - fastify.post<{ Params: { session: string; } }>('/miauth/:session/check', async (request, reply) => { + hono.post('/miauth/:session/check', async (ctx) => { const token = await this.accessTokensRepository.findOneBy({ - session: request.params.session, + session: ctx.req.param('session'), }); if (token && token.session != null && !token.fetched) { @@ -164,45 +258,37 @@ export class ApiServerService { fetched: true, }); - return { + return ctx.json({ ok: true, token: token.token, user: await this.userEntityService.pack(token.userId, null, { schema: 'UserDetailedNotMe' }), - }; + }); } else { - return { + return ctx.json({ ok: false, - }; + }); } }); - fastify.all('/clear-browser-cache', (request, reply) => { - if (['GET', 'POST'].includes(request.method)) { - reply.header('Clear-Site-Data', '"cache", "prefetchCache", "prerenderCache", "executionContexts"'); - reply.code(204); - reply.send(); - } else { - reply.code(405); - reply.send(); - } + hono.on(['GET', 'POST'], '/clear-browser-cache', (ctx) => { + ctx.header('Clear-Site-Data', '"cache", "prefetchCache", "prerenderCache", "executionContexts"'); + return ctx.body(null, 204); }); // Make sure any unknown path under /api returns HTTP 404 Not Found, // because otherwise ClientServerService will return the base client HTML // page with HTTP 200. - fastify.get('/*', (request, reply) => { - reply.code(404); - // Mock ApiCallService.send's error handling - reply.send({ + hono.all('/*', (ctx) => { + return ctx.json({ error: { message: 'Unknown API endpoint.', code: 'UNKNOWN_API_ENDPOINT', id: '2ca3b769-540a-4f08-9dd5-b5a825b6d0f1', kind: 'client', }, - }); + }, 404); }); - done(); + return hono; } } diff --git a/packages/backend/src/server/api/ApiServerTypes.ts b/packages/backend/src/server/api/ApiServerTypes.ts new file mode 100644 index 00000000000..81fd9fd935c --- /dev/null +++ b/packages/backend/src/server/api/ApiServerTypes.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Readable } from 'node:stream'; +import type { Context } from 'hono'; + +export type ApiEnv = { Variables: { ip: string; ips: string[] } }; + +export type ApiContext = Context; + +export interface ApiMultipartData { + filename: string; + file: Readable; + truncated: boolean; + fields: Record; +} diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 1bc2a66bea0..82a66abda5c 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -25,11 +25,11 @@ import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; +import { HttpStatusError } from '@/misc/http-status-error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/server'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { ApiContext } from './ApiServerTypes.js'; @Injectable() export class SigninApiService { @@ -67,42 +67,39 @@ export class SigninApiService { @bindThis public async signin( - request: FastifyRequest<{ - Body: { - username: string; - password?: string; - token?: string; - credential?: AuthenticationResponseJSON; - 'hcaptcha-response'?: string; - 'g-recaptcha-response'?: string; - 'turnstile-response'?: string; - 'm-captcha-response'?: string; - 'testcaptcha-response'?: string; - }; - }>, - reply: FastifyReply, + ctx: ApiContext, + body: { + username?: string; + password?: string; + token?: string; + credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; + }, ) { - reply.header('Access-Control-Allow-Origin', this.config.url); - reply.header('Access-Control-Allow-Credentials', 'true'); + ctx.header('Access-Control-Allow-Origin', this.config.url); + ctx.header('Access-Control-Allow-Credentials', 'true'); - const body = request.body; const username = body['username']; const password = body['password']; const token = body['token']; function error(status: number, error: { id: string }) { - reply.code(status); + ctx.status(status as never); return { error }; } // not more than 1 attempt per second and not more than 10 attempts per hour if (this.config.enableIpRateLimit) { - if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + if (process.env.NODE_ENV === 'production' && (ctx.var.ip === '::1' || ctx.var.ip === '127.0.0.1')) { this.logger.warn('Recieved signin request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); } - const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); + const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.var.ip)); if (rateLimit != null) { - reply.code(429); + ctx.status(429); return { error: { message: 'Too many failed attempts to sign in. Try again later.', @@ -114,12 +111,12 @@ export class SigninApiService { } if (typeof username !== 'string') { - reply.code(400); + ctx.status(400); return; } if (token != null && typeof token !== 'string') { - reply.code(400); + ctx.status(400); return; } @@ -145,7 +142,7 @@ export class SigninApiService { const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1); if (password == null) { - reply.code(200); + ctx.status(200); if (profile.twoFactorEnabled) { return { finished: false, @@ -160,7 +157,7 @@ export class SigninApiService { } if (typeof password !== 'string') { - reply.code(400); + ctx.status(400); return; } @@ -172,8 +169,8 @@ export class SigninApiService { await this.signinsRepository.insert({ id: this.idService.gen(), userId: user.id, - ip: request.ip, - headers: request.headers as any, + ip: ctx.var.ip, + headers: ctx.req.header() as any, success: false, }); @@ -184,37 +181,37 @@ export class SigninApiService { if (process.env.NODE_ENV !== 'test') { if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableTestcaptcha) { await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } } if (same) { - return this.signinService.signin(request, reply, user); + return this.signinService.signin(ctx, user); } else { return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', @@ -237,7 +234,7 @@ export class SigninApiService { }); } - return this.signinService.signin(request, reply, user); + return this.signinService.signin(ctx, user); } else if (body.credential) { if (!same && !profile.usePasswordLessLogin) { return await fail(403, { @@ -248,7 +245,7 @@ export class SigninApiService { const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential); if (authorized) { - return this.signinService.signin(request, reply, user); + return this.signinService.signin(ctx, user); } else { return await fail(403, { id: '93b86c4b-72f9-40eb-9815-798928603d1e', @@ -263,7 +260,7 @@ export class SigninApiService { const authRequest = await this.webAuthnService.initiateAuthentication(user.id); - reply.code(200); + ctx.status(200); return { finished: false, next: 'passkey', @@ -275,7 +272,7 @@ export class SigninApiService { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); } else { - reply.code(200); + ctx.status(200); return { finished: false, next: 'totp', diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index 640356b50c6..600f64b482b 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -14,7 +14,7 @@ import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; import { bindThis } from '@/decorators.js'; import { EmailService } from '@/core/EmailService.js'; import { NotificationService } from '@/core/NotificationService.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { ApiContext } from './ApiServerTypes.js'; @Injectable() export class SigninService { @@ -34,15 +34,15 @@ export class SigninService { } @bindThis - public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) { + public signin(ctx: ApiContext, user: MiLocalUser) { setImmediate(async () => { this.notificationService.createNotification(user.id, 'login', {}); const record = await this.signinsRepository.insertOne({ id: this.idService.gen(), userId: user.id, - ip: request.ip, - headers: request.headers as any, + ip: ctx.var.ip, + headers: ctx.req.header() as any, success: true, }); @@ -56,7 +56,7 @@ export class SigninService { } }); - reply.code(200); + ctx.status(200); return { finished: true, id: user.id, diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index 4ae8596d273..9d5eafeac78 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -24,7 +24,7 @@ import type { IdentifiableError } from '@/misc/identifiable-error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/server'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { ApiContext } from './ApiServerTypes.js'; @Injectable() export class SigninWithPasskeyApiService { @@ -53,22 +53,19 @@ export class SigninWithPasskeyApiService { @bindThis public async signin( - request: FastifyRequest<{ - Body: { - credential?: AuthenticationResponseJSON; - context?: string; - }; - }>, - reply: FastifyReply, + ctx: ApiContext, + body: { + credential?: AuthenticationResponseJSON; + context?: string; + }, ) { - reply.header('Access-Control-Allow-Origin', this.config.url); - reply.header('Access-Control-Allow-Credentials', 'true'); + ctx.header('Access-Control-Allow-Origin', this.config.url); + ctx.header('Access-Control-Allow-Credentials', 'true'); - const body = request.body; const credential = body['credential']; function error(status: number, error: { id: string }) { - reply.code(status); + ctx.status(status as never); return { error }; } @@ -77,24 +74,24 @@ export class SigninWithPasskeyApiService { await this.signinsRepository.insert({ id: this.idService.gen(), userId: userId, - ip: request.ip, - headers: request.headers as any, + ip: ctx.var.ip, + headers: ctx.req.header() as any, success: false, }); return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); }; if (this.config.enableIpRateLimit) { - if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + if (process.env.NODE_ENV === 'production' && (ctx.var.ip === '::1' || ctx.var.ip === '127.0.0.1')) { this.logger.warn('Recieved signin with passkey request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); } try { // Not more than 1 API call per 250ms and not more than 100 attempts per 30min // NOTE: 1 Sign-in require 2 API calls - await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); + await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(ctx.var.ip)); } catch (_) { - reply.code(429); + ctx.status(429); return { error: { message: 'Too many failed attempts to sign in. Try again later.', @@ -113,7 +110,7 @@ export class SigninWithPasskeyApiService { option: await this.webAuthnService.initiateSignInWithPasskeyAuthentication(context), context: context, }; - reply.code(200); + ctx.status(200); return authChallengeOptions; } @@ -171,7 +168,7 @@ export class SigninWithPasskeyApiService { }); } - const signinResponse = this.signinService.signin(request, reply, user); + const signinResponse = this.signinService.signin(ctx, user); return { signinResponse: signinResponse, }; diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index b419c51ef10..26d5c6d26af 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -15,11 +15,11 @@ import { SignupService } from '@/core/SignupService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { EmailService } from '@/core/EmailService.js'; import { MiLocalUser } from '@/models/User.js'; -import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; +import { HttpStatusError } from '@/misc/http-status-error.js'; import { bindThis } from '@/decorators.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { SigninService } from './SigninService.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { ApiContext } from './ApiServerTypes.js'; @Injectable() export class SignupApiService { @@ -56,54 +56,50 @@ export class SignupApiService { @bindThis 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; - 'm-captcha-response'?: string; - 'testcaptcha-response'?: string; - } - }>, - reply: FastifyReply, + ctx: ApiContext, + body: { + username?: string; + password?: string; + host?: string; + invitationCode?: string; + emailAddress?: string; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; + }, ) { - const body = request.body; - // Verify *Captcha // ただしテスト時はこの機構は障害となるため無効にする if (process.env.NODE_ENV !== 'test') { if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } if (this.meta.enableTestcaptcha) { await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { - throw new FastifyReplyError(400, err); + throw new HttpStatusError(400, err); }); } } @@ -114,15 +110,25 @@ export class SignupApiService { const invitationCode = body['invitationCode']; const emailAddress = body['emailAddress']; + if (typeof username !== 'string' || typeof password !== 'string') { + ctx.status(400); + return; + } + + if (host != null && typeof host !== 'string') { + ctx.status(400); + return; + } + if (this.meta.emailRequiredForSignup) { if (emailAddress == null || typeof emailAddress !== 'string') { - reply.code(400); + ctx.status(400); return; } const res = await this.emailService.validateEmailForAccount(emailAddress); if (!res.available) { - reply.code(400); + ctx.status(400); return; } } @@ -132,7 +138,7 @@ export class SignupApiService { // テスト時はこの機構は障害となるため無効にする if (process.env.NODE_ENV !== 'test' && this.meta.disableRegistration) { if (invitationCode == null || typeof invitationCode !== 'string') { - reply.code(400); + ctx.status(400); return; } @@ -141,12 +147,12 @@ export class SignupApiService { }); if (ticket == null || ticket.usedById != null) { - reply.code(400); + ctx.status(400); return; } if (ticket.expiresAt && ticket.expiresAt < new Date()) { - reply.code(400); + ctx.status(400); return; } @@ -154,34 +160,34 @@ export class SignupApiService { if (this.meta.emailRequiredForSignup) { // メアド認証済みならエラー if (ticket.usedBy) { - reply.code(400); + ctx.status(400); return; } // 認証しておらず、メール送信から30分以内ならエラー if (ticket.usedAt && ticket.usedAt.getTime() + (1000 * 60 * 30) > Date.now()) { - reply.code(400); + ctx.status(400); return; } } else if (ticket.usedAt) { - reply.code(400); + ctx.status(400); return; } } if (this.meta.emailRequiredForSignup) { if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { - throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); + throw new HttpStatusError(400, 'DUPLICATED_USERNAME'); } // Check deleted username duplication if (await this.usedUsernamesRepository.exists({ where: { username: username.toLowerCase() } })) { - throw new FastifyReplyError(400, 'USED_USERNAME'); + throw new HttpStatusError(400, 'USED_USERNAME'); } const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); if (isPreserved) { - throw new FastifyReplyError(400, 'DENIED_USERNAME'); + throw new HttpStatusError(400, 'DENIED_USERNAME'); } const code = secureRndstr(16, { chars: L_CHARS }); @@ -211,7 +217,7 @@ export class SignupApiService { }); } - reply.code(204); + ctx.status(204); return; } else { try { @@ -237,22 +243,20 @@ export class SignupApiService { token: secret, }; } catch (err) { - throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); + throw new HttpStatusError(400, typeof err === 'string' ? err : (err as Error).toString()); } } } @bindThis - public async signupPending(request: FastifyRequest<{ Body: { code: string; } }>, reply: FastifyReply) { - const body = request.body; - + public async signupPending(ctx: ApiContext, body: { code?: string; }) { const code = body['code']; try { const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code }); if (this.idService.parse(pendingUser.id).date.getTime() + (1000 * 60 * 30) < Date.now()) { - throw new FastifyReplyError(400, 'EXPIRED'); + throw new HttpStatusError(400, 'EXPIRED'); } const { account } = await this.signupService.signup({ @@ -281,9 +285,9 @@ export class SignupApiService { }); } - return this.signinService.signin(request, reply, account as MiLocalUser); + return this.signinService.signin(ctx, account as MiLocalUser); } catch (err) { - throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); + throw new HttpStatusError(400, typeof err === 'string' ? err : (err as Error).toString()); } } } diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 8a317bdc4ea..e136cb63c70 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -7,6 +7,7 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; +import * as net from 'node:net'; import { DI } from '@/di-symbols.js'; import type { MiAccessToken } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; @@ -17,11 +18,20 @@ import MainStreamConnection, { ConnectionRequest } from './stream/Connection.js' import type * as http from 'node:http'; import { ContextIdFactory, ModuleRef } from '@nestjs/core'; +type StreamingContext = { + stream: MainStreamConnection; + user: MiLocalUser | null; + app: MiAccessToken | null; +}; + @Injectable() export class StreamingApiServerService { - #wss: WebSocket.WebSocketServer; + #wss: WebSocket.WebSocketServer | null = null; #connections = new Map(); + #pendingConnections = new WeakMap(); #cleanConnectionsIntervalId: NodeJS.Timeout | null = null; + #globalEv: EventEmitter | null = null; + #initialized = false; constructor( @Inject(DI.redisForSub) @@ -34,83 +44,105 @@ export class StreamingApiServerService { } @bindThis - public attach(server: http.Server): void { - this.#wss = new WebSocket.WebSocketServer({ - noServer: true, - }); + public createWebSocketServer(): WebSocket.WebSocketServer { + this.initialize(); + return this.#wss!; + } - server.on('upgrade', async (request, socket, head) => { - if (request.url == null) { - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + @bindThis + public async handleUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise { + this.initialize(); + + const url = new URL(req.url ?? '', `http://${req.headers['host'] ?? 'localhost'}`); + + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 + // Note that the standard WHATWG WebSocket API does not support setting any headers, + // but non-browser apps may still be able to set it. + const authorization = req.headers['authorization']; + const token = (typeof authorization === 'string' && authorization.startsWith('Bearer ')) + ? authorization.slice(7) + : url.searchParams.get('i'); + + let user: MiLocalUser | null = null; + let app: MiAccessToken | null = null; + + try { + [user, app] = await this.authenticateService.authenticate(token); + + if (app !== null && !app.permission.some(p => p === 'read:account')) { + socket.write( + 'HTTP/1.1 401 Unauthorized\r\n' + + 'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Your app does not have necessary permissions to use websocket API."\r\n' + + 'Connection: close\r\n\r\n' + ); socket.destroy(); return; } - - const q = new URL(request.url, `http://${request.headers.host}`).searchParams; - - let user: MiLocalUser | null = null; - let app: MiAccessToken | null = null; - - // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 - // Note that the standard WHATWG WebSocket API does not support setting any headers, - // but non-browser apps may still be able to set it. - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) - : q.get('i'); - - try { - [user, app] = await this.authenticateService.authenticate(token); - - if (app !== null && !app.permission.some(p => p === 'read:account')) { - throw new AuthenticationError('Your app does not have necessary permissions to use websocket API.'); - } - } catch (e) { - if (e instanceof AuthenticationError) { - socket.write([ - 'HTTP/1.1 401 Unauthorized', - 'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"', - ].join('\r\n') + '\r\n\r\n'); - } else { - socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); - } - socket.destroy(); - return; - } - - if (user?.isSuspended) { - socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); - socket.destroy(); - return; + } catch (e) { + if (e instanceof AuthenticationError) { + socket.write( + 'HTTP/1.1 401 Unauthorized\r\n' + + 'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"\r\n' + + 'Connection: close\r\n\r\n' + ); + } else { + socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n'); } + socket.destroy(); + return; + } - const contextId = ContextIdFactory.create(); - this.moduleRef.registerRequestByContextId({ - user, - token: app, - }, contextId); - const stream = await this.moduleRef.create(MainStreamConnection, contextId); + if (user?.isSuspended) { + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } - await stream.init(); + const contextId = ContextIdFactory.create(); + this.moduleRef.registerRequestByContextId({ + user, + token: app, + }, contextId); + const stream = await this.moduleRef.create(MainStreamConnection, contextId); + await stream.init(); + + this.#pendingConnections.set(req, { + stream, + user, + app, + }); - this.#wss.handleUpgrade(request, socket, head, (ws) => { - this.#wss.emit('connection', ws, request, { - stream, user, app, - }); - }); + this.#wss!.handleUpgrade(req, socket, head, (ws) => { + this.#wss!.emit('connection', ws, req); }); + } + + @bindThis + private initialize(): void { + if (this.#initialized) { + return; + } - const globalEv = new EventEmitter(); + this.#initialized = true; + this.#wss = new WebSocket.WebSocketServer({ + noServer: true, + }); + this.#globalEv = new EventEmitter(); this.redisForSub.on('message', (_: string, data: string) => { const parsed = JSON.parse(data); - globalEv.emit('message', parsed); + this.#globalEv!.emit('message', parsed); }); - this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: { - stream: MainStreamConnection, - user: MiLocalUser | null; - app: MiAccessToken | null - }) => { + this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage) => { + const ctx = this.#pendingConnections.get(request); + if (ctx == null) { + connection.close(); + return; + } + + this.#pendingConnections.delete(request); + const { stream, user } = ctx; const ev = new EventEmitter(); @@ -119,7 +151,7 @@ export class StreamingApiServerService { ev.emit(data.channel, data.message); } - globalEv.on('message', onRedisMessage); + this.#globalEv!.on('message', onRedisMessage); await stream.listen(ev, connection); @@ -135,7 +167,7 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); stream.dispose(); - globalEv.off('message', onRedisMessage); + this.#globalEv!.off('message', onRedisMessage); this.#connections.delete(connection); if (userUpdateIntervalId) clearInterval(userUpdateIntervalId); }); @@ -161,12 +193,16 @@ export class StreamingApiServerService { @bindThis public detach(): Promise { + if (this.#wss == null) { + return Promise.resolve(); + } + if (this.#cleanConnectionsIntervalId) { clearInterval(this.#cleanConnectionsIntervalId); this.#cleanConnectionsIntervalId = null; } return new Promise((resolve) => { - this.#wss.close(() => resolve()); + this.#wss!.close(() => resolve()); }); } } diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 2f8322a5689..5ac31017996 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -2,15 +2,16 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import type { StatusCode } from 'hono/utils/http-status'; -type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number }; +type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: StatusCode }; export class ApiError extends Error { public message: string; public code: string; public id: string; public kind: string; - public httpStatusCode?: number; + public httpStatusCode?: StatusCode; public info?: any; constructor(err?: E | null | undefined, info?: any | null | undefined) { diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts index 24fc46e4ba5..95348f5739e 100644 --- a/packages/backend/src/server/api/openapi/OpenApiServerService.ts +++ b/packages/backend/src/server/api/openapi/OpenApiServerService.ts @@ -4,12 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Hono } from 'hono'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { genOpenapiSpec } from './gen-spec.js'; import { ApiDocPage } from './api-doc.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() export class OpenApiServerService { @@ -20,16 +20,19 @@ export class OpenApiServerService { } @bindThis - public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.get('/api-doc', async (_request, reply) => { - reply.header('Cache-Control', 'public, max-age=86400'); - reply.type('text/html; charset=utf-8'); - reply.send(await ApiDocPage()); + public createServer(): Hono { + const hono = new Hono(); + + hono.get('/api-doc', (ctx) => { + ctx.header('Cache-Control', 'public, max-age=86400'); + return ctx.html(ApiDocPage()); }); - fastify.get('/api.json', (_request, reply) => { - reply.header('Cache-Control', 'public, max-age=600'); - reply.send(genOpenapiSpec(this.config)); + + hono.get('/api.json', (ctx) => { + ctx.header('Cache-Control', 'public, max-age=600'); + return ctx.json(genOpenapiSpec(this.config)); }); - done(); + + return hono; } } diff --git a/packages/backend/src/server/api/openapi/api-doc.tsx b/packages/backend/src/server/api/openapi/api-doc.tsx index 663d9f5be3f..f60dda5df0e 100644 --- a/packages/backend/src/server/api/openapi/api-doc.tsx +++ b/packages/backend/src/server/api/openapi/api-doc.tsx @@ -2,11 +2,14 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import { raw } from 'hono/utils/html'; export function ApiDocPage() { + const doctypeTag = raw(''); + return ( <> - {''} + {doctypeTag} diff --git a/packages/backend/src/server/file/FileServerDriveHandler.ts b/packages/backend/src/server/file/FileServerDriveHandler.ts index 51b527b1468..05d3449133a 100644 --- a/packages/backend/src/server/file/FileServerDriveHandler.ts +++ b/packages/backend/src/server/file/FileServerDriveHandler.ts @@ -3,7 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as fs from 'node:fs'; +import { Readable } from 'node:stream'; +import { resolve } from 'node:path'; +import { promises as fsp } from 'node:fs'; import rename from 'rename'; import type { Config } from '@/config.js'; import type { IImageStreamable } from '@/core/ImageProcessingService.js'; @@ -11,9 +13,10 @@ import { contentDisposition } from '@/misc/content-disposition.js'; import { correctFilename } from '@/misc/correct-filename.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { VideoProcessingService } from '@/core/VideoProcessingService.js'; -import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, getSafeContentType, needsCleanup } from './FileServerUtils.js'; +import { bindThis } from '@/decorators.js'; +import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, getSafeContentType, nodeStreamToWebStream, bufferToWebStream } from './FileServerUtils.js'; import type { FileServerFileResolver } from './FileServerFileResolver.js'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { Context as HonoContext } from 'hono'; export class FileServerDriveHandler { constructor( @@ -23,20 +26,26 @@ export class FileServerDriveHandler { private videoProcessingService: VideoProcessingService, ) {} - public async handle(request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) { - const key = request.params.key; + @bindThis + public async handle(ctx: HonoContext): Promise { + const key = ctx.req.param('key'); + if (key == null) { + return ctx.text('Bad Request', 400); + } + const file = await this.fileResolver.resolveFileByAccessKey(key); if (file.kind === 'not-found') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', this.assetsPath); + ctx.header('Cache-Control', 'public, max-age=0'); + const fileBuffer = await fsp.readFile(resolve(this.assetsPath, 'dummy.png')); + ctx.header('Content-Type', 'image/png'); + ctx.header('Content-Length', fileBuffer.length.toString()); + return ctx.body(bufferToWebStream(fileBuffer), 404); } if (file.kind === 'unavailable') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; + ctx.header('Cache-Control', 'max-age=86400'); + return ctx.body(null, 204); } try { @@ -45,19 +54,19 @@ export class FileServerDriveHandler { if (file.fileRole === 'thumbnail') { if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); + ctx.header('Cache-Control', 'max-age=31536000, immutable'); const url = new URL(`${this.config.mediaProxy}/static.webp`); url.searchParams.set('url', file.url); url.searchParams.set('static', '1'); file.cleanup(); - return await reply.redirect(url.toString(), 301); + return ctx.redirect(url.toString(), 301); } else if (file.mime.startsWith('video/')) { const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); if (externalThumbnail) { file.cleanup(); - return await reply.redirect(externalThumbnail, 301); + return ctx.redirect(externalThumbnail, 301); } image = await this.videoProcessingService.generateVideoThumbnail(file.path); @@ -66,34 +75,29 @@ export class FileServerDriveHandler { if (file.fileRole === 'webpublic') { if (['image/svg+xml'].includes(file.mime)) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); + ctx.header('Cache-Control', 'max-age=31536000, immutable'); const url = new URL(`${this.config.mediaProxy}/svg.webp`); url.searchParams.set('url', file.url); file.cleanup(); - return await reply.redirect(url.toString(), 301); + return ctx.redirect(url.toString(), 301); } } image ??= { - data: handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path), + data: handleRangeRequest(ctx, file.file.size, file.path), ext: file.ext, type: file.mime, }; attachStreamCleanup(image.data, file.cleanup); - reply.header('Content-Type', getSafeContentType(image.type)); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; + ctx.header('Content-Type', getSafeContentType(image.type)); + ctx.header('Content-Length', file.file.size.toString()); + ctx.header('Cache-Control', 'max-age=31536000, immutable'); + ctx.header('Content-Disposition', contentDisposition('inline', correctFilename(file.filename, image.ext))); + return ctx.body(image.data instanceof Readable ? nodeStreamToWebStream(image.data) : bufferToWebStream(image.data)); } if (file.fileRole !== 'original') { @@ -102,11 +106,11 @@ export class FileServerDriveHandler { extname: file.ext ? `.${file.ext}` : '.unknown', }).toString(); - setFileResponseHeaders(reply, { mime: file.mime, filename }); - return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + setFileResponseHeaders(ctx, { mime: file.mime, filename }); + return ctx.body(nodeStreamToWebStream(handleRangeRequest(ctx, file.file.size, file.path))); } else { - setFileResponseHeaders(reply, { mime: file.file.type, filename: file.filename, size: file.file.size }); - return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + setFileResponseHeaders(ctx, { mime: file.file.type, filename: file.filename, size: file.file.size }); + return ctx.body(nodeStreamToWebStream(handleRangeRequest(ctx, file.file.size, file.path))); } } catch (e) { if (file.kind === 'remote') file.cleanup(); diff --git a/packages/backend/src/server/file/FileServerProxyHandler.ts b/packages/backend/src/server/file/FileServerProxyHandler.ts index 41e8e47ba52..0bd748057fd 100644 --- a/packages/backend/src/server/file/FileServerProxyHandler.ts +++ b/packages/backend/src/server/file/FileServerProxyHandler.ts @@ -4,6 +4,8 @@ */ import * as fs from 'node:fs'; +import { Readable } from 'node:stream'; +import { resolve } from 'node:path'; import sharp from 'sharp'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import type { Config } from '@/config.js'; @@ -12,10 +14,11 @@ import { StatusError } from '@/misc/status-error.js'; import { contentDisposition } from '@/misc/content-disposition.js'; import { correctFilename } from '@/misc/correct-filename.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { bindThis } from '@/decorators.js'; import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import { createRangeStream, attachStreamCleanup, needsCleanup } from './FileServerUtils.js'; +import { createRangeStream, attachStreamCleanup, needsCleanup, nodeStreamToWebStream, bufferToWebStream } from './FileServerUtils.js'; import type { DownloadedFileResult, FileResolveResult, FileServerFileResolver } from './FileServerFileResolver.js'; -import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { Context as HonoContext } from 'hono'; type ProxySource = DownloadedFileResult | FileResolveResult; type CleanupableFile = ProxySource & { cleanup: () => void }; @@ -38,53 +41,50 @@ export class FileServerProxyHandler { private imageProcessingService: ImageProcessingService, ) {} - public async handle(request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, reply: FastifyReply) { - const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + @bindThis + public async handle(ctx: HonoContext): Promise { + const url = ctx.req.query('url') || `https://${ctx.req.param('url')}`; if (typeof url !== 'string') { - reply.code(400); - return; + return ctx.body(null, 400); } // アバタークロップなど、どうしてもオリジンである必要がある場合 - const mustOrigin = 'origin' in request.query; + const mustOrigin = ctx.req.query('origin') != null; if (this.config.externalMediaProxyEnabled && !mustOrigin) { - return await this.redirectToExternalProxy(request, reply); + return await this.redirectToExternalProxy(ctx); } - this.validateUserAgent(request); + this.validateUserAgent(ctx); // Create temp file const file = await this.getStreamAndTypeFromUrl(url); if (file.kind === 'not-found') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', this.assetsPath); + ctx.status(404); + ctx.header('Cache-Control', 'max-age=86400'); + const fileBuffer = await fs.promises.readFile(resolve(this.assetsPath, 'not-found.png')); + ctx.header('Content-Type', 'image/png'); + ctx.header('Content-Length', fileBuffer.length.toString()); + return ctx.body(bufferToWebStream(fileBuffer)); } if (file.kind === 'unavailable') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; + ctx.header('Cache-Control', 'max-age=86400'); + return ctx.body(null, 204); } try { - const image = await this.processImage(file, request, reply); + const image = await this.processImage(file, ctx); if (needsCleanup(file)) { attachStreamCleanup(image.data, file.cleanup); } - reply.header('Content-Type', image.type); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; + ctx.header('Content-Type', image.type); + ctx.header('Cache-Control', 'max-age=31536000, immutable'); + ctx.header('Content-Disposition', contentDisposition('inline', correctFilename(file.filename, image.ext))); + return ctx.body(image.data instanceof Readable ? nodeStreamToWebStream(image.data) : bufferToWebStream(image.data)); } catch (e) { if (needsCleanup(file)) file.cleanup(); throw e; @@ -94,29 +94,27 @@ export class FileServerProxyHandler { /** * 外部メディアプロキシにリダイレクトする */ - private async redirectToExternalProxy( - request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, - reply: FastifyReply, - ) { - reply.header('Cache-Control', 'public, max-age=259200'); // 3 days + private async redirectToExternalProxy(ctx: HonoContext) { + ctx.header('Cache-Control', 'public, max-age=259200'); // 3 days - const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); + const url = new URL(`${this.config.mediaProxy}/${ctx.req.param('url') || ''}`); - for (const [key, value] of Object.entries(request.query)) { + for (const [key, value] of Object.entries(ctx.req.query())) { url.searchParams.append(key, value); } - return reply.redirect(url.toString(), 301); + return ctx.redirect(url.toString(), 301); } /** * User-Agent を検証する */ - private validateUserAgent(request: FastifyRequest): void { - if (!request.headers['user-agent']) { + private validateUserAgent(ctx: HonoContext) { + const ua = ctx.req.header('User-Agent'); + if (ua == null || ua.trim() === '') { throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); } - if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { + if (ua.toLowerCase().indexOf('misskey/') !== -1) { throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); } } @@ -126,10 +124,9 @@ export class FileServerProxyHandler { */ private async processImage( file: AvailableFile, - request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, - reply: FastifyReply, + ctx: HonoContext, ): Promise { - const query = request.query; + const query = ctx.req.query(); const requiresImageConversion = 'emoji' in query || 'avatar' in query || 'static' in query || 'preview' in query || 'badge' in query; const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); @@ -161,7 +158,7 @@ export class FileServerProxyHandler { throw new StatusError('Rejected type', 403, 'Rejected type'); } - return this.createDefaultStream(file, request, reply); + return this.createDefaultStream(file, ctx); } /** @@ -234,16 +231,16 @@ export class FileServerProxyHandler { */ private createDefaultStream( file: AvailableFile, - request: FastifyRequest, - reply: FastifyReply, + ctx: HonoContext, ): IImageStreamable { - if (request.headers.range && 'file' in file && file.file.size > 0) { - const { stream, start, end, chunksize } = createRangeStream(request.headers.range as string, file.file.size, file.path); - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); + const rangeHeader = ctx.req.header('Range'); + if (rangeHeader != null && 'file' in file && file.file.size > 0) { + const { stream, start, end, chunksize } = createRangeStream(rangeHeader, file.file.size, file.path); + + ctx.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + ctx.header('Accept-Ranges', 'bytes'); + ctx.header('Content-Length', chunksize.toString()); + ctx.status(206); return { data: stream, diff --git a/packages/backend/src/server/file/FileServerUtils.ts b/packages/backend/src/server/file/FileServerUtils.ts index c5995a2ccaa..ad63a18c234 100644 --- a/packages/backend/src/server/file/FileServerUtils.ts +++ b/packages/backend/src/server/file/FileServerUtils.ts @@ -4,10 +4,11 @@ */ import * as fs from 'node:fs'; +import type { Readable as NodeReadableStream } from 'node:stream'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { contentDisposition } from '@/misc/content-disposition.js'; import type { IImageStreamable } from '@/core/ImageProcessingService.js'; -import type { FastifyReply } from 'fastify'; +import type { Context as HonoContext } from 'hono'; export type RangeStream = { stream: fs.ReadStream; @@ -16,6 +17,33 @@ export type RangeStream = { chunksize: number; }; +/** Node FS Streamから、Web標準のReadableStreamに変換するユーティリティ */ +export function nodeStreamToWebStream(stream: NodeReadableStream): ReadableStream { + return new ReadableStream({ + start(controller) { + stream.on('data', (chunk) => { + controller.enqueue(new Uint8Array(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)); + }); + stream.on('end', () => { + controller.close(); + }); + stream.on('error', (err) => { + controller.error(err); + }); + }, + }); +} + +/** Bufferから、Web標準のReadableStreamに変換するユーティリティ */ +export function bufferToWebStream(data: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(data)); + controller.close(); + }, + }); +} + /** * Range リクエストに対応したストリームを作成する */ @@ -61,17 +89,17 @@ export function getSafeContentType(mime: string): string { * Range ヘッダーがない場合は通常のストリームを返す */ export function handleRangeRequest( - reply: FastifyReply, - rangeHeader: string | undefined, + ctx: HonoContext, size: number, path: string, -): fs.ReadStream { +) { + const rangeHeader = ctx.req.header('Range'); if (rangeHeader && size > 0) { const { stream, start, end, chunksize } = createRangeStream(rangeHeader, size, path); - reply.header('Content-Range', `bytes ${start}-${end}/${size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); + ctx.header('Content-Range', `bytes ${start}-${end}/${size}`); + ctx.header('Accept-Ranges', 'bytes'); + ctx.header('Content-Length', chunksize.toString()); + ctx.status(206); return stream; } return fs.createReadStream(path); @@ -88,14 +116,14 @@ export type FileResponseOptions = { * ファイルレスポンス用の共通ヘッダーを設定する */ export function setFileResponseHeaders( - reply: FastifyReply, + ctx: HonoContext, options: FileResponseOptions, ): void { - reply.header('Content-Type', getSafeContentType(options.mime)); - reply.header('Cache-Control', options.cacheControl ?? 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', options.filename)); + ctx.header('Content-Type', getSafeContentType(options.mime)); + ctx.header('Cache-Control', options.cacheControl ?? 'max-age=31536000, immutable'); + ctx.header('Content-Disposition', contentDisposition('inline', options.filename)); if (options.size !== undefined) { - reply.header('Content-Length', options.size); + ctx.header('Content-Length', options.size.toString()); } } diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 5f940e9c73e..d6870bd6b25 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -5,10 +5,11 @@ import dns from 'node:dns/promises'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { Hono } from 'hono'; +import type { Context as HonoContext } from 'hono'; import * as htmlParser from 'node-html-parser'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; -import fastifyCors from '@fastify/cors'; import { verifyChallenge } from 'pkce-challenge'; import { permissions as kinds } from 'misskey-js'; import { @@ -35,7 +36,6 @@ import Logger from '@/logger.js'; import { StatusError } from '@/misc/status-error.js'; import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; import { OAuthPage } from '@/server/web/views/oauth.js'; -import type { FastifyInstance, FastifyReply } from 'fastify'; // TODO: Consider migrating to @node-oauth/oauth2-server once // https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. @@ -318,9 +318,9 @@ function toRequestParameters(body: unknown): OAuthRequestParameters { ))); } -function applyNoStore(reply: FastifyReply): void { - reply.header('Cache-Control', 'no-store'); - reply.header('Pragma', 'no-cache'); +function applyNoStore(ctx: HonoContext): void { + ctx.header('Cache-Control', 'no-store'); + ctx.header('Pragma', 'no-cache'); } function createUnsupportedResponseTypeError(): OAuthProviderError { @@ -349,10 +349,10 @@ function normalizeOAuthProviderError(error: unknown): OAuthProviderError { return wrapped; } -function sendOAuthProviderError(reply: FastifyReply, error: OAuthProviderError): void { - applyNoStore(reply); - reply.code(error.statusCode ?? error.status ?? 400); - reply.send({ +function sendOAuthProviderError(ctx: HonoContext, error: OAuthProviderError) { + applyNoStore(ctx); + ctx.status(error.statusCode ?? error.status ?? 400); + return ctx.json({ error: error.error, ...(error.expose && error.error_description ? { error_description: error.error_description } : {}), }); @@ -370,29 +370,16 @@ function appendIssuer(payload: Record, issuerUrl: string): Recor }; } -function redirectWithQuery(reply: FastifyReply, redirectUriString: string, payload: Record): void { - applyNoStore(reply); +function redirectWithQuery(ctx: HonoContext, redirectUriString: string, payload: Record) { + applyNoStore(ctx); const redirectUri = new URL(redirectUriString); for (const [key, value] of Object.entries(payload)) { redirectUri.searchParams.set(key, value); } - reply.code(302).redirect(redirectUri.toString()); -} - -function registerFormBodyParser(fastify: FastifyInstance): void { - if (fastify.hasContentTypeParser('application/x-www-form-urlencoded')) { - return; - } - - fastify.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => { - try { - done(null, parseUrlEncodedParameters(typeof body === 'string' ? body : body.toString('utf8'))); - } catch (error) { - done(error as Error, undefined); - } - }); + ctx.status(302); + return ctx.redirect(redirectUri.toString()); } @Injectable() @@ -533,15 +520,14 @@ export class OAuth2ProviderService implements OnApplicationShutdown { } @bindThis - public async createServer(fastify: FastifyInstance): Promise { - registerFormBodyParser(fastify); - - fastify.get('/authorize', async (request, reply) => { + public createServer(): Hono { + const app = new Hono(); + app.get('/authorize', async (ctx) => { let validatedRedirectUri: string | undefined; let state: string | undefined; try { - const seed = await this.#resolveAuthorizationRequest(request.query as OAuthRequestParameters); + const seed = await this.#resolveAuthorizationRequest(ctx.req.query() as OAuthRequestParameters); const { clientInfo } = seed; validatedRedirectUri = seed.redirectUri; state = seed.state; @@ -555,8 +541,8 @@ export class OAuth2ProviderService implements OnApplicationShutdown { this.#logger.info(`Rendering authorization page for "${clientInfo.name}"`); - applyNoStore(reply); - return await HtmlTemplateService.replyHtml(reply, OAuthPage({ + applyNoStore(ctx); + return ctx.html(OAuthPage({ ...await this.htmlTemplateService.getCommonData(), transactionId, clientName: clientInfo.name, @@ -566,20 +552,19 @@ export class OAuth2ProviderService implements OnApplicationShutdown { } catch (error) { const OAuthProviderError = normalizeOAuthProviderError(error); if (validatedRedirectUri && OAuthProviderError.allow_redirect && OAuthProviderError.error !== 'unsupported_response_type') { - redirectWithQuery(reply, validatedRedirectUri, appendIssuer({ + return redirectWithQuery(ctx, validatedRedirectUri, appendIssuer({ error: OAuthProviderError.error, ...(state ? { state } : {}), }, this.config.url)); - return; } - sendOAuthProviderError(reply, OAuthProviderError); + return sendOAuthProviderError(ctx, OAuthProviderError); } }); - fastify.post('/decision', async (request, reply) => { + app.post('/decision', async (ctx) => { try { - const body = toRequestParameters(request.body); + const body = toRequestParameters(await ctx.req.parseBody()); const transactionId = firstValue(body.transaction_id); if (!transactionId) { throw new InvalidRequestError('Missing transaction ID'); @@ -594,7 +579,7 @@ export class OAuth2ProviderService implements OnApplicationShutdown { const cancel = !!firstValue(body.cancel); this.#logger.info(`Received the decision. Cancel: ${cancel}`); if (cancel) { - redirectWithQuery(reply, transaction.request.redirectUri, appendIssuer({ + return redirectWithQuery(ctx, transaction.request.redirectUri, appendIssuer({ error: 'access_denied', ...(transaction.request.state ? { state: transaction.request.state } : {}), }, this.config.url)); @@ -620,38 +605,29 @@ export class OAuth2ProviderService implements OnApplicationShutdown { scopes: transaction.request.scopes, }); - redirectWithQuery(reply, transaction.request.redirectUri, appendIssuer({ + return redirectWithQuery(ctx, transaction.request.redirectUri, appendIssuer({ code, ...(transaction.request.state ? { state: transaction.request.state } : {}), }, this.config.url)); } catch (error) { - sendOAuthProviderError(reply, normalizeOAuthProviderError(error)); + return sendOAuthProviderError(ctx, normalizeOAuthProviderError(error)); } }); - fastify.all('/*', async (_request, reply) => { - reply.code(404); - reply.send({ - error: { - message: 'Unknown OAuth endpoint.', - code: 'UNKNOWN_OAUTH_ENDPOINT', - id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', - kind: 'client', - }, - }); - }); - } - - @bindThis - public async createTokenServer(fastify: FastifyInstance): Promise { - registerFormBodyParser(fastify); - fastify.register(fastifyCors); - - fastify.post('', async (request, reply) => { - applyNoStore(reply); + app.post('/token', async (ctx) => { + applyNoStore(ctx); try { - const body = toRequestParameters(request.body); + let rawBody: unknown; + if (ctx.req.header('content-type')?.startsWith('application/json')) { + rawBody = await ctx.req.json(); + } else if (ctx.req.header('content-type')?.startsWith('application/x-www-form-urlencoded')) { + rawBody = await ctx.req.parseBody(); + } else { + throw new InvalidRequestError('Unsupported content type'); + } + const body = toRequestParameters(rawBody); + const grantType = firstValue(body.grant_type); if (!grantType) { throw new InvalidRequestError('grant_type is required'); @@ -723,15 +699,29 @@ export class OAuth2ProviderService implements OnApplicationShutdown { granted.grantedToken = accessToken; this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); - reply.send({ + return ctx.json({ access_token: accessToken, token_type: 'Bearer', scope: granted.scopes.join(' '), }); } catch (error) { - sendOAuthProviderError(reply, normalizeOAuthProviderError(error)); + return sendOAuthProviderError(ctx, normalizeOAuthProviderError(error)); } }); + + app.all('*', async (ctx) => { + ctx.status(404); + return ctx.json({ + error: { + message: 'Unknown OAuth endpoint.', + code: 'UNKNOWN_OAUTH_ENDPOINT', + id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147', + kind: 'client', + }, + }); + }); + + return app; } @bindThis diff --git a/packages/backend/src/server/oauth/errors.ts b/packages/backend/src/server/oauth/errors.ts index fb7fe9342c1..7a5ea3af079 100644 --- a/packages/backend/src/server/oauth/errors.ts +++ b/packages/backend/src/server/oauth/errors.ts @@ -2,14 +2,15 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import type { StatusCode } from 'hono/utils/http-status'; export class OAuthProviderError extends Error { public error: string; public error_description?: string; public expose = true; public allow_redirect = true; - public status = 400; - public statusCode = 400; + public status: StatusCode = 400; + public statusCode: StatusCode = 400; constructor(error: string, description?: string) { super(description ?? error); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 13ef05e7851..089737a6d8a 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -9,9 +9,10 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import sharp from 'sharp'; import { In, IsNull } from 'typeorm'; -import fastifyStatic from '@fastify/static'; -import fastifyProxy from '@fastify/http-proxy'; -import vary from 'vary'; +import { Hono } from 'hono'; +import { createMiddleware } from 'hono/factory'; +import { proxy } from 'hono/proxy'; +import { serveStatic } from '@hono/node-server/serve-static'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import * as Acct from '@/misc/acct.js'; @@ -34,13 +35,14 @@ import type { UserProfilesRepository, UsersRepository, } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; +import { handleRequestRedirectToOmitSearch } from '@/misc/hono-middleware-handlers.js'; +import { vary } from '@/misc/hono-vary.js'; import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js'; import { bindThis } from '@/decorators.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; +import { ApiError } from '@/server/api/error.js'; import { FeedService } from './FeedService.js'; import { UrlPreviewService } from './UrlPreviewService.js'; import { ClientLoggerService } from './ClientLoggerService.js'; @@ -63,7 +65,7 @@ import { CliPage } from './views/cli.js'; import { FlushPage } from './views/flush.js'; import { ErrorPage } from './views/error.js'; -import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; +import type { Context as HonoContext } from 'hono'; @Injectable() export class ClientServerService { @@ -143,7 +145,7 @@ export class ClientServerService { } @bindThis - private async manifestHandler(reply: FastifyReply) { + private async manifestHandler(ctx: HonoContext) { let manifest = { // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -198,146 +200,138 @@ export class ClientServerService { ...JSON.parse(this.meta.manifestJsonOverride === '' ? '{}' : this.meta.manifestJsonOverride), }; - reply.header('Cache-Control', 'max-age=300'); - return (manifest); + ctx.header('Cache-Control', 'max-age=300'); + return ctx.json(manifest); } @bindThis - public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + public createServer(): Hono { + const hono = new Hono(); const configUrl = new URL(this.config.url); + const staticAssetNotFound = createMiddleware(async (ctx: HonoContext) => ctx.body(null, 404)); + const rewriteStaticPath = (prefix: string) => (path: string) => path.startsWith(prefix) ? path.slice(prefix.length) : path; - fastify.addHook('onRequest', (request, reply, done) => { - // クリックジャッキング防止のためiFrameの中に入れられないようにする - reply.header('X-Frame-Options', 'DENY'); - done(); + hono.use(async (ctx, next) => { + if ( + !ctx.req.path.startsWith('/embed/') && + !ctx.req.path.startsWith('/_info_card_') + ) { + // クリックジャッキング防止のためiFrameの中に入れられないようにする + ctx.header('X-Frame-Options', 'DENY'); + } + + await next(); }); //#region vite assets if (this.config.frontendEmbedManifestExists) { this.clientLoggerService.logger.info(`[ClientServerService] Using built frontend vite assets. ${this.frontendViteOut}`); - fastify.register((fastify, options, done) => { - fastify.register(fastifyStatic, { - root: this.frontendViteOut, - prefix: '/vite/', - maxAge: ms('30 days'), - immutable: true, - decorateReply: false, - }); - fastify.register(fastifyStatic, { - root: this.frontendEmbedViteOut, - prefix: '/embed_vite/', - maxAge: ms('30 days'), - immutable: true, - decorateReply: false, - }); - fastify.addHook('onRequest', handleRequestRedirectToOmitSearch); - done(); - }); + + hono.get('/vite/*', serveStatic({ + root: this.frontendViteOut, + rewriteRequestPath: rewriteStaticPath('/vite'), + onFound: (_, ctx) => { + ctx.header('Cache-Control', `max-age=${ms('30 days') / 1000}, immutable`); + }, + }), handleRequestRedirectToOmitSearch, staticAssetNotFound); + + hono.get('/embed_vite/*', serveStatic({ + root: this.frontendEmbedViteOut, + rewriteRequestPath: rewriteStaticPath('/embed_vite'), + onFound: (_, ctx) => { + ctx.header('Cache-Control', `max-age=${ms('30 days') / 1000}, immutable`); + }, + }), handleRequestRedirectToOmitSearch, staticAssetNotFound); } else { - console.log('[ClientServerService] Proxying to Vite dev server.'); - const urlOriginWithoutPort = configUrl.origin.replace(/:\d+$/, ''); + this.clientLoggerService.logger.info(`[ClientServerService] Proxying to Vite dev server. ${configUrl.origin}`); const port = (process.env.VITE_PORT ?? '5173'); - fastify.register(fastifyProxy, { - upstream: urlOriginWithoutPort + ':' + port, - prefix: '/vite', - rewritePrefix: '/vite', + hono.get('/vite/*', (ctx) => { + const url = new URL(ctx.req.url); + url.port = port; + return proxy(url, { + headers: { + ...ctx.req.header(), + }, + }); }); const embedPort = (process.env.EMBED_VITE_PORT ?? '5174'); - fastify.register(fastifyProxy, { - upstream: urlOriginWithoutPort + ':' + embedPort, - prefix: '/embed_vite', - rewritePrefix: '/embed_vite', + hono.get('/embed_vite/*', (ctx) => { + const url = new URL(ctx.req.url); + url.port = embedPort; + return proxy(url, { + headers: { + ...ctx.req.header(), + }, + }); }); } //#endregion //#region static assets - fastify.register(fastifyStatic, { + hono.get('/static-assets/*', serveStatic({ root: this.staticAssets, - prefix: '/static-assets/', - maxAge: ms('7 days'), - decorateReply: false, - }); + rewriteRequestPath: rewriteStaticPath('/static-assets'), + onFound: (_, ctx) => { + ctx.header('Cache-Control', `max-age=${ms('7 days') / 1000}`); + }, + }), staticAssetNotFound); - fastify.register(fastifyStatic, { + hono.get('/client-assets/*', serveStatic({ root: this.clientAssets, - prefix: '/client-assets/', - maxAge: ms('7 days'), - decorateReply: false, - }); + rewriteRequestPath: rewriteStaticPath('/client-assets'), + onFound: (_, ctx) => { + ctx.header('Cache-Control', `max-age=${ms('7 days') / 1000}`); + }, + }), staticAssetNotFound); - fastify.register(fastifyStatic, { + hono.get('/assets/*', serveStatic({ root: this.assets, - prefix: '/assets/', - maxAge: ms('7 days'), - decorateReply: false, - }); - - fastify.register((fastify, options, done) => { - fastify.register(fastifyStatic, { - root: this.tarball, - prefix: '/tarball/', - maxAge: ms('30 days'), - immutable: true, - decorateReply: false, - }); - fastify.addHook('onRequest', handleRequestRedirectToOmitSearch); - done(); - }); - - fastify.get('/favicon.ico', async (request, reply) => { - return reply.sendFile('/favicon.ico', this.staticAssets); - }); - - fastify.get('/apple-touch-icon.png', async (request, reply) => { - return reply.sendFile('/apple-touch-icon.png', this.staticAssets); - }); - - fastify.get<{ Params: { path: string } }>('/fluent-emoji/:path(.*)', async (request, reply) => { - const path = request.params.path; - - if (!path.match(/^[0-9a-f-]+\.png$/)) { - reply.code(404); - return; - } - - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - - return reply.sendFile(path, this.fluentEmojiDir, { - maxAge: ms('30 days'), - }); - }); - - fastify.get<{ Params: { path: string } }>('/twemoji/:path(.*)', async (request, reply) => { - const path = request.params.path; - - if (!path.match(/^[0-9a-f-]+\.svg$/)) { - reply.code(404); - return; - } + rewriteRequestPath: rewriteStaticPath('/assets'), + onFound: (_, ctx) => { + ctx.header('Cache-Control', `max-age=${ms('7 days') / 1000}`); + }, + }), staticAssetNotFound); - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + hono.get('/tarball/*', serveStatic({ + root: this.tarball, + rewriteRequestPath: rewriteStaticPath('/tarball'), + onFound: (_, ctx) => { + ctx.header('Cache-Control', `max-age=${ms('30 days') / 1000}, immutable`); + }, + }), handleRequestRedirectToOmitSearch, staticAssetNotFound); - return reply.sendFile(path, this.twemojiDir, { - maxAge: ms('30 days'), - }); - }); + hono.get('/favicon.ico', serveStatic({ + path: resolve(this.staticAssets, 'favicon.ico'), + })); - fastify.get<{ Params: { path: string } }>('/twemoji-badge/:path(.*)', async (request, reply) => { - const path = request.params.path; + hono.get('/apple-touch-icon.png', serveStatic({ + path: resolve(this.staticAssets, 'apple-touch-icon.png'), + })); - if (!path.match(/^[0-9a-f-]+\.png$/)) { - reply.code(404); - return; - } + hono.get('/fluent-emoji/:filename{[0-9a-f-]+\\.png}', serveStatic({ + root: this.fluentEmojiDir, + rewriteRequestPath: rewriteStaticPath('/fluent-emoji'), + onFound: (_, ctx) => { + ctx.header('Cache-Control', `max-age=${ms('30 days') / 1000}, immutable`); + }, + }), staticAssetNotFound); + + hono.get('/twemoji/:filename{[0-9a-f-]+\\.svg}', serveStatic({ + root: this.twemojiDir, + rewriteRequestPath: rewriteStaticPath('/twemoji'), + onFound: (_, ctx) => { + ctx.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + ctx.header('Cache-Control', `max-age=${ms('30 days') / 1000}, immutable`); + }, + }), staticAssetNotFound); - const mask = await sharp( - `${this.twemojiDir}/${path.replace('.png', '')}.svg`, - { density: 1000 }, - ) + hono.get('/twemoji-badge/:filename{[0-9a-f-]+\\.png}', async (ctx) => { + const filename = ctx.req.param('filename'); + const path = resolve(this.twemojiDir, `${filename.replace('.png', '')}.svg`); + const mask = await sharp(path, { density: 1000 }) .resize(488, 488) .greyscale() .normalise() @@ -363,30 +357,26 @@ export class ClientServerService { .png() .toBuffer(); - 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; + ctx.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + ctx.header('Cache-Control', `max-age=${ms('30 days') / 1000}`); + ctx.header('Content-Type', 'image/png'); + return ctx.body(new Uint8Array(buffer)); }); // ServiceWorker - fastify.get('/sw.js', async (request, reply) => { - return await reply.sendFile('/sw.js', this.swAssets, { - maxAge: ms('10 minutes'), - }); - }); + hono.get('/sw.js', serveStatic({ + path: resolve(this.swAssets, 'sw.js'), + })); // Manifest - fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply)); + hono.get('/manifest.json', async (ctx) => await this.manifestHandler(ctx)); // Embed Javascript - fastify.get('/embed.js', async (request, reply) => { - return await reply.sendFile('/embed.js', this.staticAssets, { - maxAge: ms('1 day'), - }); - }); + hono.get('/embed.js', serveStatic({ + path: resolve(this.staticAssets, 'embed.js'), + })); - fastify.get('/robots.txt', async (request, reply) => { + hono.get('/robots.txt', async (ctx) => { const disallowedPaths = [ '/settings', '/admin', @@ -413,12 +403,11 @@ export class ClientServerService { content += 'Allow: /\n'; content += '\n# todo: sitemap\n'; - reply.header('Content-Type', 'text/plain; charset=utf-8'); - return await reply.send(content); + return ctx.text(content); }); // OpenSearch XML - fastify.get('/opensearch.xml', async (request, reply) => { + hono.get('/opensearch.xml', async (ctx) => { const name = this.meta.name ?? 'Misskey'; let content = ''; content += ''; @@ -429,15 +418,15 @@ export class ClientServerService { content += ``; content += ''; - reply.header('Content-Type', 'application/opensearchdescription+xml'); - return await reply.send(content); + ctx.header('Content-Type', 'application/opensearchdescription+xml'); + return ctx.body(content); }); //#endregion - const renderBase = async (reply: FastifyReply, data: Partial[0]> = {}) => { - reply.header('Cache-Control', 'public, max-age=30'); - return await HtmlTemplateService.replyHtml(reply, BasePage({ + const renderBase = async (ctx: HonoContext, data: Partial[0]> = {}) => { + ctx.header('Cache-Control', 'public, max-age=30'); + return ctx.html(BasePage({ img: this.meta.bannerUrl ?? undefined, title: this.meta.name ?? 'Misskey', desc: this.meta.description ?? undefined, @@ -446,8 +435,17 @@ export class ClientServerService { })); }; + const renderEmbedBase = async (ctx: HonoContext, data: Partial[0]> = {}) => { + ctx.header('Cache-Control', 'public, max-age=30'); + return ctx.html(BaseEmbed({ + title: this.meta.name ?? 'Misskey', + ...(await this.htmlTemplateService.getCommonData()), + ...data, + })); + }; + // URL preview endpoint - fastify.get<{ Querystring: { url: string; lang: string; } }>('/url', (request, reply) => this.urlPreviewService.handle(request, reply)); + hono.get('/url', (c) => this.urlPreviewService.handle(c)); const getFeed = async (acct: string) => { const { username, host } = Acct.parse(acct); @@ -462,61 +460,67 @@ export class ClientServerService { }; // Atom - fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => { - if (request.params.user == null) return await renderBase(reply); + hono.get('/@:user.atom', async (ctx) => { + const user = ctx.req.param('user'); + if (user == null) return await renderBase(ctx); - const feed = await getFeed(request.params.user); + const feed = await getFeed(user); if (feed) { - reply.header('Content-Type', 'application/atom+xml; charset=utf-8'); - return feed.atom1(); + ctx.header('Content-Type', 'application/atom+xml; charset=utf-8'); + return ctx.body(feed.atom1()); } else { - reply.code(404); + ctx.status(404); return; } }); // RSS - fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => { - if (request.params.user == null) return await renderBase(reply); + hono.get('/@:user.rss', async (ctx) => { + const user = ctx.req.param('user'); + if (user == null) return await renderBase(ctx); - const feed = await getFeed(request.params.user); + const feed = await getFeed(user); if (feed) { - reply.header('Content-Type', 'application/rss+xml; charset=utf-8'); - return feed.rss2(); + ctx.header('Content-Type', 'application/rss+xml; charset=utf-8'); + return ctx.body(feed.rss2()); } else { - reply.code(404); + ctx.status(404); return; } }); // JSON - fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => { - if (request.params.user == null) return await renderBase(reply); + hono.get('/@:user.json', async (ctx) => { + const user = ctx.req.param('user'); + if (user == null) return await renderBase(ctx); - const feed = await getFeed(request.params.user); + const feed = await getFeed(user); if (feed) { - reply.header('Content-Type', 'application/json; charset=utf-8'); - return feed.json1(); + ctx.header('Content-Type', 'application/json'); + return ctx.json(feed.json1()); } else { - reply.code(404); + ctx.status(404); return; } }); //#region SSR // User - fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => { - const { username, host } = Acct.parse(request.params.user); + hono.get('/@:user/:sub?', async (ctx) => { + const userParam = ctx.req.param('user'); + if (userParam == null) return await renderBase(ctx); + + const { username, host } = Acct.parse(userParam); const user = await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: host ?? IsNull(), isSuspended: false, }); - vary(reply.raw, 'Accept'); + vary(ctx, 'Accept'); if ( user != null && ( @@ -526,10 +530,10 @@ export class ClientServerService { ) { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - reply.header('Cache-Control', 'public, max-age=15'); + ctx.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); + ctx.header('X-Robots-Tag', 'noimageai'); + ctx.header('X-Robots-Tag', 'noai'); } const _user = await this.userEntityService.pack(user, null, { @@ -537,10 +541,10 @@ export class ClientServerService { userProfile: profile, }); - return await HtmlTemplateService.replyHtml(reply, UserPage({ + return ctx.html(UserPage({ user: _user, profile, - sub: request.params.sub, + sub: ctx.req.param('sub'), ...(await this.htmlTemplateService.getCommonData()), clientCtxJson: htmlSafeJsonStringify({ user: _user, @@ -549,34 +553,40 @@ export class ClientServerService { } else { // リモートユーザーなので // モデレータがAPI経由で参照可能にするために404にはしない - return await renderBase(reply); + return await renderBase(ctx); } }); - fastify.get<{ Params: { user: string; } }>('/users/:user', async (request, reply) => { + hono.get('/users/:user', async (ctx) => { + const userParam = ctx.req.param('user'); + if (userParam == null) return await renderBase(ctx); + const user = await this.usersRepository.findOneBy({ - id: request.params.user, + id: userParam, host: IsNull(), isSuspended: false, }); if (user == null) { - reply.code(404); + ctx.status(404); return; } - vary(reply.raw, 'Accept'); + vary(ctx, 'Accept'); - reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); + return ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); }); // Note - fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => { - vary(reply.raw, 'Accept'); + hono.get('/notes/:note', async (ctx) => { + vary(ctx, 'Accept'); + + const noteId = ctx.req.param('note'); + if (noteId == null) return await renderBase(ctx); const note = await this.notesRepository.findOne({ where: { - id: request.params.note, + id: noteId, visibility: In(['public', 'home']), }, relations: { @@ -595,12 +605,12 @@ export class ClientServerService { ) { const _note = await this.noteEntityService.pack(note); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); - reply.header('Cache-Control', 'public, max-age=15'); + ctx.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); + ctx.header('X-Robots-Tag', 'noimageai'); + ctx.header('X-Robots-Tag', 'noai'); } - return await HtmlTemplateService.replyHtml(reply, NotePage({ + return ctx.html(NotePage({ note: _note, profile, ...(await this.htmlTemplateService.getCommonData()), @@ -609,13 +619,16 @@ export class ClientServerService { }), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); // Page - fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => { - const { username, host } = Acct.parse(request.params.user); + hono.get('/@:user/pages/:page', async (ctx) => { + const userParam = ctx.req.param('user'); + if (userParam == null) return await renderBase(ctx); + + const { username, host } = Acct.parse(userParam); const user = await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: host ?? IsNull(), @@ -624,7 +637,7 @@ export class ClientServerService { if (user == null) return; const page = await this.pagesRepository.findOneBy({ - name: request.params.page, + name: ctx.req.param('page'), userId: user.id, }); @@ -632,63 +645,69 @@ export class ClientServerService { const _page = await this.pageEntityService.pack(page); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId }); if (['public'].includes(page.visibility)) { - reply.header('Cache-Control', 'public, max-age=15'); + ctx.header('Cache-Control', 'public, max-age=15'); } else { - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); + ctx.header('Cache-Control', 'private, max-age=0, must-revalidate'); } if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); + ctx.header('X-Robots-Tag', 'noimageai'); + ctx.header('X-Robots-Tag', 'noai'); } - return await HtmlTemplateService.replyHtml(reply, PagePage({ + return ctx.html(PagePage({ page: _page, profile, ...(await this.htmlTemplateService.getCommonData()), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); // Flash - fastify.get<{ Params: { id: string; } }>('/play/:id', async (request, reply) => { + hono.get('/play/:id', async (ctx) => { + const flashId = ctx.req.param('id'); + if (flashId == null) return await renderBase(ctx); + const flash = await this.flashsRepository.findOneBy({ - id: request.params.id, + id: flashId, }); if (flash) { const _flash = await this.flashEntityService.pack(flash); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: flash.userId }); - reply.header('Cache-Control', 'public, max-age=15'); + ctx.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); + ctx.header('X-Robots-Tag', 'noimageai'); + ctx.header('X-Robots-Tag', 'noai'); } - return await HtmlTemplateService.replyHtml(reply, FlashPage({ + return ctx.html(FlashPage({ flash: _flash, profile, ...(await this.htmlTemplateService.getCommonData()), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); // Clip - fastify.get<{ Params: { clip: string; } }>('/clips/:clip', async (request, reply) => { + hono.get('/clips/:clip', async (ctx) => { + const clipId = ctx.req.param('clip'); + if (clipId == null) return await renderBase(ctx); + const clip = await this.clipsRepository.findOneBy({ - id: request.params.clip, + id: clipId, }); if (clip && clip.isPublic) { const _clip = await this.clipEntityService.pack(clip); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId }); - reply.header('Cache-Control', 'public, max-age=15'); + ctx.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); + ctx.header('X-Robots-Tag', 'noimageai'); + ctx.header('X-Robots-Tag', 'noai'); } - return await HtmlTemplateService.replyHtml(reply, ClipPage({ + return ctx.html(ClipPage({ clip: _clip, profile, ...(await this.htmlTemplateService.getCommonData()), @@ -697,106 +716,119 @@ export class ClientServerService { }), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); // Gallery post - fastify.get<{ Params: { post: string; } }>('/gallery/:post', async (request, reply) => { - const post = await this.galleryPostsRepository.findOneBy({ id: request.params.post }); + hono.get('/gallery/:post', async (ctx) => { + const postId = ctx.req.param('post'); + if (postId == null) return await renderBase(ctx); + + const post = await this.galleryPostsRepository.findOneBy({ id: postId }); if (post) { const _post = await this.galleryPostEntityService.pack(post); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId }); - reply.header('Cache-Control', 'public, max-age=15'); + ctx.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { - reply.header('X-Robots-Tag', 'noimageai'); - reply.header('X-Robots-Tag', 'noai'); + ctx.header('X-Robots-Tag', 'noimageai'); + ctx.header('X-Robots-Tag', 'noai'); } - return await HtmlTemplateService.replyHtml(reply, GalleryPostPage({ + return ctx.html(GalleryPostPage({ galleryPost: _post, profile, ...(await this.htmlTemplateService.getCommonData()), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); // Channel - fastify.get<{ Params: { channel: string; } }>('/channels/:channel', async (request, reply) => { + hono.get('/channels/:channel', async (ctx) => { + const channelId = ctx.req.param('channel'); + if (channelId == null) return await renderBase(ctx); + const channel = await this.channelsRepository.findOneBy({ - id: request.params.channel, + id: channelId, }); if (channel) { const _channel = await this.channelEntityService.pack(channel); - reply.header('Cache-Control', 'public, max-age=15'); - return await HtmlTemplateService.replyHtml(reply, ChannelPage({ + ctx.header('Cache-Control', 'public, max-age=15'); + return ctx.html(ChannelPage({ channel: _channel, ...(await this.htmlTemplateService.getCommonData()), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); // Reversi game - fastify.get<{ Params: { game: string; } }>('/reversi/g/:game', async (request, reply) => { + hono.get('/reversi/g/:game', async (ctx) => { + const gameId = ctx.req.param('game'); + if (gameId == null) return await renderBase(ctx); + const game = await this.reversiGamesRepository.findOneBy({ - id: request.params.game, + id: gameId, }); if (game) { const _game = await this.reversiGameEntityService.packDetail(game); - reply.header('Cache-Control', 'public, max-age=3600'); - return await HtmlTemplateService.replyHtml(reply, ReversiGamePage({ + ctx.header('Cache-Control', 'public, max-age=3600'); + return ctx.html(ReversiGamePage({ reversiGame: _game, ...(await this.htmlTemplateService.getCommonData()), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); // 個別お知らせページ - fastify.get<{ Params: { announcementId: string; } }>('/announcements/:announcementId', async (request, reply) => { + hono.get('/announcements/:announcementId', async (ctx) => { + const announcementId = ctx.req.param('announcementId'); + if (announcementId == null) return await renderBase(ctx); + const announcement = await this.announcementsRepository.findOneBy({ - id: request.params.announcementId, + id: announcementId, userId: IsNull(), }); if (announcement) { const _announcement = await this.announcementEntityService.pack(announcement); - reply.header('Cache-Control', 'public, max-age=3600'); - return await HtmlTemplateService.replyHtml(reply, AnnouncementPage({ + ctx.header('Cache-Control', 'public, max-age=3600'); + return ctx.html(AnnouncementPage({ announcement: _announcement, ...(await this.htmlTemplateService.getCommonData()), })); } else { - return await renderBase(reply); + return await renderBase(ctx); } }); //#endregion //#region noindex pages // Tags - fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => { - return await renderBase(reply, { noindex: true }); + hono.get('/tags/:tag', async (ctx) => { + return await renderBase(ctx, { noindex: true }); }); // User with Tags - fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => { - return await renderBase(reply, { noindex: true }); + hono.get('/user-tags/:tag', async (ctx) => { + return await renderBase(ctx, { noindex: true }); }); //#endregion //#region embed pages - fastify.get<{ Params: { user: string; } }>('/embed/user-timeline/:user', async (request, reply) => { - reply.removeHeader('X-Frame-Options'); + hono.get('/embed/user-timeline/:user', async (ctx) => { + const userId = ctx.req.param('user'); + if (userId == null) return await renderEmbedBase(ctx); const user = await this.usersRepository.findOneBy({ - id: request.params.user, + id: userId, }); if (user == null) return; @@ -804,22 +836,23 @@ export class ClientServerService { const _user = await this.userEntityService.pack(user); - reply.header('Cache-Control', 'public, max-age=3600'); - return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ + ctx.header('Cache-Control', 'public, max-age=3600'); + return await renderEmbedBase(ctx, { title: this.meta.name ?? 'Misskey', ...(await this.htmlTemplateService.getCommonData()), embedCtxJson: htmlSafeJsonStringify({ user: _user, }), - })); + }); }); - fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => { - reply.removeHeader('X-Frame-Options'); + hono.get('/embed/notes/:note', async (ctx) => { + const noteId = ctx.req.param('note'); + if (noteId == null) return await renderEmbedBase(ctx); const note = await this.notesRepository.findOne({ where: { - id: request.params.note, + id: noteId, }, relations: { user: true, @@ -834,51 +867,45 @@ export class ClientServerService { const _note = await this.noteEntityService.pack(note, null, { detail: true }); - reply.header('Cache-Control', 'public, max-age=3600'); - return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ + ctx.header('Cache-Control', 'public, max-age=3600'); + return await renderEmbedBase(ctx, { title: this.meta.name ?? 'Misskey', ...(await this.htmlTemplateService.getCommonData()), embedCtxJson: htmlSafeJsonStringify({ note: _note, }), - })); + }); }); - fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => { - reply.removeHeader('X-Frame-Options'); + hono.get('/embed/clips/:clip', async (ctx) => { + const clipId = ctx.req.param('clip'); + if (clipId == null) return await renderEmbedBase(ctx); const clip = await this.clipsRepository.findOneBy({ - id: request.params.clip, + id: clipId, }); if (clip == null) return; const _clip = await this.clipEntityService.pack(clip); - reply.header('Cache-Control', 'public, max-age=3600'); - return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ + ctx.header('Cache-Control', 'public, max-age=3600'); + return await renderEmbedBase(ctx, { title: this.meta.name ?? 'Misskey', ...(await this.htmlTemplateService.getCommonData()), embedCtxJson: htmlSafeJsonStringify({ clip: _clip, }), - })); + }); }); - fastify.get('/embed/*', async (request, reply) => { - reply.removeHeader('X-Frame-Options'); - - reply.header('Cache-Control', 'public, max-age=3600'); - return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ - title: this.meta.name ?? 'Misskey', - ...(await this.htmlTemplateService.getCommonData()), - })); + hono.get('/embed/*', async (ctx) => { + ctx.header('Cache-Control', 'public, max-age=3600'); + return await renderEmbedBase(ctx); }); - fastify.get('/_info_card_', async (request, reply) => { - reply.removeHeader('X-Frame-Options'); - - return await HtmlTemplateService.replyHtml(reply, InfoCardPage({ + hono.get('/_info_card_', async (ctx) => { + return ctx.html(InfoCardPage({ version: this.config.version, config: this.config, meta: this.meta, @@ -886,23 +913,24 @@ export class ClientServerService { }); //#endregion - fastify.get('/bios', async (request, reply) => { - return await HtmlTemplateService.replyHtml(reply, BiosPage({ + hono.get('/bios', async (ctx) => { + return ctx.html(BiosPage({ version: this.config.version, })); }); - fastify.get('/cli', async (request, reply) => { - return await HtmlTemplateService.replyHtml(reply, CliPage({ + hono.get('/cli', async (ctx) => { + return ctx.html(CliPage({ version: this.config.version, })); }); - fastify.get('/flush', async (request, reply) => { + hono.get('/flush', async (ctx) => { let sendHeader = true; - if (request.headers['origin']) { - const originURL = new URL(request.headers['origin']); + const originHeader = ctx.req.header('Origin'); + if (originHeader != null) { + const originURL = new URL(originHeader); if (originURL.protocol !== 'https:') { // Clear-Site-Data only supports https sendHeader = false; } @@ -912,41 +940,59 @@ export class ClientServerService { } if (sendHeader) { - reply.header('Clear-Site-Data', '"*"'); + ctx.header('Clear-Site-Data', '"*"'); } - reply.header('Set-Cookie', 'http-flush-failed=1; Path=/flush; Max-Age=60'); - return await HtmlTemplateService.replyHtml(reply, FlushPage()); + ctx.header('Set-Cookie', 'http-flush-failed=1; Path=/flush; Max-Age=60'); + return ctx.html(FlushPage()); }); // streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる - fastify.get('/streaming', async (request, reply) => { - reply.code(503); - reply.header('Cache-Control', 'private, max-age=0'); + hono.get('/streaming', async (ctx) => { + ctx.status(503); + ctx.header('Cache-Control', 'private, max-age=0'); + return; }); // Render base html for all requests - fastify.get('*', async (request, reply) => { - return await renderBase(reply); - }); - - fastify.setErrorHandler(async (error, request, reply) => { - const errId = randomUUID(); - this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${error.message}`, { - path: request.routeOptions.url, - params: request.params, - query: request.query, - code: error.name, - stack: error.stack, - id: errId, - }); - reply.code(500); - reply.header('Cache-Control', 'max-age=10, must-revalidate'); - return await HtmlTemplateService.replyHtml(reply, ErrorPage({ - code: error.code, - id: errId, - })); + hono.get('*', async (ctx) => { + return await renderBase(ctx); + }); + + hono.onError(async (err, ctx) => { + // ClientServerでも、RSS・JSONなどのエンドポイントでApiErrorが発生することがある + if (err instanceof ApiError) { + ctx.status(err.httpStatusCode ?? 500); + ctx.header('Cache-Control', 'max-age=10, must-revalidate'); + + // Must be synced with ApiCallService.send + return ctx.json({ + error: { + message: err.message, + code: err.code, + id: err.id, + kind: err.kind, + ...(err.info ? { info: err.info } : {}), + }, + }); + } else { + const errId = randomUUID(); + this.clientLoggerService.logger.error(`Internal error occurred in ${ctx.req.path}: ${err.message}`, { + path: ctx.req.path, + params: ctx.req.param(), + query: ctx.req.query(), + code: err.name, + stack: err.stack, + id: errId, + }); + ctx.status(500); + ctx.header('Cache-Control', 'max-age=10, must-revalidate'); + return ctx.html(ErrorPage({ + code: err.name, + id: errId, + })); + } }); - done(); + return hono; } } diff --git a/packages/backend/src/server/web/HtmlTemplateService.ts b/packages/backend/src/server/web/HtmlTemplateService.ts index 2859b2b9852..1e061b0190f 100644 --- a/packages/backend/src/server/web/HtmlTemplateService.ts +++ b/packages/backend/src/server/web/HtmlTemplateService.ts @@ -11,7 +11,6 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js'; import { MetaEntityService } from '@/core/entities/MetaEntityService.js'; -import type { FastifyReply } from 'fastify'; import type { Manifest } from 'vite'; import type { Config } from '@/config.js'; import type { MiMeta } from '@/models/Meta.js'; @@ -176,10 +175,4 @@ export class HtmlTemplateService { frontendEmbedBootloaderCss: this.frontendEmbedBootloaderCss, }; } - - public static async replyHtml(reply: FastifyReply, html: string | Promise) { - reply.header('Content-Type', 'text/html; charset=utf-8'); - const _html = await html; - return reply.send(_html); - } } diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 886e876c404..eef12554b23 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -14,7 +14,7 @@ import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '@/server/api/error.js'; import { MiMeta } from '@/models/Meta.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { Context as HonoContext } from 'hono'; @Injectable() export class UrlPreviewService { @@ -47,30 +47,29 @@ export class UrlPreviewService { @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, - reply: FastifyReply, - ): Promise { - const url = request.query.url; + ctx: HonoContext, + ) { + const url = ctx.req.query('url'); if (typeof url !== 'string') { - reply.code(400); + ctx.status(400); return; } - const lang = request.query.lang; - if (Array.isArray(lang)) { - reply.code(400); + const _lang = ctx.req.queries('lang') ?? []; + if (_lang.length > 1) { + ctx.status(400); return; } + const lang = _lang[0]; if (!this.meta.urlPreviewEnabled) { - reply.code(403); - return { - error: new ApiError({ - message: 'URL preview is disabled', - code: 'URL_PREVIEW_DISABLED', - id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', - }), - }; + ctx.status(403); + throw new ApiError({ + message: 'URL preview is disabled', + code: 'URL_PREVIEW_DISABLED', + id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', + httpStatusCode: 403, + }); } this.logger.info(this.meta.urlPreviewSummaryProxyUrl @@ -96,21 +95,18 @@ export class UrlPreviewService { summary.thumbnail = this.wrap(summary.thumbnail); // Cache 1day - reply.header('Cache-Control', 'max-age=86400, immutable'); + ctx.res.headers.set('Cache-Control', 'max-age=86400, immutable'); - return summary; + return ctx.json(summary); } catch (err) { this.logger.warn(`Failed to get preview of ${url}: ${err}`); - reply.code(422); - reply.header('Cache-Control', 'max-age=86400, immutable'); - return { - error: new ApiError({ - message: 'Failed to get preview', - code: 'URL_PREVIEW_FAILED', - id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', - }), - }; + throw new ApiError({ + message: 'Failed to get preview', + code: 'URL_PREVIEW_FAILED', + id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', + httpStatusCode: 422, + }); } } diff --git a/packages/backend/src/server/web/views/base-embed.tsx b/packages/backend/src/server/web/views/base-embed.tsx index a656bb28a71..8318d8a8f18 100644 --- a/packages/backend/src/server/web/views/base-embed.tsx +++ b/packages/backend/src/server/web/views/base-embed.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { raw } from 'hono/utils/html'; +import type { PropsWithChildren, Child } from 'hono/jsx'; import { comment } from '@/server/web/views/_.js'; import type { CommonProps } from '@/server/web/views/_.js'; import { Splash } from '@/server/web/views/_splash.js'; -import type { PropsWithChildren, Children } from '@kitajs/html'; export function BaseEmbed(props: PropsWithChildren>) { const now = Date.now(); - // 変数名をsafeで始めることでエラーをスキップ - const safeMetaJson = props.metaJson; - const safeEmbedCtxJson = props.embedCtxJson; + const metaJson = props.metaJson; + const embedCtxJson = props.embedCtxJson; + + const doctypeTag = raw(''); + const commentTag = raw(comment); return ( <> - {''} - {comment} + {doctypeTag} + {commentTag} @@ -52,24 +55,22 @@ export function BaseEmbed(props: PropsWithChildren ))} - {props.titleSlot ?? {props.title || 'Misskey'}} + {props.titleSlot ?? {props.title || 'Misskey'}} {props.metaSlot} - {props.frontendEmbedBootloaderCss != null ? : } + {props.frontendEmbedBootloaderCss != null ? : } - + - {safeMetaJson != null ? : null} - {safeEmbedCtxJson != null ? : null} + {metaJson != null ? : null} + {embedCtxJson != null ? : null} - {props.frontendEmbedBootloaderJs != null ? : } + {props.frontendEmbedBootloaderJs != null ? : }