diff --git a/.config/ci.yml b/.config/ci.yml index b0b97e9471..fefa45643c 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -349,6 +349,9 @@ attachLdSignatureForRelays: true # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index 83be98e429..e4eb8cc805 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -295,6 +295,9 @@ allowedPrivateNetworks: [ # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. diff --git a/.config/docker_example.yml b/.config/docker_example.yml index ee57da781f..7968a7d1f4 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -411,6 +411,9 @@ attachLdSignatureForRelays: true # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. diff --git a/.config/example.yml b/.config/example.yml index 704a80d413..d0ed4defaa 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -417,6 +417,9 @@ attachLdSignatureForRelays: true # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false +# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable. +# # default: false in production, true otherwise. +# #verbose: false # Settings for the activity logger, which records inbound activities to the database. # Disabled by default due to the large volume of data it saves. diff --git a/packages/backend/package.json b/packages/backend/package.json index 9aa26033d0..bb60eff05b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -97,6 +97,7 @@ "ajv": "8.17.1", "archiver": "7.0.1", "argon2": "^0.40.1", + "axios": "1.7.4", "async-mutex": "0.5.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", @@ -115,7 +116,6 @@ "deep-email-validator": "0.1.21", "fast-xml-parser": "4.4.1", "fastify": "5.3.2", - "fastify-multer": "^2.0.3", "fastify-raw-body": "5.0.0", "feed": "4.2.2", "file-type": "19.6.0", diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index cf9e9a9bae..538c529106 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import * as os from 'node:os'; import cluster from 'node:cluster'; +import * as net from 'node:net'; import chalk from 'chalk'; import chalkTemplate from 'chalk-template'; import * as Sentry from '@sentry/node'; @@ -18,7 +19,6 @@ import type { Config } from '@/config.js'; import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; import { jobQueue, server } from './common.js'; -import * as net from 'node:net'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 40f154c000..92fc2b8a13 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -135,7 +135,8 @@ type Source = { sql?: { disableQueryTruncation?: boolean, enableQueryParamLogging?: boolean, - } + }; + verbose?: boolean; } activityLogging?: { @@ -220,7 +221,8 @@ export type Config = { sql?: { disableQueryTruncation?: boolean, enableQueryParamLogging?: boolean, - } + }; + verbose?: boolean; } version: string; @@ -585,6 +587,7 @@ function applyEnvOverrides(config: Source) { _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]); _apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]); + _apply_top(['logging', ['verbose']]); _apply_top(['activityLogging', ['enabled', 'preSave', 'maxAge']]); _apply_top(['customHtml', ['head']]); } diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 8cc7df1a81..82c447baaa 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -45,6 +45,7 @@ import { isMimeImage } from '@/misc/is-mime-image.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { BunnyService } from '@/core/BunnyService.js'; +import { LoggerService } from './LoggerService.js'; type AddFileArgs = { /** User who wish to add file */ @@ -133,8 +134,10 @@ export class DriveService { private perUserDriveChart: PerUserDriveChart, private instanceChart: InstanceChart, private utilityService: UtilityService, + + loggerService: LoggerService, ) { - const logger = new Logger('drive', 'blue'); + const logger = loggerService.getLogger('drive', 'blue'); this.registerLogger = logger.createSubLogger('register', 'yellow'); this.downloaderLogger = logger.createSubLogger('downloader'); this.deleteLogger = logger.createSubLogger('delete'); diff --git a/packages/backend/src/core/LoggerService.ts b/packages/backend/src/core/LoggerService.ts index f102461a50..25721f0630 100644 --- a/packages/backend/src/core/LoggerService.ts +++ b/packages/backend/src/core/LoggerService.ts @@ -3,19 +3,25 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import type { KEYWORD } from 'color-convert/conversions.js'; +import { envOption } from '@/env.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; @Injectable() export class LoggerService { constructor( + @Inject(DI.config) + private config: Config, ) { } @bindThis public getLogger(domain: string, color?: KEYWORD | undefined) { - return new Logger(domain, color); + const verbose = this.config.logging?.verbose || envOption.verbose; + return new Logger(domain, color, verbose); } } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index e7a6be99fb..897b950022 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -28,9 +28,8 @@ import type { Config } from '@/config.js'; import { AccountMoveService } from '@/core/AccountMoveService.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { ThinUser } from '@/queue/types.js'; -import Logger from '../logger.js'; - -const logger = new Logger('following/create'); +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '../logger.js'; type Local = MiLocalUser | { id: MiLocalUser['id']; @@ -48,6 +47,7 @@ type Both = Local | Remote; @Injectable() export class UserFollowingService implements OnModuleInit { private userBlockingService: UserBlockingService; + private readonly logger: Logger; constructor( private moduleRef: ModuleRef, @@ -86,7 +86,10 @@ export class UserFollowingService implements OnModuleInit { private accountMoveService: AccountMoveService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, + + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('following/create'); } onModuleInit() { @@ -254,7 +257,7 @@ export class UserFollowingService implements OnModuleInit { followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null, }).catch(err => { if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); + this.logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); alreadyFollowed = true; } else { throw err; @@ -372,7 +375,7 @@ export class UserFollowingService implements OnModuleInit { }); if (following === null || !following.follower || !following.followee) { - logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); + this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index 79623768a8..b3735200eb 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -27,17 +27,19 @@ export type DataObject = Record | (object & { length?: never; } export default class Logger { private context: Context; private parentLogger: Logger | null = null; + public readonly verbose: boolean; - constructor(context: string, color?: KEYWORD) { + constructor(context: string, color?: KEYWORD, verbose?: boolean) { this.context = { name: context, color: color, }; + this.verbose = verbose ?? envOption.verbose; } @bindThis public createSubLogger(context: string, color?: KEYWORD): Logger { - const logger = new Logger(context, color); + const logger = new Logger(context, color, this.verbose); logger.parentLogger = this; return logger; } @@ -110,7 +112,7 @@ export default class Logger { @bindThis public debug(message: string, data?: Data, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) - if (process.env.NODE_ENV !== 'production' || envOption.verbose) { + if (process.env.NODE_ENV !== 'production' || this.verbose) { this.log('debug', message, data, important); } } diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts index 6cc896046f..fda63c7a9d 100644 --- a/packages/backend/src/misc/create-temp.ts +++ b/packages/backend/src/misc/create-temp.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { pipeline } from 'node:stream/promises'; +import fs from 'node:fs'; import * as tmp from 'tmp'; export function createTemp(): Promise<[string, () => void]> { @@ -27,3 +29,14 @@ export function createTempDir(): Promise<[string, () => void]> { ); }); } + +export async function saveToTempFile(stream: NodeJS.ReadableStream): Promise { + const [filepath, cleanup] = await createTemp(); + try { + await pipeline(stream, fs.createWriteStream(filepath)); + return filepath; + } catch (e) { + cleanup(); + throw e; + } +} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 6726d4aa67..8ff8da380a 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -16,6 +16,7 @@ import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js'; import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js'; +import { ServerUtilityService } from '@/server/ServerUtilityService.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; import { HealthServerService } from './HealthServerService.js'; @@ -126,6 +127,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ApiSearchMastodon, ApiStatusMastodon, ApiTimelineMastodon, + ServerUtilityService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerUtilityService.ts b/packages/backend/src/server/ServerUtilityService.ts new file mode 100644 index 0000000000..c2a3132489 --- /dev/null +++ b/packages/backend/src/server/ServerUtilityService.ts @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import querystring from 'querystring'; +import multipart from '@fastify/multipart'; +import { Inject, Injectable } from '@nestjs/common'; +import { FastifyInstance } from 'fastify'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { saveToTempFile } from '@/misc/create-temp.js'; + +@Injectable() +export class ServerUtilityService { + constructor( + @Inject(DI.config) + private readonly config: Config, + ) {} + + public addMultipartFormDataContentType(fastify: FastifyInstance): void { + fastify.register(multipart, { + limits: { + fileSize: this.config.maxFileSize, + files: 1, + }, + }); + + // Default behavior saves files to memory - we don't want that! + // Store to temporary file instead, and copy the body fields while we're at it. + fastify.addHook<{ Body?: Record }>('preValidation', async request => { + if (request.isMultipart()) { + // We can't use saveRequestFiles() because it erases all the data fields. + // Instead, recreate it manually. + // https://github.com/fastify/fastify-multipart/issues/549 + + for await (const part of request.parts()) { + if (part.type === 'field') { + const k = part.fieldname; + const v = part.value; + const body = request.body ??= {}; + + // Value can be string, buffer, or undefined. + // We only support the first one. + if (typeof(v) !== 'string') continue; + + // This is just progressive conversion from undefined -> string -> string[] + if (!body[k]) { + body[k] = v; + } else if (Array.isArray(body[k])) { + body[k].push(v); + } else { + body[k] = [body[k], v]; + } + } else { // Otherwise it's a file + try { + const filepath = await saveToTempFile(part.file); + + const tmpUploads = (request.tmpUploads ??= []); + tmpUploads.push(filepath); + + const requestSavedFiles = (request.savedRequestFiles ??= []); + requestSavedFiles.push({ + ...part, + filepath, + }); + } catch (e) { + // Cleanup to avoid file leak in case of errors + await request.cleanRequestFiles(); + request.tmpUploads = null; + request.savedRequestFiles = null; + throw e; + } + } + } + } + }); + } + + public addFormUrlEncodedContentType(fastify: FastifyInstance) { + fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { + let body = ''; + payload.on('data', (data) => { + body += data; + }); + payload.on('end', () => { + try { + const parsed = querystring.parse(body); + done(null, parsed); + } catch (e) { + done(e as Error); + } + }); + payload.on('error', done); + }); + } + + public addCORS(fastify: FastifyInstance) { + fastify.addHook('preHandler', (_, reply, done) => { + // Allow web-based clients to connect from other origins. + reply.header('Access-Control-Allow-Origin', '*'); + + // Mastodon uses all types of request methods. + reply.header('Access-Control-Allow-Methods', '*'); + + // Allow web-based clients to access Link header - required for mastodon pagination. + // https://stackoverflow.com/a/54928828 + // https://docs.joinmastodon.org/api/guidelines/#pagination + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers + reply.header('Access-Control-Expose-Headers', 'Link'); + + // Cache to avoid extra pre-flight requests + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age + reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds + + done(); + }); + } + + public addFlattenedQueryType(fastify: FastifyInstance) { + // Remove trailing "[]" from query params + fastify.addHook<{ Querystring?: Record }>('preValidation', (request, _reply, done) => { + if (!request.query || typeof(request.query) !== 'object') { + return done(); + } + + for (const key of Object.keys(request.query)) { + if (!key.endsWith('[]')) { + continue; + } + if (request.query[key] == null) { + continue; + } + + const newKey = key.substring(0, key.length - 2); + const newValue = request.query[key]; + const oldValue = request.query[newKey]; + + // Move the value to the correct key + if (oldValue != null) { + if (Array.isArray(oldValue)) { + // Works for both array and single values + request.query[newKey] = oldValue.concat(newValue); + } else if (Array.isArray(newValue)) { + // Preserve order + request.query[newKey] = [oldValue, ...newValue]; + } else { + // Preserve order + request.query[newKey] = [oldValue, newValue]; + } + } else { + request.query[newKey] = newValue; + } + + // Remove the invalid key + delete request.query[key]; + } + + return done(); + }); + } +} diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 601618553e..397626c49d 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -84,6 +84,8 @@ export class AuthenticateService implements OnApplicationShutdown { return [user, { id: accessToken.id, permission: app.permission, + appId: app.id, + app, } as MiAccessToken]; } else { return [user, accessToken]; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 1c5a781fd9..a78c3e9ae6 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -128,6 +128,7 @@ export * as 'antennas/update' from './endpoints/antennas/update.js'; export * as 'ap/get' from './endpoints/ap/get.js'; export * as 'ap/show' from './endpoints/ap/show.js'; export * as 'app/create' from './endpoints/app/create.js'; +export * as 'app/current' from './endpoints/app/current.js'; export * as 'app/show' from './endpoints/app/show.js'; export * as 'auth/accept' from './endpoints/auth/accept.js'; export * as 'auth/session/generate' from './endpoints/auth/session/generate.js'; diff --git a/packages/backend/src/server/api/endpoints/app/current.ts b/packages/backend/src/server/api/endpoints/app/current.ts new file mode 100644 index 0000000000..39b5ef347c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/current.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AppsRepository } from '@/models/_.js'; +import { AppEntityService } from '@/core/entities/AppEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['app'], + + errors: { + credentialRequired: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }, + noAppLogin: { + message: 'Not logged in with an app.', + code: 'NO_APP_LOGIN', + id: '339a4ad2-48c3-47fc-bd9d-2408f05120f8', + }, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'App', + }, + + // 10 calls per 5 seconds + limit: { + duration: 1000 * 5, + max: 10, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + private appEntityService: AppEntityService, + ) { + super(meta, paramDef, async (_, user, token) => { + if (!user) { + throw new ApiError(meta.errors.credentialRequired); + } + if (!token || !token.appId) { + throw new ApiError(meta.errors.noAppLogin); + } + + const app = token.app ?? await this.appsRepository.findOneByOrFail({ id: token.appId }); + + return await this.appEntityService.pack(app, user, { + detail: true, + includeSecret: false, + }); + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 59ab3b71aa..74fd9d7d59 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -3,13 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import querystring from 'querystring'; -import multer from 'fastify-multer'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; +import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; -import type { Config } from '@/config.js'; -import { getErrorData, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { getErrorData, getErrorException, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js'; import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js'; @@ -20,6 +16,7 @@ import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifi import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js'; import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js'; import { ApiError } from '@/server/api/error.js'; +import { ServerUtilityService } from '@/server/ServerUtilityService.js'; import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js'; import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js'; import type { Entity } from 'megalodon'; @@ -28,9 +25,6 @@ import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() export class MastodonApiServerService { constructor( - @Inject(DI.config) - private readonly config: Config, - private readonly mastoConverters: MastodonConverters, private readonly logger: MastodonLogger, private readonly clientService: MastodonClientService, @@ -42,115 +36,47 @@ export class MastodonApiServerService { private readonly apiSearchMastodon: ApiSearchMastodon, private readonly apiStatusMastodon: ApiStatusMastodon, private readonly apiTimelineMastodon: ApiTimelineMastodon, + private readonly serverUtilityService: ServerUtilityService, ) {} @bindThis public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); - - fastify.addHook('onRequest', (_, reply, done) => { - // Allow web-based clients to connect from other origins. - reply.header('Access-Control-Allow-Origin', '*'); - - // Mastodon uses all types of request methods. - reply.header('Access-Control-Allow-Methods', '*'); - - // Allow web-based clients to access Link header - required for mastodon pagination. - // https://stackoverflow.com/a/54928828 - // https://docs.joinmastodon.org/api/guidelines/#pagination - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers - reply.header('Access-Control-Expose-Headers', 'Link'); - - // Cache to avoid extra pre-flight requests - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age - reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds - - done(); - }); - - fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e) { - done(e as Error); - } - }); - payload.on('error', done); - }); - - // Remove trailing "[]" from query params - fastify.addHook('preValidation', (request, _reply, done) => { - if (!request.query || typeof(request.query) !== 'object') { - return done(); - } - - // Same object aliased with a different type - const query = request.query as Record; - - for (const key of Object.keys(query)) { - if (!key.endsWith('[]')) { - continue; - } - if (query[key] == null) { - continue; - } - - const newKey = key.substring(0, key.length - 2); - const newValue = query[key]; - const oldValue = query[newKey]; - - // Move the value to the correct key - if (oldValue != null) { - if (Array.isArray(oldValue)) { - // Works for both array and single values - query[newKey] = oldValue.concat(newValue); - } else if (Array.isArray(newValue)) { - // Preserve order - query[newKey] = [oldValue, ...newValue]; - } else { - // Preserve order - query[newKey] = [oldValue, newValue]; - } - } else { - query[newKey] = newValue; - } - - // Remove the invalid key - delete query[key]; - } - - return done(); - }); + this.serverUtilityService.addMultipartFormDataContentType(fastify); + this.serverUtilityService.addFormUrlEncodedContentType(fastify); + this.serverUtilityService.addCORS(fastify); + this.serverUtilityService.addFlattenedQueryType(fastify); + // Convert JS exceptions into error responses fastify.setErrorHandler((error, request, reply) => { const data = getErrorData(error); const status = getErrorStatus(error); + const exception = getErrorException(error); - this.logger.error(request, data, status); + if (exception) { + this.logger.exception(request, exception); + } - reply.code(status).send(data); + return reply.code(status).send(data); }); - fastify.register(multer.contentParser); + // Log error responses (including converted JSON exceptions) + fastify.addHook('onSend', (request, reply, payload, done) => { + if (reply.statusCode >= 400) { + if (typeof(payload) === 'string' && String(reply.getHeader('content-type')).toLowerCase().includes('application/json')) { + const body = JSON.parse(payload); + const data = getErrorData(body); + this.logger.error(request, data, reply.statusCode); + } + } + done(); + }); // External endpoints - this.apiAccountMastodon.register(fastify, upload); - this.apiAppsMastodon.register(fastify, upload); - this.apiFilterMastodon.register(fastify, upload); + this.apiAccountMastodon.register(fastify); + this.apiAppsMastodon.register(fastify); + this.apiFilterMastodon.register(fastify); this.apiInstanceMastodon.register(fastify); - this.apiNotificationsMastodon.register(fastify, upload); + this.apiNotificationsMastodon.register(fastify); this.apiSearchMastodon.register(fastify); this.apiStatusMastodon.register(fastify); this.apiTimelineMastodon.register(fastify); @@ -158,7 +84,7 @@ export class MastodonApiServerService { fastify.get('/v1/custom_emojis', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getInstanceCustomEmojis(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/announcements', async (_request, reply) => { @@ -166,7 +92,7 @@ export class MastodonApiServerService { const data = await client.getInstanceAnnouncements(); const response = data.data.map((announcement) => convertAnnouncement(announcement)); - reply.send(response); + return reply.send(response); }); fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { @@ -175,64 +101,62 @@ export class MastodonApiServerService { const client = this.clientService.getClient(_request); const data = await client.dismissInstanceAnnouncement(_request.body.id); - reply.send(data.data); + return reply.send(data.data); }); - fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const multipartData = await _request.file(); + fastify.post('/v1/media', async (_request, reply) => { + const multipartData = _request.savedRequestFiles?.[0]; if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' }); } const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData); const response = convertAttachment(data.data as Entity.Attachment); - reply.send(response); + return reply.send(response); }); - fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const multipartData = await _request.file(); + fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', async (_request, reply) => { + const multipartData = _request.savedRequestFiles?.[0]; if (!multipartData) { - reply.code(401).send({ error: 'No image' }); - return; + return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' }); } const client = this.clientService.getClient(_request); const data = await client.uploadMedia(multipartData, _request.body); const response = convertAttachment(data.data as Entity.Attachment); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/trends', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/trends/tags', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getInstanceTrends(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/trends/links', async (_request, reply) => { // As we do not have any system for news/links this will just return empty - reply.send([]); + return reply.send([]); }); fastify.get('/v1/preferences', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getPreferences(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get('/v1/followed_tags', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getFollowedTags(); - reply.send(data.data); + return reply.send(data.data); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => { @@ -241,7 +165,7 @@ export class MastodonApiServerService { const data = await client.getBookmarks(parseTimelineArgs(_request.query)); const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => { @@ -263,7 +187,7 @@ export class MastodonApiServerService { const data = await client.getFavourites(args); const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => { @@ -272,7 +196,7 @@ export class MastodonApiServerService { const data = await client.getMutes(parseTimelineArgs(_request.query)); const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => { @@ -281,7 +205,7 @@ export class MastodonApiServerService { const data = await client.getBlocks(parseTimelineArgs(_request.query)); const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: { limit?: string } }>('/v1/follow_requests', async (_request, reply) => { @@ -291,27 +215,27 @@ export class MastodonApiServerService { const data = await client.getFollowRequests(limit); const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account as Entity.Account))); - reply.send(response); + return reply.send(response); }); - fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.acceptFollowRequest(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); - fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.rejectFollowRequest(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); //#endregion @@ -325,7 +249,7 @@ export class MastodonApiServerService { focus?: string, is_sensitive?: string, }, - }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { + }>('/v1/media/:id', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const options = { @@ -336,7 +260,7 @@ export class MastodonApiServerService { const data = await client.updateMedia(_request.params.id, options); const response = convertAttachment(data.data); - reply.send(response); + return reply.send(response); }); done(); diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts index cf625d6e94..375ea1ef08 100644 --- a/packages/backend/src/server/api/mastodon/MastodonConverters.ts +++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Entity, MastodonEntity } from 'megalodon'; +import { Entity, MastodonEntity, MisskeyEntity } from 'megalodon'; import mfm from '@transfem-org/sfm-js'; import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js'; import { NotificationType } from 'megalodon/lib/src/notification.js'; @@ -369,6 +369,15 @@ export class MastodonConverters { type: convertNotificationType(notification.type as NotificationType), }; } + + public convertApplication(app: MisskeyEntity.App): MastodonEntity.Application { + return { + name: app.name, + scopes: app.permission, + redirect_uri: app.callbackUrl, + redirect_uris: [app.callbackUrl], + }; + } } function simpleConvert(data: T): T { @@ -459,4 +468,3 @@ export function convertRelationship(relationship: Partial & note: relationship.note ?? '', }; } - diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts index 81d3e8f03d..5ea69ed151 100644 --- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -3,33 +3,49 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import { FastifyRequest } from 'fastify'; -import Logger from '@/logger.js'; +import { Injectable } from '@nestjs/common'; +import { isAxiosError } from 'axios'; +import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import { ApiError } from '@/server/api/error.js'; -import { EnvService } from '@/core/EnvService.js'; import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js'; +import { AuthenticationError } from '@/server/api/AuthenticateService.js'; +import type { FastifyRequest } from 'fastify'; @Injectable() export class MastodonLogger { public readonly logger: Logger; constructor( - @Inject(EnvService) - private readonly envService: EnvService, - loggerService: LoggerService, ) { this.logger = loggerService.getLogger('masto-api'); } public error(request: FastifyRequest, error: MastodonError, status: number): void { - if ((status < 400 && status > 499) || this.envService.env.NODE_ENV === 'development') { - const path = new URL(request.url, getBaseUrl(request)).pathname; + const path = getPath(request); + + if (status >= 400 && status <= 499) { // Client errors + this.logger.debug(`Error in mastodon endpoint ${request.method} ${path}:`, error); + } else { // Server errors this.logger.error(`Error in mastodon endpoint ${request.method} ${path}:`, error); } } + + public exception(request: FastifyRequest, ex: Error): void { + const path = getPath(request); + + // Exceptions are always server errors, and should therefore always be logged. + this.logger.error(`Exception in mastodon endpoint ${request.method} ${path}:`, ex); + } +} + +function getPath(request: FastifyRequest): string { + try { + return new URL(request.url, getBaseUrl(request)).pathname; + } catch { + return request.url; + } } // TODO move elsewhere @@ -38,6 +54,43 @@ export interface MastodonError { error_description?: string; } +export function getErrorException(error: unknown): Error | null { + if (!(error instanceof Error)) { + return null; + } + + // AxiosErrors need special decoding + if (isAxiosError(error)) { + // Axios errors with a response are from the remote + if (error.response) { + return null; + } + + // This is the inner exception, basically + if (error.cause && !isAxiosError(error.cause)) { + if (!error.cause.stack) { + error.cause.stack = error.stack; + } + + return error.cause; + } + + const ex = new Error(); + ex.name = error.name; + ex.stack = error.stack; + ex.message = error.message; + ex.cause = error.cause; + return ex; + } + + // AuthenticationError is a client error + if (error instanceof AuthenticationError) { + return null; + } + + return error; +} + export function getErrorData(error: unknown): MastodonError { // Axios wraps errors from the backend error = unpackAxiosError(error); @@ -59,17 +112,33 @@ export function getErrorData(error: unknown): MastodonError { } } + if ('error' in error && typeof (error.error) === 'string') { + if ('message' in error && typeof (error.message) === 'string') { + return convertErrorMessageError(error as { error: string, message: string }); + } + } + if (error instanceof Error) { return convertGenericError(error); } - return convertUnknownError(error); + if ('error' in error && typeof(error.error) === 'string') { + // "error_description" is string, undefined, or not present. + if (!('error_description' in error) || typeof(error.error_description) === 'string' || typeof(error.error_description) === 'undefined') { + return convertMastodonError(error as MastodonError); + } + } + + return { + error: 'INTERNAL_ERROR', + error_description: 'Internal error occurred. Please contact us if the error persists.', + }; } function unpackAxiosError(error: unknown): unknown { - if (error && typeof(error) === 'object') { - if ('response' in error && error.response && typeof (error.response) === 'object') { - if ('data' in error.response && error.response.data && typeof (error.response.data) === 'object') { + if (isAxiosError(error)) { + if (error.response) { + if (error.response.data && typeof(error.response.data) === 'object') { if ('error' in error.response.data && error.response.data.error && typeof(error.response.data.error) === 'object') { return error.response.data.error; } @@ -80,46 +149,48 @@ function unpackAxiosError(error: unknown): unknown { // No data - this is a fallback to avoid leaking request/response details in the error return undefined; } + + if (error.cause && !isAxiosError(error.cause)) { + if (!error.cause.stack) { + error.cause.stack = error.stack; + } + + return error.cause; + } + + // No data - this is a fallback to avoid leaking request/response details in the error + return String(error); } return error; } function convertApiError(apiError: ApiError): MastodonError { - const mastoError: MastodonError & Partial = { + return { error: apiError.code, error_description: apiError.message, - ...apiError, }; - - delete mastoError.code; - delete mastoError.message; - delete mastoError.httpStatusCode; - - return mastoError; } -function convertUnknownError(data: object = {}): MastodonError { - return Object.assign({}, data, { - error: 'INTERNAL_ERROR', - error_description: 'Internal error occurred. Please contact us if the error persists.', - id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', - kind: 'server', - }); +function convertErrorMessageError(error: { error: string, message: string }): MastodonError { + return { + error: error.error, + error_description: error.message, + }; } function convertGenericError(error: Error): MastodonError { - const mastoError: MastodonError & Partial = { + return { error: 'INTERNAL_ERROR', error_description: String(error), - ...error, }; +} - delete mastoError.name; - delete mastoError.message; - delete mastoError.stack; - - return mastoError; +function convertMastodonError(error: MastodonError): MastodonError { + return { + error: error.error, + error_description: error.error_description, + }; } export function getErrorStatus(error: unknown): number { @@ -134,6 +205,10 @@ export function getErrorStatus(error: unknown): number { if ('httpStatusCode' in error && typeof(error.httpStatusCode) === 'number') { return error.httpStatusCode; } + + if ('statusCode' in error && typeof(error.statusCode) === 'number') { + return error.statusCode; + } } return 500; diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 8bc3c14c15..6a1af62be7 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -11,7 +11,6 @@ import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js'; import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js'; -import type multer from 'fastify-multer'; import type { FastifyInstance } from 'fastify'; interface ApiAccountMastodonRoute { @@ -34,7 +33,7 @@ export class ApiAccountMastodon { private readonly driveService: DriveService, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { + public register(fastify: FastifyInstance): void { fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.verifyAccountCredentials(); @@ -48,7 +47,7 @@ export class ApiAccountMastodon { language: '', }, }); - reply.send(response); + return reply.send(response); }); fastify.patch<{ @@ -70,60 +69,50 @@ export class ApiAccountMastodon { value: string, }[], }, - }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { + }>('/v1/accounts/update_credentials', async (_request, reply) => { const accessTokens = _request.headers.authorization; const client = this.clientService.getClient(_request); // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it. - if (_request.files.length > 0 && accessTokens) { + if (_request.savedRequestFiles?.length && accessTokens) { const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const avatar = (_request.files as any).find((obj: any) => { + const avatar = _request.savedRequestFiles.find(obj => { return obj.fieldname === 'avatar'; }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const header = (_request.files as any).find((obj: any) => { + const header = _request.savedRequestFiles.find(obj => { return obj.fieldname === 'header'; }); if (tokeninfo && avatar) { const upload = await this.driveService.addFile({ user: { id: tokeninfo.userId, host: null }, - path: avatar.path, - name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, + path: avatar.filepath, + name: avatar.filename && avatar.filename !== 'file' ? avatar.filename : undefined, sensitive: false, }); if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).avatar = upload.id; + _request.body.avatar = upload.id; } } else if (tokeninfo && header) { const upload = await this.driveService.addFile({ user: { id: tokeninfo.userId, host: null }, - path: header.path, - name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, + path: header.filepath, + name: header.filename && header.filename !== 'file' ? header.filename : undefined, sensitive: false, }); if (upload.type.startsWith('image/')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).header = upload.id; + _request.body.header = upload.id; } } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((_request.body as any).fields_attributes) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fields = (_request.body as any).fields_attributes.map((field: any) => { + if (_request.body.fields_attributes) { + for (const field of _request.body.fields_attributes) { if (!(field.name.trim() === '' && field.value.trim() === '')) { if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty'); if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty'); } - return { - ...field, - }; - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); + } + _request.body.fields_attributes = _request.body.fields_attributes.filter(field => field.name.trim().length > 0 && field.value.length > 0); } const options = { @@ -139,7 +128,7 @@ export class ApiAccountMastodon { const data = await client.updateCredentials(options); const response = await this.mastoConverters.convertAccount(data.data); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: { acct?: string } }>('/v1/accounts/lookup', async (_request, reply) => { @@ -151,7 +140,7 @@ export class ApiAccountMastodon { data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; const response = await this.mastoConverters.convertAccount(data.data.accounts[0]); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/accounts/relationships', async (_request, reply) => { @@ -161,7 +150,7 @@ export class ApiAccountMastodon { const data = await client.getRelationships(_request.query.id); const response = data.data.map(relationship => convertRelationship(relationship)); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { @@ -171,7 +160,7 @@ export class ApiAccountMastodon { const data = await client.getAccount(_request.params.id); const account = await this.mastoConverters.convertAccount(data.data); - reply.send(account); + return reply.send(account); }); fastify.get('/v1/accounts/:id/statuses', async (request, reply) => { @@ -183,7 +172,7 @@ export class ApiAccountMastodon { const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { @@ -193,7 +182,7 @@ export class ApiAccountMastodon { const data = await client.getFeaturedTags(); const response = data.data.map((tag) => convertFeaturedTag(tag)); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/accounts/:id/followers', async (request, reply) => { @@ -207,7 +196,7 @@ export class ApiAccountMastodon { const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/accounts/:id/following', async (request, reply) => { @@ -221,7 +210,7 @@ export class ApiAccountMastodon { const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { @@ -231,10 +220,10 @@ export class ApiAccountMastodon { const data = await client.getAccountLists(_request.params.id); const response = data.data.map((list) => convertList(list)); - reply.send(response); + return reply.send(response); }); - fastify.post('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/follow', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -242,10 +231,10 @@ export class ApiAccountMastodon { const acct = convertRelationship(data.data); acct.following = true; // TODO this is wrong, follow may not have processed immediately - reply.send(acct); + return reply.send(acct); }); - fastify.post('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/unfollow', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -253,20 +242,20 @@ export class ApiAccountMastodon { const acct = convertRelationship(data.data); acct.following = false; - reply.send(acct); + return reply.send(acct); }); - fastify.post('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/block', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.blockAccount(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); - fastify.post('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/unblock', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -276,7 +265,7 @@ export class ApiAccountMastodon { return reply.send(response); }); - fastify.post('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/mute', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); @@ -286,17 +275,17 @@ export class ApiAccountMastodon { ); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); - fastify.post('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/accounts/:id/unmute', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.unmuteAccount(_request.params.id); const response = convertRelationship(data.data); - reply.send(response); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts index dbef3b7d35..72b520c74a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/apps.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts @@ -5,8 +5,8 @@ import { Injectable } from '@nestjs/common'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; +import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'; import type { FastifyInstance } from 'fastify'; -import type multer from 'fastify-multer'; const readScope = [ 'read:account', @@ -48,9 +48,9 @@ const writeScope = [ export interface AuthPayload { scopes?: string | string[], - redirect_uris?: string, - client_name?: string, - website?: string, + redirect_uris?: string | string[], + client_name?: string | string[], + website?: string | string[], } // Not entirely right, but it gets TypeScript to work so *shrug* @@ -60,14 +60,18 @@ type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; export class ApiAppsMastodon { constructor( private readonly clientService: MastodonClientService, + private readonly mastoConverters: MastodonConverters, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { - fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { + public register(fastify: FastifyInstance): void { + fastify.post('/v1/apps', async (_request, reply) => { const body = _request.body ?? _request.query; if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' }); if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' }); + if (Array.isArray(body.redirect_uris)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "redirect_uris": only one value is allowed' }); if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' }); + if (Array.isArray(body.client_name)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "client_name": only one value is allowed' }); + if (Array.isArray(body.website)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "website": only one value is allowed' }); let scope = body.scopes; if (typeof scope === 'string') { @@ -88,12 +92,10 @@ export class ApiAppsMastodon { } } - const red = body.redirect_uris; - const client = this.clientService.getClient(_request); const appData = await client.registerApp(body.client_name, { scopes: Array.from(pushScope), - redirect_uris: red, + redirect_uri: body.redirect_uris, website: body.website, }); @@ -101,12 +103,19 @@ export class ApiAppsMastodon { id: Math.floor(Math.random() * 100).toString(), name: appData.name, website: body.website, - redirect_uri: red, + redirect_uri: body.redirect_uris, client_id: Buffer.from(appData.url || '').toString('base64'), client_secret: appData.clientSecret, }; - reply.send(response); + return reply.send(response); + }); + + fastify.get('/v1/apps/verify_credentials', async (_request, reply) => { + const client = this.clientService.getClient(_request); + const data = await client.verifyAppCredentials(); + const response = this.mastoConverters.convertApplication(data.data); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index deac1e9aad..f2bd0052d5 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -8,7 +8,6 @@ import { toBoolean } from '@/server/api/mastodon/argsUtils.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { convertFilter } from '../MastodonConverters.js'; import type { FastifyInstance } from 'fastify'; -import type multer from 'fastify-multer'; interface ApiFilterMastodonRoute { Params: { @@ -29,14 +28,14 @@ export class ApiFilterMastodon { private readonly clientService: MastodonClientService, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { + public register(fastify: FastifyInstance): void { fastify.get('/v1/filters', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.getFilters(); const response = data.data.map((filter) => convertFilter(filter)); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/filters/:id', async (_request, reply) => { @@ -46,10 +45,10 @@ export class ApiFilterMastodon { const data = await client.getFilter(_request.params.id); const response = convertFilter(data.data); - reply.send(response); + return reply.send(response); }); - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/filters', async (_request, reply) => { if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); @@ -65,10 +64,10 @@ export class ApiFilterMastodon { const data = await client.createFilter(_request.body.phrase, _request.body.context, options); const response = convertFilter(data.data); - reply.send(response); + return reply.send(response); }); - fastify.post('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/filters/:id', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' }); if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' }); @@ -85,7 +84,7 @@ export class ApiFilterMastodon { const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options); const response = convertFilter(data.data); - reply.send(response); + return reply.send(response); }); fastify.delete('/v1/filters/:id', async (_request, reply) => { @@ -94,7 +93,7 @@ export class ApiFilterMastodon { const client = this.clientService.getClient(_request); const data = await client.deleteFilter(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts index a168339ac6..cfca5b1350 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/instance.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts @@ -87,7 +87,7 @@ export class ApiInstanceMastodon { rules: instance.rules ?? [], }; - reply.send(response); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index ee6c990fd1..f6cc59e782 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -10,7 +10,6 @@ import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js' import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js'; import { MastodonClientService } from '../MastodonClientService.js'; import type { FastifyInstance } from 'fastify'; -import type multer from 'fastify-multer'; interface ApiNotifyMastodonRoute { Params: { @@ -26,7 +25,7 @@ export class ApiNotificationsMastodon { private readonly clientService: MastodonClientService, ) {} - public register(fastify: FastifyInstance, upload: ReturnType): void { + public register(fastify: FastifyInstance): void { fastify.get('/v1/notifications', async (request, reply) => { const { client, me } = await this.clientService.getAuthClient(request); const data = await client.getNotifications(parseTimelineArgs(request.query)); @@ -46,7 +45,7 @@ export class ApiNotificationsMastodon { } attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/notification/:id', async (_request, reply) => { @@ -63,23 +62,23 @@ export class ApiNotificationsMastodon { }); } - reply.send(response); + return reply.send(response); }); - fastify.post('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/notification/:id/dismiss', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); const client = this.clientService.getClient(_request); const data = await client.dismissNotification(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); - fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { + fastify.post('/v1/notifications/clear', async (_request, reply) => { const client = this.clientService.getClient(_request); const data = await client.dismissNotifications(); - reply.send(data.data); + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 33bfa87e5f..f58f21966c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -62,7 +62,7 @@ export class ApiSearchMastodon { attachMinMaxPagination(request, reply, response[type]); } - reply.send(response); + return reply.send(response); }); fastify.get('/v2/search', async (request, reply) => { @@ -103,7 +103,7 @@ export class ApiSearchMastodon { // Offset pagination is the only possible option attachOffsetPagination(request, reply, longestResult); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/trends/statuses', async (request, reply) => { @@ -126,7 +126,7 @@ export class ApiSearchMastodon { const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get('/v2/suggestions', async (request, reply) => { @@ -158,7 +158,7 @@ export class ApiSearchMastodon { })); attachOffsetPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index ec31e0cc46..22b8a911ca 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -38,7 +38,7 @@ export class ApiStatusMastodon { response.media_attachments = []; } - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { @@ -47,7 +47,7 @@ export class ApiStatusMastodon { const client = this.clientService.getClient(_request); const data = await client.getStatusSource(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { @@ -59,7 +59,7 @@ export class ApiStatusMastodon { const descendants = await Promise.all(data.descendants.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); const response = { ancestors, descendants }; - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { @@ -68,7 +68,7 @@ export class ApiStatusMastodon { const user = await this.clientService.getAuth(_request); const edits = await this.mastoConverters.getEdits(_request.params.id, user); - reply.send(edits); + return reply.send(edits); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { @@ -78,7 +78,7 @@ export class ApiStatusMastodon { const data = await client.getStatusRebloggedBy(_request.params.id); const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { @@ -88,7 +88,7 @@ export class ApiStatusMastodon { const data = await client.getStatusFavouritedBy(_request.params.id); const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { @@ -98,7 +98,7 @@ export class ApiStatusMastodon { const data = await client.getMedia(_request.params.id); const response = convertAttachment(data.data); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { @@ -108,7 +108,7 @@ export class ApiStatusMastodon { const data = await client.getPoll(_request.params.id); const response = convertPoll(data.data); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { @@ -119,7 +119,7 @@ export class ApiStatusMastodon { const data = await client.votePoll(_request.params.id, _request.body.choices); const response = convertPoll(data.data); - reply.send(response); + return reply.send(response); }); fastify.post<{ @@ -161,14 +161,14 @@ export class ApiStatusMastodon { body.in_reply_to_id, removed, ); - reply.send(a.data); + return reply.send(a.data); } if (body.in_reply_to_id && removed === '/unreact') { const id = body.in_reply_to_id; const post = await client.getStatus(id); const react = post.data.emoji_reactions.filter((e: Entity.Emoji) => e.me)[0].name; const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); + return reply.send(data.data); } if (!body.media_ids) body.media_ids = undefined; if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; @@ -194,7 +194,7 @@ export class ApiStatusMastodon { const data = await client.postStatus(text, options); const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me); - reply.send(response); + return reply.send(response); }); fastify.put<{ @@ -233,7 +233,7 @@ export class ApiStatusMastodon { const data = await client.editStatus(_request.params.id, options); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { @@ -243,7 +243,7 @@ export class ApiStatusMastodon { const data = await client.createEmojiReaction(_request.params.id, '❤'); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { @@ -253,7 +253,7 @@ export class ApiStatusMastodon { const data = await client.deleteEmojiReaction(_request.params.id, '❤'); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { @@ -263,7 +263,7 @@ export class ApiStatusMastodon { const data = await client.reblogStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { @@ -273,7 +273,7 @@ export class ApiStatusMastodon { const data = await client.unreblogStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { @@ -283,7 +283,7 @@ export class ApiStatusMastodon { const data = await client.bookmarkStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { @@ -293,7 +293,7 @@ export class ApiStatusMastodon { const data = await client.unbookmarkStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' }); @@ -302,7 +302,7 @@ export class ApiStatusMastodon { const data = await client.pinStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { @@ -312,7 +312,7 @@ export class ApiStatusMastodon { const data = await client.unpinStatus(_request.params.id); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { @@ -323,7 +323,7 @@ export class ApiStatusMastodon { const data = await client.createEmojiReaction(_request.params.id, _request.params.name); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { @@ -334,7 +334,7 @@ export class ApiStatusMastodon { const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); const response = await this.mastoConverters.convertStatus(data.data, me); - reply.send(response); + return reply.send(response); }); fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { @@ -343,7 +343,7 @@ export class ApiStatusMastodon { const client = this.clientService.getClient(_request); const data = await client.deleteStatus(_request.params.id); - reply.send(data.data); + return reply.send(data.data); }); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index b6162d9eb2..b2f7b18dc9 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -28,7 +28,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => { @@ -38,7 +38,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => { @@ -50,7 +50,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => { @@ -62,7 +62,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => { @@ -72,7 +72,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -82,7 +82,7 @@ export class ApiTimelineMastodon { const data = await client.getList(_request.params.id); const response = convertList(data.data); - reply.send(response); + return reply.send(response); }); fastify.get('/v1/lists', async (request, reply) => { @@ -91,7 +91,7 @@ export class ApiTimelineMastodon { const response = data.data.map((list: Entity.List) => convertList(list)); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => { @@ -102,7 +102,7 @@ export class ApiTimelineMastodon { const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); attachMinMaxPagination(request, reply, response); - reply.send(response); + return reply.send(response); }); fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { @@ -112,7 +112,7 @@ export class ApiTimelineMastodon { const client = this.clientService.getClient(_request); const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); + return reply.send(data.data); }); fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { @@ -122,7 +122,7 @@ export class ApiTimelineMastodon { const client = this.clientService.getClient(_request); const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); - reply.send(data.data); + return reply.send(data.data); }); fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { @@ -132,7 +132,7 @@ export class ApiTimelineMastodon { const data = await client.createList(_request.body.title); const response = convertList(data.data); - reply.send(response); + return reply.send(response); }); fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -143,7 +143,7 @@ export class ApiTimelineMastodon { const data = await client.updateList(_request.params.id, _request.body.title); const response = convertList(data.data); - reply.send(response); + return reply.send(response); }); fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { @@ -152,7 +152,7 @@ export class ApiTimelineMastodon { const client = this.clientService.getClient(_request); await client.deleteList(_request.params.id); - reply.send({}); + return reply.send({}); }); } } diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index a65acb7c9b..01ee451297 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -3,15 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import querystring from 'querystring'; import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; -import multer from 'fastify-multer'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js'; import { getErrorData } from '@/server/api/mastodon/MastodonLogger.js'; +import { ServerUtilityService } from '@/server/ServerUtilityService.js'; import type { FastifyInstance } from 'fastify'; const kinds = [ @@ -56,6 +55,7 @@ export class OAuth2ProviderService { private config: Config, private readonly mastodonClientService: MastodonClientService, + private readonly serverUtilityService: ServerUtilityService, ) { } // https://datatracker.ietf.org/doc/html/rfc8414.html @@ -92,36 +92,10 @@ export class OAuth2ProviderService { }); }); */ - const upload = multer({ - storage: multer.diskStorage({}), - limits: { - fileSize: this.config.maxFileSize || 262144000, - files: 1, - }, - }); - - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('Access-Control-Allow-Origin', '*'); - done(); - }); - - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { - let body = ''; - payload.on('data', (data) => { - body += data; - }); - payload.on('end', () => { - try { - const parsed = querystring.parse(body); - done(null, parsed); - } catch (e: unknown) { - done(e instanceof Error ? e : new Error(String(e))); - } - }); - payload.on('error', done); - }); - - fastify.register(multer.contentParser); + this.serverUtilityService.addMultipartFormDataContentType(fastify); + this.serverUtilityService.addFormUrlEncodedContentType(fastify); + this.serverUtilityService.addCORS(fastify); + this.serverUtilityService.addFlattenedQueryType(fastify); for (const url of ['/authorize', '/authorize/']) { fastify.get<{ Querystring: Record }>(url, async (request, reply) => { @@ -132,11 +106,11 @@ export class OAuth2ProviderService { if (request.query.state) redirectUri.searchParams.set('state', String(request.query.state)); if (request.query.redirect_uri) redirectUri.searchParams.set('redirect_uri', String(request.query.redirect_uri)); - reply.redirect(redirectUri.toString()); + return reply.redirect(redirectUri.toString()); }); } - fastify.post<{ Body?: Record, Querystring: Record }>('/token', { preHandler: upload.none() }, async (request, reply) => { + fastify.post<{ Body?: Record, Querystring: Record }>('/token', async (request, reply) => { const body = request.body ?? request.query; if (body.grant_type === 'client_credentials') { @@ -146,7 +120,7 @@ export class OAuth2ProviderService { scope: 'read', created_at: Math.floor(new Date().getTime() / 1000), }; - reply.send(ret); + return reply.send(ret); } try { @@ -163,13 +137,13 @@ export class OAuth2ProviderService { const ret = { access_token: atData.accessToken, token_type: 'Bearer', - scope: body.scope || 'read write follow push', - created_at: Math.floor(new Date().getTime() / 1000), + scope: atData.scope || body.scope || 'read write follow push', + created_at: atData.createdAt || Math.floor(new Date().getTime() / 1000), }; - reply.send(ret); + return reply.send(ret); } catch (e: unknown) { const data = getErrorData(e); - reply.code(401).send(data); + return reply.code(401).send(data); } }); } diff --git a/packages/megalodon/src/index.ts b/packages/megalodon/src/index.ts index 50663c3ce5..bacd0574d4 100644 --- a/packages/megalodon/src/index.ts +++ b/packages/megalodon/src/index.ts @@ -9,6 +9,7 @@ import * as NotificationType from './notification' import FilterContext from './filter_context' import Converter from './converter' import MastodonEntity from './mastodon/entity'; +import MisskeyEntity from './misskey/entity'; export { Response, @@ -23,4 +24,5 @@ export { Entity, Converter, MastodonEntity, + MisskeyEntity, } diff --git a/packages/megalodon/src/mastodon/entities/application.ts b/packages/megalodon/src/mastodon/entities/application.ts index a3f07997ee..f402152bf6 100644 --- a/packages/megalodon/src/mastodon/entities/application.ts +++ b/packages/megalodon/src/mastodon/entities/application.ts @@ -3,5 +3,8 @@ namespace MastodonEntity { name: string website?: string | null vapid_key?: string | null + scopes: string[] + redirect_uris: string[] + redirect_uri?: string } } diff --git a/packages/megalodon/src/misskey.ts b/packages/megalodon/src/misskey.ts index 670b31e838..bc38e27ce5 100644 --- a/packages/megalodon/src/misskey.ts +++ b/packages/megalodon/src/misskey.ts @@ -39,9 +39,9 @@ export default class Misskey implements MegalodonInterface { public async registerApp( client_name: string, - options: Partial<{ scopes: Array; redirect_uris: string; website: string }> = { + options: Partial<{ scopes: Array; redirect_uri: string; website?: string }> = { scopes: MisskeyAPI.DEFAULT_SCOPE, - redirect_uris: this.baseUrl + redirect_uri: this.baseUrl } ): Promise { return this.createApp(client_name, options).then(async appData => { @@ -62,13 +62,14 @@ export default class Misskey implements MegalodonInterface { */ public async createApp( client_name: string, - options: Partial<{ scopes: Array; redirect_uris: string; website: string }> = { + options: Partial<{ scopes: Array; redirect_uri: string; website?: string }> = { scopes: MisskeyAPI.DEFAULT_SCOPE, - redirect_uris: this.baseUrl + redirect_uri: this.baseUrl } ): Promise { - const redirect_uris = options.redirect_uris || this.baseUrl + const redirect_uri = options.redirect_uri || this.baseUrl const scopes = options.scopes || MisskeyAPI.DEFAULT_SCOPE + const website = options.website ?? ''; const params: { name: string @@ -77,9 +78,9 @@ export default class Misskey implements MegalodonInterface { callbackUrl: string } = { name: client_name, - description: '', + description: website, permission: scopes, - callbackUrl: redirect_uris + callbackUrl: redirect_uri } /** @@ -101,7 +102,7 @@ export default class Misskey implements MegalodonInterface { website: null, redirect_uri: res.data.callbackUrl, client_id: '', - client_secret: res.data.secret + client_secret: res.data.secret! } return OAuth.AppData.from(appData) }) @@ -121,11 +122,8 @@ export default class Misskey implements MegalodonInterface { // ====================================== // apps // ====================================== - public async verifyAppCredentials(): Promise> { - return new Promise((_, reject) => { - const err = new NoImplementedError('misskey does not support') - reject(err) - }) + public async verifyAppCredentials(): Promise> { + return await this.client.post('/api/app/current'); } // ====================================== @@ -1502,13 +1500,13 @@ export default class Misskey implements MegalodonInterface { /** * POST /api/drive/files/create */ - public async uploadMedia(file: any, _options?: { description?: string; focus?: string }): Promise> { + public async uploadMedia(file: { filepath: fs.PathLike, mimetype: string, filename: string }, _options?: { description?: string; focus?: string }): Promise> { const formData = new FormData() - formData.append('file', fs.createReadStream(file.path), { + formData.append('file', fs.createReadStream(file.filepath), { contentType: file.mimetype, }); - if (file.originalname != null && file.originalname !== "file") formData.append("name", file.originalname); + if (file.filename && file.filename !== "file") formData.append("name", file.filename); if (_options?.description != null) formData.append("comment", _options.description); let headers: { [key: string]: string } = {} diff --git a/packages/megalodon/src/misskey/api_client.ts b/packages/megalodon/src/misskey/api_client.ts index 550897b669..659184d156 100644 --- a/packages/megalodon/src/misskey/api_client.ts +++ b/packages/megalodon/src/misskey/api_client.ts @@ -1,6 +1,5 @@ import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' import dayjs from 'dayjs' -import FormData from 'form-data' import { DEFAULT_UA } from '../default' import Response from '../response' @@ -575,22 +574,26 @@ namespace MisskeyAPI { this.accessToken = accessToken this.baseUrl = baseUrl this.userAgent = userAgent - this.abortController = new AbortController() - axios.defaults.signal = this.abortController.signal + this.abortController = new AbortController(); } /** * GET request to misskey API. **/ public async get(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise> { + if (!headers['Authorization'] && this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + if (!headers['User-Agent']) { + headers['User-Agent'] = this.userAgent; + } + let options: AxiosRequestConfig = { params: params, - headers: { - 'User-Agent': this.userAgent, - ...headers, - }, + headers, maxContentLength: Infinity, - maxBodyLength: Infinity + maxBodyLength: Infinity, + signal: this.abortController.signal, } return axios.get(this.baseUrl + path, options).then((resp: AxiosResponse) => { const res: Response = { @@ -610,22 +613,21 @@ namespace MisskeyAPI { * @param headers Request header object */ public async post(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise> { + if (!headers['Authorization'] && this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + if (!headers['User-Agent']) { + headers['User-Agent'] = this.userAgent; + } + let options: AxiosRequestConfig = { headers: headers, maxContentLength: Infinity, - maxBodyLength: Infinity + maxBodyLength: Infinity, + signal: this.abortController.signal, } - let bodyParams = params - if (this.accessToken) { - if (params instanceof FormData) { - bodyParams.append('i', this.accessToken) - } else { - bodyParams = Object.assign(params, { - i: this.accessToken - }) - } - } - return axios.post(this.baseUrl + path, bodyParams, options).then((resp: AxiosResponse) => { + + return axios.post(this.baseUrl + path, params, options).then((resp: AxiosResponse) => { const res: Response = { data: resp.data, status: resp.status, diff --git a/packages/megalodon/src/misskey/entities/app.ts b/packages/megalodon/src/misskey/entities/app.ts index 40a704b944..49c431596f 100644 --- a/packages/megalodon/src/misskey/entities/app.ts +++ b/packages/megalodon/src/misskey/entities/app.ts @@ -4,6 +4,6 @@ namespace MisskeyEntity { name: string callbackUrl: string permission: Array - secret: string + secret?: string } } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 18cb070af5..44700add31 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -552,6 +552,9 @@ type AppCreateRequest = operations['app___create']['requestBody']['content']['ap // @public (undocumented) type AppCreateResponse = operations['app___create']['responses']['200']['content']['application/json']; +// @public (undocumented) +type AppCurrentResponse = operations['app___current']['responses']['200']['content']['application/json']; + // @public (undocumented) type AppShowRequest = operations['app___show']['requestBody']['content']['application/json']; @@ -1643,6 +1646,7 @@ declare namespace entities { ApShowResponse, AppCreateRequest, AppCreateResponse, + AppCurrentResponse, AppShowRequest, AppShowResponse, AuthAcceptRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 75b3c5769e..0dfe042811 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1313,6 +1313,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 9293a5e950..b424927316 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -159,6 +159,7 @@ import type { ApShowResponse, AppCreateRequest, AppCreateResponse, + AppCurrentResponse, AppShowRequest, AppShowResponse, AuthAcceptRequest, @@ -778,6 +779,7 @@ export type Endpoints = { 'ap/get': { req: ApGetRequest; res: ApGetResponse }; 'ap/show': { req: ApShowRequest; res: ApShowResponse }; 'app/create': { req: AppCreateRequest; res: AppCreateResponse }; + 'app/current': { req: EmptyRequest; res: AppCurrentResponse }; 'app/show': { req: AppShowRequest; res: AppShowResponse }; 'auth/accept': { req: AuthAcceptRequest; res: EmptyResponse }; 'auth/session/generate': { req: AuthSessionGenerateRequest; res: AuthSessionGenerateResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index f71407a6ae..39359e3cfa 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -162,6 +162,7 @@ export type ApShowRequest = operations['ap___show']['requestBody']['content']['a export type ApShowResponse = operations['ap___show']['responses']['200']['content']['application/json']; export type AppCreateRequest = operations['app___create']['requestBody']['content']['application/json']; export type AppCreateResponse = operations['app___create']['responses']['200']['content']['application/json']; +export type AppCurrentResponse = operations['app___current']['responses']['200']['content']['application/json']; export type AppShowRequest = operations['app___show']['requestBody']['content']['application/json']; export type AppShowResponse = operations['app___show']['responses']['200']['content']['application/json']; export type AuthAcceptRequest = operations['auth___accept']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 57e98f2f88..077ea35729 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1086,6 +1086,15 @@ export type paths = { */ post: operations['app___create']; }; + '/app/current': { + /** + * app/current + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['app___current']; + }; '/app/show': { /** * app/show @@ -13071,6 +13080,58 @@ export type operations = { }; }; }; + /** + * app/current + * @description No description provided. + * + * **Credential required**: *No* + */ + app___current: { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['App']; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Too many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * app/show * @description No description provided. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 691069f563..ed31b44e1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: async-mutex: specifier: 0.5.0 version: 0.5.0 + axios: + specifier: 1.7.4 + version: 1.7.4 bcryptjs: specifier: 2.4.3 version: 2.4.3 @@ -233,9 +236,6 @@ importers: fastify: specifier: 5.3.2 version: 5.3.2 - fastify-multer: - specifier: ^2.0.3 - version: 2.0.3 fastify-raw-body: specifier: 5.0.0 version: 5.0.0 @@ -2274,10 +2274,6 @@ packages: '@fastify/ajv-compiler@4.0.1': resolution: {integrity: sha512-DxrBdgsjNLP0YM6W5Hd6/Fmj43S8zMKiFJYgi+Ri3htTGAowPVG/tG1wpnWLMjufEnehRivUCKZ1pLDIoZdTuw==} - '@fastify/busboy@1.2.1': - resolution: {integrity: sha512-7PQA7EH43S0CxcOa9OeAnaeA0oQ+e/DHNPZwSQM9CQHW76jle5+OvLdibRp/Aafs9KXbLhxyjOTkRjWUbQEd3Q==} - engines: {node: '>=14'} - '@fastify/busboy@2.1.0': resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} engines: {node: '>=14'} @@ -5455,10 +5451,6 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} - config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -6313,13 +6305,6 @@ packages: resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} hasBin: true - fastify-multer@2.0.3: - resolution: {integrity: sha512-QnFqrRgxmUwWHTgX9uyQSu0C/hmVCfcxopqjApZ4uaZD5W9MJ+nHUlW4+9q7Yd3BRxDIuHvgiM5mjrh6XG8cAA==} - engines: {node: '>=10.17.0'} - - fastify-plugin@2.3.4: - resolution: {integrity: sha512-I+Oaj6p9oiRozbam30sh39BiuiqBda7yK2nmSPVwDCfIBlKnT8YB3MY+pRQc2Fcd07bf6KPGklHJaQ2Qu81TYQ==} - fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} @@ -6435,15 +6420,6 @@ packages: resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} engines: {node: '>=18'} - follow-redirects@1.15.2: - resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - follow-redirects@1.15.9: resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} engines: {node: '>=4.0'} @@ -9960,9 +9936,6 @@ packages: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} - text-decoding@1.0.0: - resolution: {integrity: sha512-/0TJD42KDnVwKmDK6jj3xP7E2MG7SHAOG4tyTgyUCRPdHwvkquYNLEQltmdMa3owq3TkddCVcTsoctJI8VQNKA==} - textarea-caret@3.1.0: resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==} @@ -12060,10 +12033,6 @@ snapshots: ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.0.1 - '@fastify/busboy@1.2.1': - dependencies: - text-decoding: 1.0.0 - '@fastify/busboy@2.1.0': {} '@fastify/busboy@3.0.0': {} @@ -15354,7 +15323,7 @@ snapshots: axios@0.24.0: dependencies: - follow-redirects: 1.15.2 + follow-redirects: 1.15.9(debug@4.4.0) transitivePeerDependencies: - debug @@ -15990,13 +15959,6 @@ snapshots: readable-stream: 2.3.7 typedarray: 0.0.6 - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.0 - typedarray: 0.0.6 - config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -17122,21 +17084,6 @@ snapshots: dependencies: strnum: 1.0.5 - fastify-multer@2.0.3: - dependencies: - '@fastify/busboy': 1.2.1 - append-field: 1.0.0 - concat-stream: 2.0.0 - fastify-plugin: 2.3.4 - mkdirp: 1.0.4 - on-finished: 2.4.1 - type-is: 1.6.18 - xtend: 4.0.2 - - fastify-plugin@2.3.4: - dependencies: - semver: 7.6.0 - fastify-plugin@4.5.1: {} fastify-plugin@5.0.1: {} @@ -17298,8 +17245,6 @@ snapshots: async: 0.2.10 which: 1.3.1 - follow-redirects@1.15.2: {} - follow-redirects@1.15.9(debug@4.4.0): optionalDependencies: debug: 4.4.0(supports-color@8.1.1) @@ -21438,8 +21383,6 @@ snapshots: glob: 10.4.5 minimatch: 9.0.5 - text-decoding@1.0.0: {} - textarea-caret@3.1.0: {} thenify-all@1.6.0: