This commit is contained in:
かっこかり 2026-06-24 21:25:52 +09:00 committed by GitHub
commit 604a529e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2329 additions and 2475 deletions

View file

@ -55,12 +55,8 @@
"dependencies": {
"@aws-sdk/client-s3": "3.1065.0",
"@aws-sdk/lib-storage": "3.1065.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.2.0",
@ -91,13 +87,12 @@
"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",
"form-data": "4.0.6",
"got": "15.0.5",
"hono": "4.12.27",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
@ -147,13 +142,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.26",
"@rollup/plugin-esm-shim": "0.1.8",
"@sentry/vue": "10.57.0",

View file

@ -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<RedisOptions> & {
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<FastifyServerOptions['trustProxy']>;
trustProxy: NonNullable<TrustProxyOption>
chmodSocket: string | undefined;
enableIpRateLimit: boolean;
disableHsts: boolean | undefined;

View file

@ -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;
}

View file

@ -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) {

View file

@ -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();
};

View file

@ -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;
});

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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');
},
}));
hono.get('/files/:key', this.driveHandler.handle);
hono.get('/files/:key/*', (ctx) => {
return ctx.redirect(`${this.config.url}/files/${ctx.req.param('key')}`, 301);
});
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('/proxy/:url*', this.proxyHandler.handle);
done();
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<Response> {
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');
}
}

View file

@ -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<void>((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;
}
}

View file

@ -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 activeHalfyear = null;
const activeMonth = null;
const proxyAccount = await this.systemAccountService.fetch('proxy');
const proxyAccount = await this.systemAccountService.fetch('proxy');
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
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,
// 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,
},
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',
},
};
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<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); // 10m
if (version >= 21) {
document.software.repository = meta.repositoryUrl;
document.software.homepage = meta.repositoryUrl;
}
fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2(21));
return document;
}
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 };
@bindThis
public createServer(): Hono {
const hono = new Hono();
const cache = new MemorySingleCache<Awaited<ReturnType<typeof this.generateNodeinfoDocument>>>(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 });
});
fastify.get(nodeinfo2_0path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2(20));
hono.get(nodeinfo2_0path, async (ctx) => {
const base = await cache.fetch(() => this.generateNodeinfoDocument(20));
delete (base as any).software.repository;
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 };
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;
}
}

View file

@ -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<void> {
const fastify = Fastify({
trustProxy: this.config.trustProxy,
logger: false,
});
this.#fastify = fastify;
this.#trustProxyChecker = this.createTrustProxyChecker();
const hono = new Hono<ApiEnv>();
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();
});
// 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');
if (user) {
reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user));
} else {
reply.redirect('/static-assets/user-unknown.png');
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');
});
fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=86400');
hono.get('/identicon/:x', async (ctx) => {
ctx.header('Content-Type', 'image/png');
ctx.header('Cache-Control', 'public, max-age=86400');
if (this.meta.enableIdenticonGeneration) {
return await genIdenticon(request.params.x);
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,
});
// WebSocket
this.#honoNodeServer.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '', `http://${req.headers['host'] ?? 'localhost'}`);
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<void>((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<void> {
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<void>(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<void>((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<void>(resolve => setTimeout(resolve, 5_000)),
]).catch(err => this.logger.error('Server close failed', err as Error));
}
}
@bindThis

View file

@ -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<string, string> }[]) =>
`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">${x.map(({ element, value, attributes }) =>
`<${
@ -49,77 +48,69 @@ export class WellKnownServerService {
typeof value === 'string' ? `>${escapeValue(value)}</${element}` : '/'
}>`).reduce((a, c) => a + c, '')}</XRD>`;
const allPath = '/.well-known/*';
const webFingerPath = '/.well-known/webfinger';
const jrd = 'application/jrd+json';
const xrd = 'application/xrd+xml';
const hono = new Hono();
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();
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<MiUser> => ({
@ -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;
}
}

View file

@ -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<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
): void {
const body = request.method === 'GET'
? request.query
: request.body;
ctx: ApiContext,
bodyData?: Record<string, unknown>,
): Promise<Response> {
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);
});
if (user) {
this.logIp(request, user);
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}`);
}
}).catch(err => {
this.#sendAuthenticationError(reply, err);
});
if (user) {
this.logIp(ctx.var.ip, user);
}
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<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
): Promise<void> {
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<Response> {
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<string, unknown>;
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);
}
}).catch(err => {
this.#sendAuthenticationError(reply, err);
});
return this.send(ctx, res);
} catch (err) {
cleanup();
if (err instanceof ApiError) {
return this.#sendApiError(ctx, 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<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
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));
}
}

View file

@ -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, {
private async parseJsonBody(ctx: ApiContext): Promise<Record<string, unknown> | Response> {
try {
const parsed = await ctx.req.json();
if (parsed == null || Array.isArray(parsed) || typeof parsed !== 'object') {
return ctx.body(null, 400);
}
return parsed as Record<string, unknown>;
} catch {
return ctx.body(null, 400);
}
}
@bindThis
private async parseMultipartBody(ctx: ApiContext): Promise<ApiMultipartData | Response | null> {
try {
const body = await ctx.req.parseBody({ all: true });
let file: File | null = null;
const fields: Record<string, unknown> = {};
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);
}
}
@bindThis
private finalize(ctx: ApiContext, result: unknown): Response {
if (result instanceof Response) {
return result;
}
const status = ctx.res.status === 200 ? 200 : ctx.res.status;
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);
}
return ctx.json(result, status as never);
}
@bindThis
private async invoke(ctx: ApiContext, handler: () => Promise<unknown>): Promise<Response> {
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<ApiEnv> {
const hono = new Hono<ApiEnv>({
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();
});
fastify.register(multipart, {
limits: {
fileSize: this.config.maxFileSize,
files: 1,
},
});
hono.use('*', async (ctx, next) => {
if (ctx.req.method === 'GET') {
return await next();
}
// Prevent cache
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
done();
const contentType = ctx.req.header('Content-Type') || '';
if (contentType.includes('multipart/form-data')) {
return await multipartBodyLimit(ctx, next);
} else {
return await jsonBodyLimit(ctx, next);
}
});
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,
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);
}
};
if (endpoint.meta.requireFile) {
fastify.all<{
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
}>('/' + endpoint.name, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405);
reply.send();
return;
}
const registerRoute = (path: string, handler: Handler) => {
hono.post(path, handler);
// 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<string, unknown>,
Querystring: Record<string, unknown>,
}>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405);
reply.send();
return;
}
// GET が許可されている場合のみ GET も登録
if (endpoint.meta.allowGet) {
hono.get(path, handler);
}
};
// Await so that any error can automatically be translated to HTTP 500
await this.apiCallService.handleRequest(ep, request, reply);
return reply;
});
}
registerRoute('/' + endpoint.name, handler);
}
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;
}
}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
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));
});
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;
};
}>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));
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));
});
fastify.post<{
Body: {
credential?: AuthenticationResponseJSON;
context?: string;
};
}>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply));
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.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(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));
});
fastify.get('/v1/instance/peers', async (request, reply) => {
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;
}
}

View file

@ -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<ApiEnv>;
export interface ApiMultipartData {
filename: string;
file: Readable;
truncated: boolean;
fields: Record<string, unknown>;
}

View file

@ -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',

View file

@ -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,

View file

@ -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,
};

View file

@ -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());
}
}
}

View file

@ -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<WebSocket.WebSocket, number>();
#pendingConnections = new WeakMap<http.IncomingMessage, StreamingContext>();
#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 {
public createWebSocketServer(): WebSocket.WebSocketServer {
this.initialize();
return this.#wss!;
}
@bindThis
public async handleUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
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;
}
} 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;
}
if (user?.isSuspended) {
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId<ConnectionRequest>({
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(req, socket, head, (ws) => {
this.#wss!.emit('connection', ws, req);
});
}
@bindThis
private initialize(): void {
if (this.#initialized) {
return;
}
this.#initialized = true;
this.#wss = new WebSocket.WebSocketServer({
noServer: true,
});
server.on('upgrade', async (request, socket, head) => {
if (request.url == null) {
socket.write('HTTP/1.1 400 Bad Request\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;
}
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId<ConnectionRequest>({
user,
token: app,
}, contextId);
const stream = await this.moduleRef.create(MainStreamConnection, contextId);
await stream.init();
this.#wss.handleUpgrade(request, socket, head, (ws) => {
this.#wss.emit('connection', ws, request, {
stream, user, app,
});
});
});
const globalEv = new EventEmitter();
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<void> {
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());
});
}
}

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -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('<!DOCTYPE html>');
return (
<>
{'<!DOCTYPE html>'}
{doctypeTag}
<html>
<head>
<meta charset="UTF-8" />

View file

@ -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<Response> {
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();

View file

@ -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<Response> {
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<IImageStreamable> {
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);
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);
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
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,

View file

@ -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<Uint8Array> {
return new ReadableStream<Uint8Array>({
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<Uint8Array> {
return new ReadableStream<Uint8Array>({
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());
}
}

View file

@ -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<string, string>, issuerUrl: string): Recor
};
}
function redirectWithQuery(reply: FastifyReply, redirectUriString: string, payload: Record<string, string>): void {
applyNoStore(reply);
function redirectWithQuery(ctx: HonoContext, redirectUriString: string, payload: Record<string, string>) {
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<void> {
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<void> {
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

View file

@ -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);

File diff suppressed because it is too large Load diff

View file

@ -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<string>) {
reply.header('Content-Type', 'text/html; charset=utf-8');
const _html = await html;
return reply.send(_html);
}
}

View file

@ -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<object | undefined> {
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,
});
}
}

View file

@ -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<CommonProps<{
title?: string;
@ -19,19 +20,21 @@ export function BaseEmbed(props: PropsWithChildren<CommonProps<{
metaJson?: string;
embedCtxJson?: string;
titleSlot?: Children;
metaSlot?: Children;
titleSlot?: Child;
metaSlot?: Child;
}>>) {
const now = Date.now();
// 変数名をsafeで始めることでエラーをスキップ
const safeMetaJson = props.metaJson;
const safeEmbedCtxJson = props.embedCtxJson;
const metaJson = props.metaJson;
const embedCtxJson = props.embedCtxJson;
const doctypeTag = raw('<!DOCTYPE html>');
const commentTag = raw(comment);
return (
<>
{'<!DOCTYPE html>'}
{comment}
{doctypeTag}
{commentTag}
<html>
<head>
<meta charset="UTF-8" />
@ -52,24 +55,22 @@ export function BaseEmbed(props: PropsWithChildren<CommonProps<{
<link rel="stylesheet" href={`/embed_vite/${href}`} />
))}
{props.titleSlot ?? <title safe>{props.title || 'Misskey'}</title>}
{props.titleSlot ?? <title>{props.title || 'Misskey'}</title>}
{props.metaSlot}
<meta name="robots" content="noindex" />
{props.frontendEmbedBootloaderCss != null ? <style safe>{props.frontendEmbedBootloaderCss}</style> : <link rel="stylesheet" href="/embed_vite/loader/style.css" />}
{props.frontendEmbedBootloaderCss != null ? <style>{props.frontendEmbedBootloaderCss}</style> : <link rel="stylesheet" href="/embed_vite/loader/style.css" />}
<script>
const VERSION = '{props.version}';
const CLIENT_ENTRY = {JSON.stringify(props.frontendEmbedViteFiles?.entryJs ?? null)};
const LANGS = {JSON.stringify(props.langs)};
</script>
<script dangerouslySetInnerHTML={{
__html: `const VERSION = '${props.version}'; const CLIENT_ENTRY = ${JSON.stringify(props.frontendEmbedViteFiles?.entryJs ?? null)}; const LANGS = ${JSON.stringify(props.langs)};`,
}}></script>
{safeMetaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now}>{safeMetaJson}</script> : null}
{safeEmbedCtxJson != null ? <script type="application/json" id="misskey_embedCtx" data-generated-at={now}>{safeEmbedCtxJson}</script> : null}
{metaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now} dangerouslySetInnerHTML={{ __html: metaJson }}></script> : null}
{embedCtxJson != null ? <script type="application/json" id="misskey_embedCtx" data-generated-at={now} dangerouslySetInnerHTML={{ __html: embedCtxJson }}></script> : null}
{props.frontendEmbedBootloaderJs != null ? <script>{props.frontendEmbedBootloaderJs}</script> : <script src="/embed_vite/loader/boot.js"></script>}
{props.frontendEmbedBootloaderJs != null ? <script dangerouslySetInnerHTML={{ __html: props.frontendEmbedBootloaderJs }}></script> : <script src="/embed_vite/loader/boot.js"></script>}
</head>
<body>
<noscript>

View file

@ -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, defaultDescription } from '@/server/web/views/_.js';
import { Splash } from '@/server/web/views/_splash.js';
import type { CommonProps } from '@/server/web/views/_.js';
import type { PropsWithChildren, Children } from '@kitajs/html';
export function Layout(props: PropsWithChildren<CommonProps<{
title?: string;
@ -19,21 +20,23 @@ export function Layout(props: PropsWithChildren<CommonProps<{
metaJson?: string;
clientCtxJson?: string;
titleSlot?: Children;
descSlot?: Children;
metaSlot?: Children;
ogSlot?: Children;
titleSlot?: Child;
descSlot?: Child;
metaSlot?: Child;
ogSlot?: Child;
}>>) {
const now = Date.now();
// 変数名をsafeで始めることでエラーをスキップ
const safeMetaJson = props.metaJson;
const safeClientCtxJson = props.clientCtxJson;
const metaJson = props.metaJson;
const clientCtxJson = props.clientCtxJson;
const doctypeTag = raw('<!DOCTYPE html>');
const commentTag = raw(comment);
return (
<>
{'<!DOCTYPE html>'}
{comment}
{doctypeTag}
{commentTag}
<html>
<head>
<meta charset="UTF-8" />
@ -59,7 +62,7 @@ export function Layout(props: PropsWithChildren<CommonProps<{
<link rel="stylesheet" href={`/vite/${href}`} />
))}
{props.titleSlot ?? <title safe>{props.title || 'Misskey'}</title>}
{props.titleSlot ?? <title>{props.title || 'Misskey'}</title>}
{props.noindex ? <meta name="robots" content="noindex" /> : null}
@ -76,16 +79,14 @@ export function Layout(props: PropsWithChildren<CommonProps<{
</>
)}
{props.frontendBootloaderCss != null ? <style safe>{props.frontendBootloaderCss}</style> : <link rel="stylesheet" href="/vite/loader/style.css" />}
{props.frontendBootloaderCss != null ? <style>{props.frontendBootloaderCss}</style> : <link rel="stylesheet" href="/vite/loader/style.css" />}
<script>
const VERSION = '{props.version}';
const CLIENT_ENTRY = {JSON.stringify(props.frontendViteFiles?.entryJs ?? null)};
const LANGS = {JSON.stringify(props.langs)};
</script>
<script dangerouslySetInnerHTML={{
__html: `const VERSION = '${props.version}'; const CLIENT_ENTRY = ${JSON.stringify(props.frontendViteFiles?.entryJs ?? null)}; const LANGS = ${JSON.stringify(props.langs)};`,
}}></script>
{safeMetaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now}>{safeMetaJson}</script> : null}
{safeClientCtxJson != null ? <script type="application/json" id="misskey_clientCtx" data-generated-at={now}>{safeClientCtxJson}</script> : null}
{metaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now} dangerouslySetInnerHTML={{ __html: metaJson }}></script> : null}
{clientCtxJson != null ? <script type="application/json" id="misskey_clientCtx" data-generated-at={now} dangerouslySetInnerHTML={{ __html: clientCtxJson }}></script> : null}
{props.frontendBootloaderJs != null ? <script>{props.frontendBootloaderJs}</script> : <script src="/vite/loader/boot.js"></script>}
</head>

View file

@ -18,7 +18,7 @@
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
"jsxImportSource": "@kitajs/html", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
"jsxImportSource": "hono/jsx", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */

View file

@ -1,6 +1,7 @@
import { portToPid } from 'pid-port';
import fkill from 'fkill';
import Fastify from 'fastify';
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { NestFactory } from '@nestjs/core';
import { MainModule } from '@/MainModule.js';
import { ServerService } from '@/server/ServerService.js';
@ -67,22 +68,24 @@ async function killTestServer() {
* @param port
*/
async function startControllerEndpoints(port = config.port + 1000) {
const fastify = Fastify();
const hono = new Hono();
fastify.post<{ Body: { key?: string, value?: string } }>('/env', async (req, res) => {
console.log(req.body);
const key = req.body['key'];
hono.post('/env', async (c) => {
const req = await c.req.json<{ key?: string, value?: string }>();
console.log(req);
const key = req['key'];
if (!key) {
res.code(400).send({ success: false });
return;
c.status(400);
return c.json({ success: false });
}
process.env[key] = req.body['value'];
process.env[key] = req['value'];
res.code(200).send({ success: true });
c.status(200);
return c.json({ success: true });
});
fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => {
hono.post('/env-reset', async (c) => {
process.env = JSON.parse(originEnv);
await serverService.dispose();
@ -98,8 +101,13 @@ async function startControllerEndpoints(port = config.port + 1000) {
serverService = app.get(ServerService);
await serverService.launch();
res.code(200).send({ success: true });
c.status(200);
return c.json({ success: true });
});
await fastify.listen({ port: port, host: 'localhost' });
serve({
fetch: hono.fetch,
port,
hostname: 'localhost',
});
}

View file

@ -24,7 +24,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"jsxImportSource": "hono/jsx",
"paths": {
"@/*": ["../src/*"]
},

View file

@ -18,9 +18,10 @@ const PREFER_HTML = 'text/html, */*';
const UNSPECIFIED = '*/*';
// Response Content-Type in lowercase
const AP = 'application/activity+json; charset=utf-8';
// https://www.rfc-editor.org/rfc/rfc8259.html#section-8.1 - "JSON text exchanged between systems that are not part of a closed ecosystem MUST be encoded using UTF-8"
const AP = 'application/activity+json';
const HTML = 'text/html; charset=utf-8';
const JSON_UTF8 = 'application/json; charset=utf-8';
const JSON_UTF8 = 'application/json';
describe('Webリソース', () => {
let alice: misskey.entities.SignupResponse;
@ -106,7 +107,7 @@ describe('Webリソース', () => {
{ path: '/cli', type: HTML },
{ path: '/flush', type: HTML },
{ path: '/robots.txt', type: 'text/plain; charset=UTF-8' },
{ path: '/favicon.ico', type: 'image/vnd.microsoft.icon' },
{ path: '/favicon.ico', type: 'image/x-icon' },
{ path: '/opensearch.xml', type: 'application/opensearchdescription+xml' },
{ path: '/apple-touch-icon.png', type: 'image/png' },
{ path: '/twemoji/2764.svg', type: 'image/svg+xml' },
@ -137,7 +138,7 @@ describe('Webリソース', () => {
describe.each([
{ ext: 'rss', type: 'application/rss+xml; charset=utf-8' },
{ ext: 'atom', type: 'application/atom+xml; charset=utf-8' },
{ ext: 'json', type: 'application/json; charset=utf-8' },
{ ext: 'json', type: 'application/json' },
])('/@:username.$ext', ({ ext, type }) => {
const path = (username: string): string => `/@${username}.${ext}`;

View file

@ -21,7 +21,9 @@ import {
} from 'simple-oauth2';
import pkceChallenge from 'pkce-challenge';
import * as htmlParser from 'node-html-parser';
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
import { Hono } from 'hono';
import type { Handler } from 'hono';
import { serve } from '@hono/node-server';
import { api, port, sendEnvUpdateRequest, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
@ -155,28 +157,30 @@ async function assertDirectError(response: Response, status: number, error: stri
}
describe('OAuth', () => {
let fastify: FastifyInstance;
let hono: Hono;
let honoServer: ReturnType<typeof serve>;
let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse;
let sender: (reply: FastifyReply) => void;
let sender: Handler;
beforeAll(async () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
fastify = Fastify();
fastify.get('/', async (request, reply) => {
sender(reply);
hono = new Hono();
hono.get('/', sender);
honoServer = serve({
fetch: hono.fetch,
port: clientPort,
});
await fastify.listen({ port: clientPort });
}, 1000 * 60 * 2);
beforeEach(async () => {
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '' });
sender = (reply): void => {
reply.send(`
sender = (ctx) => {
return ctx.html(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect" />
<div class="h-app"><a href="/" class="u-url p-name">Misklient
@ -185,7 +189,7 @@ describe('OAuth', () => {
});
afterAll(async () => {
await fastify.close();
honoServer.close();
});
test('Full flow', async () => {
@ -873,9 +877,8 @@ describe('OAuth', () => {
// https://indieauth.spec.indieweb.org/#client-information-discovery
describe('JSON client metadata (11 July 2024)', () => {
test('Read JSON document', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
sender = (ctx) => {
return ctx.json({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/`,
client_name: 'Misklient JSON',
@ -900,10 +903,9 @@ describe('OAuth', () => {
});
test('Merge Link header redirect_uri with JSON redirect_uris', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect2>; rel="redirect_uri"');
reply.header('content-type', 'application/json');
reply.send({
sender = (ctx) => {
ctx.header('Link', '</redirect2>; rel="redirect_uri"');
return ctx.json({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/`,
client_name: 'Misklient JSON',
@ -933,9 +935,8 @@ describe('OAuth', () => {
});
test('Reject when client_id does not match retrieved URL', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
sender = (ctx) => {
return ctx.json({
client_id: `http://127.0.0.1:${clientPort}/mismatch`,
client_uri: `http://127.0.0.1:${clientPort}/`,
redirect_uris: ['/redirect'],
@ -954,9 +955,8 @@ describe('OAuth', () => {
});
test('Reject when client_uri is not a prefix of client_id', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
sender = (ctx) => {
return ctx.json({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`,
redirect_uris: ['/redirect'],
@ -975,9 +975,8 @@ describe('OAuth', () => {
});
test('Reject when JSON metadata has no redirect_uris and no Link header', async () => {
sender = (reply): void => {
reply.header('content-type', 'application/json');
reply.send({
sender = (ctx) => {
return ctx.json({
client_id: `http://127.0.0.1:${clientPort}/`,
client_uri: `http://127.0.0.1:${clientPort}/`,
client_name: 'Misklient JSON',
@ -999,31 +998,31 @@ describe('OAuth', () => {
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
describe('HTML link client metadata (12 Feb 2022)', () => {
describe('Redirection', () => {
const tests: Record<string, (reply: FastifyReply) => void> = {
'Read HTTP header': reply => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
const tests: Record<string, Handler> = {
'Read HTTP header': (ctx) => {
ctx.header('Link', '</redirect>; rel="redirect_uri"');
return ctx.html(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Mixed links': reply => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
'Mixed links': (ctx) => {
ctx.header('Link', '</redirect>; rel="redirect_uri"');
return ctx.html(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect2" />
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Multiple items in Link header': reply => {
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
reply.send(`
'Multiple items in Link header': (ctx) => {
ctx.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
return ctx.html(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
},
'Multiple items in HTML': reply => {
reply.send(`
'Multiple items in HTML': (ctx) => {
return ctx.html(`
<!DOCTYPE html>
<link rel="redirect_uri" href="/redirect2" />
<link rel="redirect_uri" href="/redirect" />
@ -1050,8 +1049,8 @@ describe('OAuth', () => {
}
test('No item', async () => {
sender = (reply): void => {
reply.send(`
sender = (ctx) => {
return ctx.html(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
@ -1088,9 +1087,9 @@ describe('OAuth', () => {
});
test('Missing name', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send();
sender = (ctx) => {
ctx.header('Link', '</redirect>; rel="redirect_uri"');
return ctx.body(null, 204);
};
const client = new AuthorizationCode(clientConfig);
@ -1107,16 +1106,15 @@ describe('OAuth', () => {
});
test('With Logo', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
sender = (ctx) => {
ctx.header('Link', '</redirect>; rel="redirect_uri"');
return ctx.html(`
<!DOCTYPE html>
<div class="h-app">
<a href="/" class="u-url p-name">Misklient</a>
<img src="/logo.png" class="u-logo" />
</div>
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);
@ -1135,13 +1133,12 @@ describe('OAuth', () => {
});
test('Missing Logo', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
sender = (ctx) => {
ctx.header('Link', '</redirect>; rel="redirect_uri"');
return ctx.html(`
<!DOCTYPE html>
<div class="h-app"><a href="/" class="u-url p-name">Misklient
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);
@ -1160,13 +1157,12 @@ describe('OAuth', () => {
});
test('Mismatching URL in h-app', async () => {
sender = (reply): void => {
reply.header('Link', '</redirect>; rel="redirect_uri"');
reply.send(`
sender = (ctx) => {
ctx.header('Link', '</redirect>; rel="redirect_uri"');
return ctx.html(`
<!DOCTYPE html>
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
`);
reply.send();
};
const client = new AuthorizationCode(clientConfig);

View file

@ -24,7 +24,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"jsxImportSource": "hono/jsx",
"paths": {
"@/*": ["../src/*"]
},

View file

@ -3,13 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IncomingHttpHeaders } from 'node:http';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { Hono } from 'hono';
import { Test, TestingModule } from '@nestjs/testing';
import { FastifyReply, FastifyRequest } from 'fastify';
import type { AuthenticationResponseJSON } from '@simplewebauthn/server';
import { HttpHeader } from 'fastify/types/utils.js';
import { MiUser } from '@/models/User.js';
import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
@ -21,6 +19,8 @@ import { RateLimiterService } from '@/server/api/RateLimiterService.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { SigninService } from '@/server/api/SigninService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { dummyContextMiddleware } from '../utils.js';
import type { ApiEnv } from '@/server/api/ApiServerTypes.js';
class FakeLimiter {
public async limit() {
@ -34,33 +34,9 @@ class FakeSigninService {
}
}
class DummyFastifyReply {
public statusCode: number;
code(num: number): void {
this.statusCode = num;
}
header(_key: HttpHeader, _value: any): void {
}
}
class DummyFastifyRequest {
public ip: string;
public body: {credential: any, context: string};
public headers: IncomingHttpHeaders = { 'accept': 'application/json' };
constructor(body?: any) {
this.ip = '0.0.0.0';
this.body = body;
}
}
type ApiFastifyRequestType = FastifyRequest<{
Body: {
credential?: AuthenticationResponseJSON;
context?: string;
};
}>;
describe('SigninWithPasskeyApiService', () => {
let app: TestingModule;
let honoApp: Hono<ApiEnv>;
let passkeyApiService: SigninWithPasskeyApiService;
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
@ -101,6 +77,12 @@ describe('SigninWithPasskeyApiService', () => {
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
webAuthnService = app.get<WebAuthnService>(WebAuthnService);
idService = app.get<IdService>(IdService);
honoApp = new Hono();
honoApp.post('/signin-with-passkey', dummyContextMiddleware, async (ctx) => {
const json = await ctx.req.json();
return ctx.json(await passkeyApiService.signin(ctx, json));
});
});
beforeEach(async () => {
@ -128,50 +110,66 @@ describe('SigninWithPasskeyApiService', () => {
describe('Get Passkey Options', () => {
it('Should return passkey Auth Options', async () => {
const req = new DummyFastifyRequest({}) as ApiFastifyRequestType;
const res = new DummyFastifyReply() as unknown as FastifyReply;
const res_body = await passkeyApiService.signin(req, res);
expect(res.statusCode).toBe(200);
expect((res_body as any).option).toBeDefined();
const res = await honoApp.request('/signin-with-passkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
expect(res.status).toBe(200);
const res_body = await res.json();
expect(res_body).toHaveProperty('option');
expect(typeof (res_body as any).context).toBe('string');
});
});
describe('Try Passkey Auth', () => {
it('Should Success', async () => {
const req = new DummyFastifyRequest({ context: 'auth-context', credential: { dummy: [] } }) as ApiFastifyRequestType;
const res = new DummyFastifyReply() as FastifyReply;
const res_body = await passkeyApiService.signin(req, res);
const res = await honoApp.request('/signin-with-passkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: 'auth-context', credential: { dummy: [] } as unknown as AuthenticationResponseJSON }),
});
expect(res.status).toBe(200);
const res_body = await res.json();
expect((res_body as any).signinResponse).toBeDefined();
});
it('Should return 400 Without Auth Context', async () => {
const req = new DummyFastifyRequest({ credential: { dummy: [] } }) as ApiFastifyRequestType;
const res = new DummyFastifyReply() as FastifyReply;
const res_body = await passkeyApiService.signin(req, res);
expect(res.statusCode).toBe(400);
const res = await honoApp.request('/signin-with-passkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: { dummy: [] } as unknown as AuthenticationResponseJSON }),
});
expect(res.status).toBe(400);
const res_body = await res.json();
expect((res_body as any).error?.id).toStrictEqual('1658cc2e-4495-461f-aee4-d403cdf073c1');
});
it('Should return 403 When Challenge Verify fail', async () => {
const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType;
const res = new DummyFastifyReply() as FastifyReply;
vi.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication')
.mockImplementation(async () => {
throw new IdentifiableError('THIS_ERROR_CODE_SHOULD_BE_FORWARDED');
});
const res_body = await passkeyApiService.signin(req, res);
expect(res.statusCode).toBe(403);
const res = await honoApp.request('/signin-with-passkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: 'misskey-1234', credential: { dummy: [] } as unknown as AuthenticationResponseJSON }),
});
expect(res.status).toBe(403);
const res_body = await res.json();
expect((res_body as any).error?.id).toStrictEqual('THIS_ERROR_CODE_SHOULD_BE_FORWARDED');
});
it('Should return 403 When The user not Enabled Passwordless login', async () => {
const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType;
const res = new DummyFastifyReply() as FastifyReply;
const userId = await FakeWebauthnVerify();
const data = { userId: userId, usePasswordLessLogin: false };
await userProfilesRepository.update({ userId: userId }, data);
const res_body = await passkeyApiService.signin(req, res);
expect(res.statusCode).toBe(403);
const res = await honoApp.request('/signin-with-passkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: 'misskey-1234', credential: { dummy: [] } as unknown as AuthenticationResponseJSON }),
});
expect(res.status).toBe(403);
const res_body = await res.json();
expect((res_body as any).error?.id).toStrictEqual('2d84773e-f7b7-4d0b-8f72-bb69b584c912');
});
});

View file

@ -5,8 +5,10 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import fastifyStatic from '@fastify/static';
import Fastify, { type FastifyInstance } from 'fastify';
import { serve } from '@hono/node-server';
import type { ServerType } from '@hono/node-server';
import { Hono } from 'hono';
import type { Context as HonoContext } from 'hono';
import { describe, expect, test, beforeAll, afterAll, afterEach } from 'vitest';
import sharp from 'sharp';
import { DataSource, type Repository } from 'typeorm';
@ -30,37 +32,99 @@ const dummyBuffer = fs.readFileSync(dummyPath);
const svgBuffer = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"></svg>', 'utf8');
const textBuffer = Buffer.from('dummy text', 'utf8');
type HonoTestResponse = {
statusCode: number;
headers: Record<string, string>;
body: Uint8Array;
};
type HonoTestServer = {
inject: (request: {
method: string;
url: string;
headers?: Record<string, string>;
}) => Promise<HonoTestResponse>;
close: () => Promise<void>;
};
type ListeningHonoServer = ServerType;
function createHonoTestServer(app: Hono): HonoTestServer {
return {
inject: async ({ method, url, headers }) => {
const response = await app.request(new Request(new URL(url, 'http://127.0.0.1').toString(), {
method,
headers,
}));
return {
statusCode: response.status,
headers: Object.fromEntries(Array.from(response.headers.entries(), ([key, value]) => [key.toLowerCase(), value])),
body: new Uint8Array(await response.arrayBuffer()),
};
},
close: async () => {},
};
}
async function closeListeningServer(server: ListeningHonoServer): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err != null) {
reject(err);
return;
}
resolve();
});
});
}
async function startListeningHonoServer(app: Hono): Promise<{ server: ListeningHonoServer; baseUrl: string; }> {
return await new Promise((resolve, reject) => {
const onError = (error: Error) => {
server.off('error', onError);
reject(error);
};
const server = serve({
fetch: app.fetch,
hostname: '127.0.0.1',
port: 0,
}, (info) => {
server.off('error', onError);
resolve({
server,
baseUrl: `http://127.0.0.1:${info.port}`,
});
});
server.once('error', onError);
});
}
async function createRemoteFileServer() {
const flatPngBuffer = await sharp({
create: { width: 8, height: 8, channels: 3, background: { r: 0, g: 0, b: 0 } },
}).png().toBuffer();
const server = Fastify();
const app = new Hono();
const respondWithBuffer = (ctx: HonoContext, body: Buffer, contentType: string) => {
ctx.header('Content-Type', contentType);
ctx.header('Content-Length', String(body.length));
return ctx.body(new Uint8Array(body));
};
server.get('/dummy.png', async (_request, reply) => {
reply.header('Content-Type', 'image/png');
reply.header('Content-Length', String(dummyBuffer.length));
return reply.send(dummyBuffer);
});
app.get('/dummy.png', (ctx) => respondWithBuffer(ctx, dummyBuffer, 'image/png'));
server.get('/dummy.svg', async (_request, reply) => {
reply.header('Content-Type', 'image/svg+xml');
reply.header('Content-Length', String(svgBuffer.length));
return reply.send(svgBuffer);
});
app.get('/dummy.svg', (ctx) => respondWithBuffer(ctx, svgBuffer, 'image/svg+xml'));
server.get('/dummy.txt', async (_request, reply) => {
reply.header('Content-Type', 'text/plain');
reply.header('Content-Length', String(textBuffer.length));
return reply.send(textBuffer);
});
app.get('/dummy.txt', (ctx) => respondWithBuffer(ctx, textBuffer, 'text/plain'));
server.get('/flat.png', async (_request, reply) => {
reply.header('Content-Type', 'image/png');
reply.header('Content-Length', String(flatPngBuffer.length));
return reply.send(flatPngBuffer);
});
app.get('/flat.png', (ctx) => respondWithBuffer(ctx, flatPngBuffer, 'image/png'));
const baseUrl = await server.listen({ port: 0, host: '127.0.0.1' });
const { server, baseUrl } = await startListeningHonoServer(app);
return {
server,
@ -73,15 +137,15 @@ async function createRemoteFileServer() {
describe('FileServerService', () => {
let db: DataSource;
let fastify: FastifyInstance;
let externalFastify: FastifyInstance;
let honoServer: HonoTestServer;
let externalHonoServer: HonoTestServer;
let driveFilesRepository: Repository<MiDriveFile>;
let internalStorageService: InternalStorageService;
let idService: IdService;
let config: Config;
let fileServerService: FileServerService;
let externalFileServerService: FileServerService;
let remoteServer: FastifyInstance;
let remoteServer: ListeningHonoServer;
let remotePngUrl: string;
let remoteSvgUrl: string;
let remoteTextUrl: string;
@ -168,13 +232,7 @@ describe('FileServerService', () => {
loggerService,
);
fastify = Fastify();
await fastify.register(fastifyStatic, {
root: path.resolve('src/server/assets'),
serve: false,
});
fileServerService.createServer(fastify, {}, () => {});
await fastify.ready();
honoServer = createHonoTestServer(fileServerService.createServer());
const externalConfig = {
...config,
@ -191,13 +249,7 @@ describe('FileServerService', () => {
internalStorageService,
loggerService,
);
externalFastify = Fastify();
await externalFastify.register(fastifyStatic, {
root: path.resolve('src/server/assets'),
serve: false,
});
externalFileServerService.createServer(externalFastify, {}, () => {});
await externalFastify.ready();
externalHonoServer = createHonoTestServer(externalFileServerService.createServer());
const remoteServerInfo = await createRemoteFileServer();
remoteServer = remoteServerInfo.server;
@ -227,9 +279,9 @@ describe('FileServerService', () => {
});
afterAll(async () => {
await fastify.close();
await externalFastify.close();
await remoteServer.close();
await honoServer.close();
await externalHonoServer.close();
await closeListeningServer(remoteServer);
await db.destroy();
if (createdFallbackAssets) {
fs.rmSync(fallbackAssetsDir, { recursive: true, force: true });
@ -242,7 +294,7 @@ describe('FileServerService', () => {
process.env.NODE_ENV = 'test';
try {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: '/files/app-default.jpg',
});
@ -262,7 +314,7 @@ describe('FileServerService', () => {
process.env.NODE_ENV = 'development';
try {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: '/files/app-default.jpg',
});
@ -275,7 +327,7 @@ describe('FileServerService', () => {
});
test('GET /files/app-default.jpg?x=1 クエリを除去してリダイレクトする', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: '/files/app-default.jpg?x=1',
});
@ -290,7 +342,7 @@ describe('FileServerService', () => {
test('GET /files/:key 404 のときダミー画像を返す', async () => {
const accessKey = randomString();
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
});
@ -308,7 +360,7 @@ describe('FileServerService', () => {
isLink: false,
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
});
@ -330,7 +382,7 @@ describe('FileServerService', () => {
isLink: false,
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
headers: {
@ -355,7 +407,7 @@ describe('FileServerService', () => {
isLink: false,
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
headers: {
@ -381,7 +433,7 @@ describe('FileServerService', () => {
name: 'sample.png',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${thumbnailKey}`,
headers: {
@ -409,7 +461,7 @@ describe('FileServerService', () => {
name: 'sample.png',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${thumbnailKey}`,
});
@ -432,7 +484,7 @@ describe('FileServerService', () => {
name: 'sample.png',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${webpublicKey}`,
});
@ -453,7 +505,7 @@ describe('FileServerService', () => {
type: 'application/x-msdownload',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
});
@ -470,7 +522,7 @@ describe('FileServerService', () => {
isLink: false,
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
});
@ -489,7 +541,7 @@ describe('FileServerService', () => {
name: 'remote.png',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
});
@ -511,7 +563,7 @@ describe('FileServerService', () => {
name: 'remote.png',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${accessKey}`,
headers: {
@ -539,7 +591,7 @@ describe('FileServerService', () => {
name: 'remote.png',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${thumbnailKey}`,
});
@ -563,7 +615,7 @@ describe('FileServerService', () => {
type: 'image/svg+xml',
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/files/${webpublicKey}`,
});
@ -576,7 +628,7 @@ describe('FileServerService', () => {
describe('GET /files/:key/*', () => {
test('GET /files/:key/* 正規の /files/:key にリダイレクトする', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: '/files/testkey/extra/path',
});
@ -589,7 +641,7 @@ describe('FileServerService', () => {
describe('GET /proxy/:url*', () => {
test('GET /proxy/:url* 外部メディアプロキシへリダイレクトする', async () => {
const res = await externalFastify.inject({
const res = await externalHonoServer.inject({
method: 'GET',
url: '/proxy/path-part?url=https%3A%2F%2Fexample.com%2Fimg.png&static=1',
});
@ -603,7 +655,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* misskey User-Agent を拒否する', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png',
headers: {
@ -616,7 +668,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* origin 指定時は User-Agent 必須を検証する', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png&origin=1',
headers: {
@ -631,7 +683,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* emoji 指定で非画像は 404 を返す', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}&emoji=1`,
headers: {
@ -644,7 +696,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* 非画像は 403 を返す', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}`,
headers: {
@ -657,7 +709,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* emoji static で webp を返す', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&emoji=1&static=1`,
headers: {
@ -672,7 +724,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* avatar static で webp を返す', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&avatar=1&static=1`,
headers: {
@ -687,7 +739,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* static で webp を返す', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&static=1`,
headers: {
@ -702,7 +754,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* preview で webp を返す', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&preview=1`,
headers: {
@ -717,7 +769,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* svg を webp に変換する', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remoteSvgUrl)}`,
headers: {
@ -732,7 +784,7 @@ describe('FileServerService', () => {
});
test('GET /proxy/:url* badge で低エントロピー画像は 404 を返す', async () => {
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(remoteFlatPngUrl)}&badge=1`,
headers: {
@ -753,7 +805,7 @@ describe('FileServerService', () => {
isLink: false,
});
const res = await fastify.inject({
const res = await honoServer.inject({
method: 'GET',
url: `/proxy/any?url=${encodeURIComponent(`${config.url}/files/${accessKey}`)}&origin=1`,
headers: {

View file

@ -13,7 +13,10 @@ import fetch, { RequestInit, type Headers } from 'node-fetch';
import * as htmlParser from 'node-html-parser';
import { DataSource } from 'typeorm';
import { type Response } from 'node-fetch';
import Fastify from 'fastify';
import { serve, type ServerType } from '@hono/node-server';
import { Hono } from 'hono';
import { createMiddleware } from 'hono/factory';
import type { ApiEnv } from '@/server/api/ApiServerTypes.js';
import { entities } from '@/postgres.js';
import { loadConfig } from '@/config.js';
import type * as misskey from 'misskey-js';
@ -99,7 +102,7 @@ export const api = async <E extends keyof misskey.Endpoints, P extends misskey.E
redirect: 'manual',
});
const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
const body = res.headers.get('content-type') === 'application/json'
? await res.json() as misskey.api.SwitchCaseResponseType<E, P>
: null;
@ -473,8 +476,8 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde
});
const jsonTypes = [
'application/json; charset=utf-8',
'application/activity+json; charset=utf-8',
'application/json',
'application/activity+json',
];
const htmlTypes = [
'text/html; charset=utf-8',
@ -651,37 +654,78 @@ export function castAsError(obj: Record<string, unknown>): { error: ApiError } {
}
export async function captureWebhook<T = SystemWebhookPayload>(postAction: () => Promise<void>, port = WEBHOOK_PORT): Promise<T> {
const fastify = Fastify();
const hono = new Hono();
let server: ServerType | null = null;
const closeServer = async () => {
if (server == null || !server.listening) return;
const currentServer = server;
server = null;
await new Promise<void>((resolve, reject) => {
currentServer.close((err) => {
if (err != null) {
reject(err);
return;
}
resolve();
});
});
};
let timeoutHandle: NodeJS.Timeout | null = null;
const result = await new Promise<string>(async (resolve, reject) => {
fastify.all('/', async (req, res) => {
hono.all('/', async (ctx) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
const body = JSON.stringify(req.body);
res.status(200).send('ok');
await fastify.close();
const body = await ctx.req.text();
await closeServer();
resolve(body);
return ctx.text('ok');
});
await fastify.listen({ port });
await new Promise<void>((resolveListen, rejectListen) => {
const onError = (error: Error) => {
server?.off('error', onError);
rejectListen(error);
};
server = serve({
fetch: hono.fetch,
hostname: '127.0.0.1',
port,
}, () => {
server?.off('error', onError);
resolveListen();
});
server.once('error', onError);
});
timeoutHandle = setTimeout(async () => {
await fastify.close();
await closeServer();
reject(new Error('timeout'));
}, 3000);
try {
await postAction();
} catch (e) {
await fastify.close();
await closeServer();
reject(e);
}
});
await fastify.close();
await closeServer();
return JSON.parse(result) as T;
}
export const dummyContextMiddleware = createMiddleware<ApiEnv>(async (ctx, next) => {
ctx.set('ip', '0.0.0.0');
ctx.set('ips', ['0.0.0.0']);
await next();
});

View file

@ -24,15 +24,12 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"jsxImportSource": "hono/jsx",
"rootDir": "./src",
"paths": {
"@/*": ["./src/*"]
},
"outDir": "./built",
"plugins": [
{"name": "@kitajs/ts-html-plugin"}
],
"types": [
"node"
],

730
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -52,6 +52,8 @@ minimumReleaseAgeExclude:
- slacc-win32-arm64-msvc
- slacc-win32-x64-msvc
- '@typescript/native-preview*'
- hono
- '@hono/node-server'
- pnpm
- esbuild # 脆弱性対応。そのうち消す
- '@esbuild/*' # 脆弱性対応。そのうち消す