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/locales/index.d.ts b/locales/index.d.ts index 977fb43be4..2c9e3e3890 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12947,6 +12947,16 @@ export interface Locale extends ILocale { * If disabled, then the proxy account will not be used. User lists will only include notes from local or followed users. */ "enableProxyAccountDescription": string; + "_confirmPollEdit": { + /** + * Are you sure you want to edit this poll? + */ + "title": string; + /** + * Editing this poll will cause it to lose all previous votes + */ + "text": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/package.json b/package.json index 46a4e63aad..ffd07c5950 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sharkey", - "version": "2025.4.1", + "version": "2025.4.2-rc", "codename": "shonk", "repository": { "type": "git", diff --git a/packages/backend/package.json b/packages/backend/package.json index 9aa26033d0..b9cb0002ab 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -91,12 +91,11 @@ "@swc/core": "1.11.24", "@transfem-org/sfm-js": "0.24.6", "@twemoji/parser": "15.1.1", - "@types/redis-info": "3.0.3", - "@types/psl": "^1.1.3", "accepts": "1.3.8", "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", @@ -107,6 +106,7 @@ "cbor": "9.0.2", "chalk": "5.4.1", "chalk-template": "1.1.0", + "cheerio": "1.0.0", "chokidar": "3.6.0", "cli-highlight": "2.1.11", "color-convert": "2.0.1", @@ -115,7 +115,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", @@ -132,7 +131,6 @@ "ipaddr.js": "2.2.0", "is-svg": "5.1.0", "js-yaml": "4.1.0", - "jsdom": "26.1.0", "json5": "2.2.3", "jsonld": "8.3.3", "jsrsasign": "11.1.0", @@ -209,7 +207,6 @@ "@types/http-link-header": "1.0.7", "@types/jest": "29.5.14", "@types/js-yaml": "4.0.9", - "@types/jsdom": "21.1.7", "@types/jsonld": "1.5.15", "@types/jsrsasign": "10.5.15", "@types/mime-types": "2.1.4", @@ -221,10 +218,12 @@ "@types/oauth2orize-pkce": "0.1.2", "@types/pg": "8.11.14", "@types/proxy-addr": "^2.0.3", + "@types/psl": "^1.1.3", "@types/pug": "2.0.10", "@types/qrcode": "1.5.5", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", + "@types/redis-info": "3.0.3", "@types/rename": "1.0.7", "@types/sanitize-html": "2.15.0", "@types/semver": "7.7.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/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index ce3af7c774..980f1fcacf 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -5,9 +5,9 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { JSDOM } from 'jsdom'; import tinycolor from 'tinycolor2'; import * as Redis from 'ioredis'; +import { load as cheerio } from 'cheerio'; import type { MiInstance } from '@/models/Instance.js'; import type Logger from '@/logger.js'; import { DI } from '@/di-symbols.js'; @@ -15,7 +15,7 @@ import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import type { DOMWindow } from 'jsdom'; +import type { CheerioAPI } from 'cheerio'; type NodeInfo = { openRegistrations?: unknown; @@ -181,17 +181,14 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchDom(instance: MiInstance): Promise { + private async fetchDom(instance: MiInstance): Promise { this.logger.info(`Fetching HTML of ${instance.host} ...`); const url = 'https://' + instance.host; const html = await this.httpRequestService.getHtml(url); - const { window } = new JSDOM(html); - const doc = window.document; - - return doc; + return cheerio(html); } @bindThis @@ -206,12 +203,15 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise { + private async fetchFaviconUrl(instance: MiInstance, doc: CheerioAPI | null): Promise { const url = 'https://' + instance.host; if (doc) { // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; + const href = doc('link[rel][href]') + .filter((_, link) => link.attribs.rel.split(' ').includes('icon')) + .last() + .attr('href'); if (href) { return (new URL(href, url)).href; @@ -232,7 +232,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record | null): Promise { + private async fetchIconUrl(instance: MiInstance, doc: CheerioAPI | null, manifest: Record | null): Promise { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { const url = 'https://' + instance.host; return (new URL(manifest.icons[0].src, url)).href; @@ -242,13 +242,16 @@ export class FetchInstanceMetadataService { const url = 'https://' + instance.host; // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const links = Array.from(doc.getElementsByTagName('link')).reverse(); + const links = Array.from(doc('link[rel][href]')).reverse().map(link => ({ + rel: link.attribs.rel.split(' '), + href: link.attribs.href, + })); // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 const href = [ - links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, - links.find(link => link.relList.contains('apple-touch-icon'))?.href, - links.find(link => link.relList.contains('icon'))?.href, + links.find(link => link.rel.includes('apple-touch-icon-precomposed'))?.href, + links.find(link => link.rel.includes('apple-touch-icon'))?.href, + links.find(link => link.rel.includes('icon'))?.href, ] .find(href => href); @@ -261,8 +264,8 @@ export class FetchInstanceMetadataService { } @bindThis - private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { - const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; + private async getThemeColor(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record | null): Promise { + const themeColor = info?.metadata?.themeColor ?? doc?.('meta[name="theme-color"][content]').attr('content') ?? manifest?.theme_color; if (themeColor) { const color = new tinycolor(themeColor); @@ -273,7 +276,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getSiteName(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeName === 'string') { return info.metadata.nodeName; @@ -283,7 +286,7 @@ export class FetchInstanceMetadataService { } if (doc) { - const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); + const og = doc('meta[property="og:title"][content]').attr('content'); if (og) { return og; @@ -298,7 +301,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getDescription(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeDescription === 'string') { return info.metadata.nodeDescription; @@ -308,12 +311,12 @@ export class FetchInstanceMetadataService { } if (doc) { - const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); + const meta = doc('meta[name="description"][content]').attr('content'); if (meta) { return meta; } - const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + const og = doc('meta[property="og:description"][content]').attr('content'); if (og) { return og; } 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/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index ed97908f66..e961d4236c 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -539,7 +539,8 @@ export class NoteCreateService implements OnApplicationShutdown { await this.notesRepository.insert(insert); } - return insert; + // Re-fetch note to get the default values of null / unset fields. + return await this.notesRepository.findOneByOrFail({ id: insert.id }); } catch (e) { // duplicate key error if (isDuplicateKeyValueError(e)) { diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 332560154d..e9637c56c7 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -574,12 +574,15 @@ export class NoteEditService implements OnApplicationShutdown { await this.notesRepository.update(oldnote.id, note); } + // Re-fetch note to get the default values of null / unset fields. + const edited = await this.notesRepository.findOneByOrFail({ id: note.id }); + setImmediate('post edited', { signal: this.#shutdownController.signal }).then( - () => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!), + () => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!), () => { /* aborted, ignore this */ }, ); - return note; + return edited; } else { return oldnote; } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index fb0fa8f28d..361e636662 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -52,6 +52,7 @@ export const QUEUE_TYPES = [ 'objectStorage', 'userWebhookDeliver', 'systemWebhookDeliver', + 'scheduleNotePost', ] as const; @Injectable() @@ -783,6 +784,7 @@ export class QueueService { case 'objectStorage': return this.objectStorageQueue; case 'userWebhookDeliver': return this.userWebhookDeliverQueue; case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue; + case 'scheduleNotePost': return this.ScheduleNotePostQueue; default: throw new Error(`Unrecognized queue type: ${type}`); } } 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/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index f8d04c0592..170afc72dc 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -176,4 +176,14 @@ export class UtilityService { const host = this.extractDbHost(uri); return this.isFederationAllowedHost(host); } + + @bindThis + public getUrlScheme(url: string): string { + try { + // Returns in the format "https:" or an empty string + return new URL(url).protocol; + } catch { + return ''; + } + } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 37b01a6a40..b8526a972c 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -470,7 +470,7 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver): Promise { + private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver, silent = false): Promise { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); @@ -505,7 +505,7 @@ export class ApInboxService { }); if (isPost(object)) { - await this.createNote(resolver, actor, object, false); + await this.createNote(resolver, actor, object, silent); } else { return `skip: Unsupported type for Create: ${getApType(object)} ${getNullableApId(object)}`; } @@ -889,7 +889,7 @@ export class ApInboxService { } else if (getApType(object) === 'Question') { // If we get an Update(Question) for a note that doesn't exist, then create it instead if (!await this.apNoteService.hasNote(object)) { - return await this.create(actor, activity, resolver); + return await this.create(actor, activity, resolver, true); } await this.apQuestionService.updateQuestion(object, actor, resolver); @@ -897,7 +897,7 @@ export class ApInboxService { } else if (isPost(object)) { // If we get an Update(Note) for a note that doesn't exist, then create it instead if (!await this.apNoteService.hasNote(object)) { - return await this.create(actor, activity, resolver); + return await this.create(actor, activity, resolver, true); } await this.apNoteService.updateNote(object, actor, resolver); diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index eceb972a91..6ada5463a3 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -402,7 +402,8 @@ export class NoteEntityService implements OnModuleInit { bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; myReactions: Map; packedFiles: Map | null>; - packedUsers: Map> + packedUsers: Map>; + mentionHandles: Record; }; }, ): Promise> { @@ -476,7 +477,8 @@ export class NoteEntityService implements OnModuleInit { allowRenoteToExternal: channel.allowRenoteToExternal, userId: channel.userId, } : undefined, - mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, @@ -529,6 +531,25 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; + const targetNotes: MiNote[] = []; + for (const note of notes) { + if (isPureRenote(note)) { + // we may need to fetch 'my reaction' for renote target. + targetNotes.push(note.renote); + if (note.renote.reply) { + // idem if the renote is also a reply. + targetNotes.push(note.renote.reply); + } + } else { + if (note.reply) { + // idem for OP of a regular reply. + targetNotes.push(note.reply); + } + + targetNotes.push(note); + } + } + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; const meId = me ? me.id : null; @@ -536,25 +557,6 @@ export class NoteEntityService implements OnModuleInit { if (meId) { const idsNeedFetchMyReaction = new Set(); - const targetNotes: MiNote[] = []; - for (const note of notes) { - if (isPureRenote(note)) { - // we may need to fetch 'my reaction' for renote target. - targetNotes.push(note.renote); - if (note.renote.reply) { - // idem if the renote is also a reply. - targetNotes.push(note.renote.reply); - } - } else { - if (note.reply) { - // idem for OP of a regular reply. - targetNotes.push(note.reply); - } - - targetNotes.push(note); - } - } - for (const note of targetNotes) { const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { @@ -594,6 +596,15 @@ export class NoteEntityService implements OnModuleInit { const packedUsers = await this.userEntityService.packMany(users, me) .then(users => new Map(users.map(u => [u.id, u]))); + // Recursively add all mentioned users from all notes + replies + renotes + const allMentionedUsers = targetNotes.reduce((users, note) => { + for (const user of note.mentions) { + users.add(user); + } + return users; + }, new Set()); + const mentionHandles = await this.getUserHandles(Array.from(allMentionedUsers)); + return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { @@ -601,6 +612,7 @@ export class NoteEntityService implements OnModuleInit { myReactions: myReactionsMap, packedFiles, packedUsers, + mentionHandles, }, }))); } @@ -636,4 +648,36 @@ export class NoteEntityService implements OnModuleInit { relations: ['user'], }); } + + private async getUserHandles(userIds: string[], hint?: Record): Promise> { + if (userIds.length < 1) return {}; + + // Hint is provided by packMany to avoid N+1 queries. + // It should already include all existing mentioned users. + if (hint) { + const handles = {} as Record; + for (const id of userIds) { + handles[id] = hint[id]; + } + return handles; + } + + const users = await this.usersRepository.find({ + select: { + id: true, + username: true, + host: true, + }, + where: { + id: In(userIds), + }, + }); + + return users.reduce((map, user) => { + map[user.id] = user.host + ? `@${user.username}@${user.host}` + : `@${user.username}`; + return map; + }, {} as Record); + } } 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..9ba95cff42 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,21 @@ export function createTempDir(): Promise<[string, () => void]> { ); }); } + +export async function saveToTempFile(stream: NodeJS.ReadableStream & { truncated?: boolean }): Promise<[string, () => void]> { + const [filepath, cleanup] = await createTemp(); + + try { + await pipeline(stream, fs.createWriteStream(filepath)); + } catch (e) { + cleanup(); + throw e; + } + + if (stream.truncated) { + cleanup(); + throw new Error('Read failed: input stream truncated'); + } + + return [filepath, cleanup]; +} diff --git a/packages/backend/src/misc/verify-field-link.ts b/packages/backend/src/misc/verify-field-link.ts index f519acfba0..62542eaaa0 100644 --- a/packages/backend/src/misc/verify-field-link.ts +++ b/packages/backend/src/misc/verify-field-link.ts @@ -3,32 +3,29 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { JSDOM } from 'jsdom'; +import { load as cheerio } from 'cheerio'; import type { HttpRequestService } from '@/core/HttpRequestService.js'; type Field = { name: string, value: string }; export async function verifyFieldLinks(fields: Field[], profile_url: string, httpRequestService: HttpRequestService): Promise { const verified_links = []; - for (const field_url of fields - .filter(x => URL.canParse(x.value) && ['http:', 'https:'].includes((new URL(x.value).protocol)))) { + for (const field_url of fields.filter(x => URL.canParse(x.value) && ['http:', 'https:'].includes((new URL(x.value).protocol)))) { try { const html = await httpRequestService.getHtml(field_url.value); - const { window } = new JSDOM(html); - const doc: Document = window.document; + const doc = cheerio(html); - const aEls = Array.from(doc.getElementsByTagName('a')); - const linkEls = Array.from(doc.getElementsByTagName('link')); + const links = doc('a[rel~="me"][href], link[rel~="me"][href]').toArray(); - const includesProfileLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === profile_url); - if (includesProfileLinks) { verified_links.push(field_url.value); } - - window.close(); - } catch (err) { + const includesProfileLinks = links.some(link => link.attribs.href === profile_url); + if (includesProfileLinks) { + verified_links.push(field_url.value); + } + } catch { // don't do anything. - continue; } } + return verified_links; } diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 16e240ab11..b19c8f7c06 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -85,6 +85,16 @@ export const packedNoteSchema = { format: 'id', }, }, + mentionHandles: { + type: 'object', + optional: true, nullable: false, + additionalProperties: { + anyOf: [{ + type: 'string', + }], + optional: true, nullable: false, + }, + }, visibleUserIds: { type: 'array', optional: true, nullable: false, 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/ServerService.ts b/packages/backend/src/server/ServerService.ts index 5857b3059e..2d20aa1222 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -7,7 +7,7 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Fastify, { FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import fastifyStatic from '@fastify/static'; import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; @@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown { } @bindThis - public async launch() { + public async launch(): Promise { const fastify = Fastify({ trustProxy: true, logger: false, @@ -135,8 +135,8 @@ export class ServerService implements OnApplicationShutdown { reply.header('content-type', 'text/plain; charset=utf-8'); reply.header('link', `<${encodeURI(location)}>; rel="canonical"`); done(null, [ - 'Refusing to relay remote ActivityPub object lookup.', - '', + "Refusing to relay remote ActivityPub object lookup.", + "", `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`, ].join('\n')); }); @@ -304,7 +304,6 @@ export class ServerService implements OnApplicationShutdown { } await fastify.ready(); - return fastify; } @bindThis @@ -313,6 +312,13 @@ export class ServerService implements OnApplicationShutdown { await this.#fastify.close(); } + /** + * Get the Fastify instance for testing. + */ + public get fastify(): FastifyInstance { + return this.#fastify; + } + @bindThis async onApplicationShutdown(signal: string): Promise { await this.dispose(); diff --git a/packages/backend/src/server/ServerUtilityService.ts b/packages/backend/src/server/ServerUtilityService.ts new file mode 100644 index 0000000000..00eb97f679 --- /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/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 1b8d33f9c9..0d2dafd556 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -6,11 +6,8 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; -import { Transform } from 'node:stream'; -import { type MultipartFile } from '@fastify/multipart'; import { Inject, Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/node'; -import { AttachmentFile } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -19,7 +16,7 @@ import type Logger from '@/logger.js'; import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; -import { type RolePolicies, RoleService } from '@/core/RoleService.js'; +import { RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; @@ -194,6 +191,18 @@ export class ApiCallService implements OnApplicationShutdown { return; } + const [path, cleanup] = await createTemp(); + await stream.pipeline(multipartData.file, fs.createWriteStream(path)); + + // ファイルサイズが制限を超えていた場合 + // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある + if (multipartData.file.truncated) { + cleanup(); + reply.code(413); + reply.send(); + return; + } + const fields = {} as Record; for (const [k, v] of Object.entries(multipartData.fields)) { fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; @@ -204,13 +213,18 @@ export class ApiCallService implements OnApplicationShutdown { ? request.headers.authorization.slice(7) : fields['i']; if (token != null && typeof token !== 'string') { + cleanup(); reply.code(400); return; } this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, multipartData, request, reply).then((res) => { + this.call(endpoint, user, app, fields, { + name: multipartData.filename, + path: path, + }, request, reply).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { + cleanup(); this.#sendApiError(reply, err); }); @@ -218,6 +232,7 @@ export class ApiCallService implements OnApplicationShutdown { this.logIp(request, user); } }).catch(err => { + cleanup(); this.#sendAuthenticationError(reply, err); }); } @@ -278,7 +293,10 @@ export class ApiCallService implements OnApplicationShutdown { user: MiLocalUser | null | undefined, token: MiAccessToken | null | undefined, data: any, - multipartFile: MultipartFile | null, + file: { + name: string; + path: string; + } | null, request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, reply: FastifyReply, ) { @@ -354,37 +372,6 @@ export class ApiCallService implements OnApplicationShutdown { } } - // Cast non JSON input - if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { - for (const k of Object.keys(ep.params.properties)) { - const param = ep.params.properties![k]; - if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { - try { - data[k] = JSON.parse(data[k]); - } catch (e) { - throw new ApiError({ - message: 'Invalid param.', - code: 'INVALID_PARAM', - id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', - }, { - param: k, - reason: `cannot cast to ${param.type}`, - }); - } - } - } - } - - if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) - || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { - throw new ApiError({ - message: 'Your app does not have the necessary permissions to use this endpoint.', - code: 'PERMISSION_DENIED', - kind: 'permission', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', - }); - } - if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { @@ -418,91 +405,49 @@ export class ApiCallService implements OnApplicationShutdown { } } - let attachmentFile: AttachmentFile | null = null; - let cleanup = () => {}; - if (ep.meta.requireFile && request.method === 'POST' && multipartFile) { - const policies = await this.roleService.getUserPolicies(user!.id); - const result = await this.handleAttachmentFile( - Math.min((policies.maxFileSizeMb * 1024 * 1024), this.config.maxFileSize), - multipartFile, - ); - attachmentFile = result.attachmentFile; - cleanup = result.cleanup; + if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) + || (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + kind: 'permission', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + + // Cast non JSON input + if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) { + for (const k of Object.keys(ep.params.properties)) { + const param = ep.params.properties![k]; + if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { + try { + data[k] = JSON.parse(data[k]); + } catch (e) { + throw new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', + }, { + param: k, + reason: `cannot cast to ${param.type}`, + }); + } + } + } } // API invoking if (this.config.sentryForBackend) { return await Sentry.startSpan({ name: 'API: ' + ep.name, - }, () => { - return ep.exec(data, user, token, attachmentFile, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) - .finally(() => cleanup()); - }); + }, () => ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); } else { - return await ep.exec(data, user, token, attachmentFile, request.ip, request.headers) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)) - .finally(() => cleanup()); + return await ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); } } - @bindThis - private async handleAttachmentFile( - fileSizeLimit: number, - multipartFile: MultipartFile, - ) { - function createTooLongError() { - return new ApiError({ - httpStatusCode: 413, - kind: 'client', - message: 'File size is too large.', - code: 'FILE_SIZE_TOO_LARGE', - id: 'ff827ce8-9b4b-4808-8511-422222a3362f', - }); - } - - function createLimitStream(limit: number) { - let total = 0; - - return new Transform({ - transform(chunk, _, callback) { - total += chunk.length; - if (total > limit) { - callback(createTooLongError()); - } else { - callback(null, chunk); - } - }, - }); - } - - const [path, cleanup] = await createTemp(); - try { - await stream.pipeline( - multipartFile.file, - createLimitStream(fileSizeLimit), - fs.createWriteStream(path), - ); - - // ファイルサイズが制限を超えていた場合 - // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある - if (multipartFile.file.truncated) { - throw createTooLongError(); - } - } catch (err) { - cleanup(); - throw err; - } - - return { - attachmentFile: { - name: multipartFile.filename, - path, - }, - cleanup, - }; - } - @bindThis public dispose(): void { clearInterval(this.userIpHistoriesClearIntervalId); 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-base.ts b/packages/backend/src/server/api/endpoint-base.ts index b063487305..e061aa3a8e 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -21,23 +21,23 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); export type Response = Record | void; -export type AttachmentFile = { +type File = { name: string | null; path: string; }; // TODO: paramsの型をT['params']のスキーマ定義から推論する type Executor = - (params: SchemaType, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, cleanup?: () => any, ip?: string | null, headers?: Record | null) => - Promise>>; + (params: SchemaType, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record | null) => + Promise>>; export abstract class Endpoint { - public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record | null) => Promise; + public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => Promise; constructor(meta: T, paramDef: Ps, cb: Executor) { const validate = ajv.compile(paramDef); - this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record | null) => { + this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record | null) => { let cleanup: undefined | (() => void) = undefined; if (meta.requireFile) { 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/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts index 79731c9786..aba68376ad 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -20,7 +20,7 @@ export const paramDef = { type: 'object', properties: { queue: { type: 'string', enum: QUEUE_TYPES }, - state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } }, + state: { type: 'array', items: { type: 'string', enum: ['active', 'paused', 'wait', 'delayed', 'completed', 'failed'] } }, search: { type: 'string' }, }, required: ['queue', 'state'], 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/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 7043f4883a..f4c47d71bf 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -67,6 +67,7 @@ export const meta = { message: 'Cannot upload the file because it exceeds the maximum file size.', code: 'MAX_FILE_SIZE_EXCEEDED', id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a', + httpStatusCode: 413, }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 5f93597fd7..ad8f38703b 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -6,7 +6,6 @@ import * as mfm from '@transfem-org/sfm-js'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; @@ -622,6 +621,7 @@ export default class extends Endpoint { // eslint- } // this function is superseded by '@/misc/verify-field-link.ts' + /* private async verifyLink(url: string, user: MiLocalUser) { if (!safeForSql(url)) return; @@ -653,6 +653,7 @@ export default class extends Endpoint { // eslint- // なにもしない } } + */ // these two methods need to be kept in sync with // `ApRendererService.renderPerson` 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..c43d6cfc9a 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) => { @@ -112,7 +112,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers as HeadersInit, + ...request.headers, 'Accept': 'application/json', 'Content-Type': 'application/json', }, @@ -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) => { @@ -135,7 +135,7 @@ export class ApiSearchMastodon { { method: 'POST', headers: { - ...request.headers as HeadersInit, + ...request.headers, 'Accept': 'application/json', 'Content-Type': 'application/json', }, @@ -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/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index aa8fcd0c2a..0cab657c23 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { summaly } from '@misskey-dev/summaly'; import { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; import * as Redis from 'ioredis'; +import { IsNull, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -18,31 +19,48 @@ import { ApiError } from '@/server/api/error.js'; import { MiMeta } from '@/models/Meta.js'; import { RedisKVCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; -import type { FastifyRequest, FastifyReply } from 'fastify'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; +import type { NotesRepository } from '@/models/_.js'; +import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; +import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import type { FastifyRequest, FastifyReply } from 'fastify'; + +export type LocalSummalyResult = SummalyResult & { + haveNoteLocally?: boolean; +}; + +// Increment this to invalidate cached previews after a major change. +const cacheFormatVersion = 2; @Injectable() export class UrlPreviewService { private logger: Logger; - private previewCache: RedisKVCache; + private previewCache: RedisKVCache; constructor( @Inject(DI.config) private config: Config, @Inject(DI.redis) - private redisClient: Redis.Redis, + private readonly redisClient: Redis.Redis, @Inject(DI.meta) - private meta: MiMeta, + private readonly meta: MiMeta, + + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, private httpRequestService: HttpRequestService, private loggerService: LoggerService, - private utilityService: UtilityService, - private apDbResolverService: ApDbResolverService, + private readonly utilityService: UtilityService, + private readonly apUtilityService: ApUtilityService, + private readonly apDbResolverService: ApDbResolverService, + private readonly apRequestService: ApRequestService, + private readonly systemAccountService: SystemAccountService, ) { this.logger = this.loggerService.getLogger('url-preview'); - this.previewCache = new RedisKVCache(this.redisClient, 'summaly', { + this.previewCache = new RedisKVCache(this.redisClient, 'summaly', { lifetime: 1000 * 60 * 60 * 24, // 1d memoryCacheLifetime: 1000 * 60 * 10, // 10m fetcher: () => { throw new Error('the UrlPreview cache should never fetch'); }, @@ -53,17 +71,21 @@ export class UrlPreviewService { @bindThis private wrap(url?: string | null): string | null { - return url != null - ? `${this.config.mediaProxy}/preview.webp?${query({ - url, - preview: '1', - })}` - : null; + if (url == null) return null; + + // Don't proxy our own media + if (this.utilityService.isUriLocal(url)) { + return url; + } + + // But proxy everything else! + const mediaQuery = query({ url, preview: '1' }); + return `${this.config.mediaProxy}/preview.webp?${mediaQuery}`; } @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, + request: FastifyRequest<{ Querystring: { url?: string; lang?: string; } }>, reply: FastifyReply, ): Promise { const url = request.query.url; @@ -89,8 +111,7 @@ export class UrlPreviewService { }; } - const host = new URL(url).host; - if (this.utilityService.isBlockedHost(this.meta.blockedHosts, host)) { + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) { reply.code(403); return { error: new ApiError({ @@ -101,12 +122,11 @@ export class UrlPreviewService { }; } - const key = `${url}@${lang}`; - const cached = await this.previewCache.get(key) as SummalyResult & { haveNoteLocally?: boolean }; + const cacheKey = `${url}@${lang}@${cacheFormatVersion}`; + const cached = await this.previewCache.get(cacheKey); if (cached !== undefined) { - this.logger.info(`Returning cache preview of ${key}`); - // Cache 7days - reply.header('Cache-Control', 'max-age=604800, immutable'); + // Cache 1 day (matching redis) + reply.header('Cache-Control', 'public, max-age=86400'); if (cached.activityPub) { cached.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(cached.activityPub); @@ -115,43 +135,52 @@ export class UrlPreviewService { return cached; } - this.logger.info(this.meta.urlPreviewSummaryProxyUrl - ? `(Proxy) Getting preview of ${key} ...` - : `Getting preview of ${key} ...`); - try { - const summary: SummalyResult & { haveNoteLocally?: boolean } = this.meta.urlPreviewSummaryProxyUrl + const summary: LocalSummalyResult = this.meta.urlPreviewSummaryProxyUrl ? await this.fetchSummaryFromProxy(url, this.meta, lang) : await this.fetchSummary(url, this.meta, lang); - this.logger.succ(`Got preview of ${url}: ${summary.title}`); + this.validateUrls(summary); - if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); + // Repeat check, since redirects are allowed. + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) { + reply.code(403); + return { + error: new ApiError({ + message: 'URL is blocked', + code: 'URL_PREVIEW_BLOCKED', + id: '50294652-857b-4b13-9700-8e5c7a8deae8', + }), + }; } - if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); - } + this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`); summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); - this.previewCache.set(key, summary); - - if (summary.activityPub) { - summary.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(summary.activityPub); + // Summaly cannot always detect links to a fedi post, so do some additional tests to try and find missed cases. + if (!summary.activityPub) { + await this.inferActivityPubLink(summary); } - // Cache 7days - reply.header('Cache-Control', 'max-age=604800, immutable'); + if (summary.activityPub) { + // Avoid duplicate checks in case inferActivityPubLink already set this. + summary.haveNoteLocally ||= !!await this.apDbResolverService.getNoteFromApId(summary.activityPub); + } + + // Await this to avoid hammering redis when a bunch of URLs are fetched at once + await this.previewCache.set(cacheKey, summary); + + // Cache 1 day (matching redis) + reply.header('Cache-Control', 'public, max-age=86400'); return summary; } catch (err) { - this.logger.warn(`Failed to get preview of ${url}: ${err}`); + this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`); reply.code(422); - reply.header('Cache-Control', 'max-age=86400, immutable'); + reply.header('Cache-Control', 'max-age=3600'); return { error: new ApiError({ message: 'Failed to get preview', @@ -171,7 +200,7 @@ export class UrlPreviewService { : undefined; return summaly(url, { - followRedirects: false, + followRedirects: true, lang: lang ?? 'ja-JP', agent: agent, userAgent: meta.urlPreviewUserAgent ?? undefined, @@ -184,6 +213,7 @@ export class UrlPreviewService { private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise { const proxy = meta.urlPreviewSummaryProxyUrl!; const queryStr = query({ + followRedirects: true, url: url, lang: lang ?? 'ja-JP', userAgent: meta.urlPreviewUserAgent ?? undefined, @@ -192,6 +222,84 @@ export class UrlPreviewService { contentLengthRequired: meta.urlPreviewRequireContentLength, }); - return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); + return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); + } + + private validateUrls(summary: LocalSummalyResult) { + const urlScheme = this.utilityService.getUrlScheme(summary.url); + if (urlScheme !== 'http:' && urlScheme !== 'https:') { + throw new Error(`unsupported scheme in preview URL: "${urlScheme}"`); + } + + if (summary.player.url) { + const playerScheme = this.utilityService.getUrlScheme(summary.player.url); + if (playerScheme !== 'http:' && playerScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: player URL has unsupported scheme "${playerScheme}"`); + summary.player.url = null; + } + } + + if (summary.icon) { + const iconScheme = this.utilityService.getUrlScheme(summary.icon); + if (iconScheme !== 'http:' && iconScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: icon URL has unsupported scheme "${iconScheme}"`); + summary.icon = null; + } + } + + if (summary.thumbnail) { + const thumbnailScheme = this.utilityService.getUrlScheme(summary.thumbnail); + if (thumbnailScheme !== 'http:' && thumbnailScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: thumbnail URL has unsupported scheme "${thumbnailScheme}"`); + summary.thumbnail = null; + } + } + + if (summary.activityPub) { + const activityPubScheme = this.utilityService.getUrlScheme(summary.activityPub); + if (activityPubScheme !== 'http:' && activityPubScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: ActivityPub URL has unsupported scheme "${activityPubScheme}"`); + summary.activityPub = null; + } + } + } + + private async inferActivityPubLink(summary: LocalSummalyResult) { + // Match canonical URI first. + // This covers local and remote links. + const isCanonicalUri = !!await this.apDbResolverService.getNoteFromApId(summary.url); + if (isCanonicalUri) { + summary.activityPub = summary.url; + summary.haveNoteLocally = true; + return; + } + + // Try public URL next. + // This is necessary for Mastodon and other software with a different public URL. + const urlMatches = await this.notesRepository.find({ + select: { + uri: true, + }, + where: { + url: summary.url, + uri: Not(IsNull()), + }, + }) as { uri: string }[]; + + // Older versions did not validate URL, so do it now to avoid impersonation. + const matchByUrl = urlMatches.find(({ uri }) => this.apUtilityService.haveSameAuthority(uri, summary.url)); + if (matchByUrl) { + summary.activityPub = matchByUrl.uri; + summary.haveNoteLocally = true; + return; + } + + // Finally, attempt a signed GET in case it's a direct link to an instance with authorized fetch. + const instanceActor = await this.systemAccountService.getInstanceActor(); + const remoteObject = await this.apRequestService.signedGet(summary.url, instanceActor).catch(() => null); + if (remoteObject && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) { + summary.activityPub = remoteObject.id; + return; + } } } diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index f9e65aaa84..49c6a0636b 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -159,8 +159,8 @@ describe('API', () => { user: { token: application3 }, }, { status: 403, - code: 'PERMISSION_DENIED', - id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + code: 'ROLE_PERMISSION_DENIED', + id: 'c3d38592-54c0-429d-be96-5636b0431a61', }); await failedApiCall({ diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 7434701e67..47851e9474 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -19,7 +19,7 @@ import { ResourceOwnerPassword, } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; -import { JSDOM } from 'jsdom'; +import { load as cheerio } from 'cheerio'; import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; import { api, port, sendEnvUpdateRequest, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; @@ -73,11 +73,11 @@ const clientConfig: ModuleOptions<'client_id'> = { }; function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: string | undefined } { - const fragment = JSDOM.fragment(html); + const fragment = cheerio(html); return { - transactionId: fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content, - clientName: fragment.querySelector('meta[name="misskey:oauth:client-name"]')?.content, - clientLogo: fragment.querySelector('meta[name="misskey:oauth:client-logo"]')?.content, + transactionId: fragment('meta[name="misskey:oauth:transaction-id"][content]').attr('content'), + clientName: fragment('meta[name="misskey:oauth:client-name"][content]').attr('content'), + clientLogo: fragment('meta[name="misskey:oauth:client-logo"][content]').attr('content'), }; } diff --git a/packages/backend/test/unit/server/api/drive/files/create.ts b/packages/backend/test/unit/server/api/drive/files/create.ts index b98892fa03..9b38f4d744 100644 --- a/packages/backend/test/unit/server/api/drive/files/create.ts +++ b/packages/backend/test/unit/server/api/drive/files/create.ts @@ -3,29 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { S3Client } from '@aws-sdk/client-s3'; import { Test, TestingModule } from '@nestjs/testing'; -import { mockClient } from 'aws-sdk-client-mock'; import { FastifyInstance } from 'fastify'; import request from 'supertest'; +import { randomString } from '../../../../../utils.js'; import { CoreModule } from '@/core/CoreModule.js'; import { RoleService } from '@/core/RoleService.js'; import { DI } from '@/di-symbols.js'; import { GlobalModule } from '@/GlobalModule.js'; -import { MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { DriveFoldersRepository, MiDriveFolder, MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { MiUser } from '@/models/User.js'; import { ServerModule } from '@/server/ServerModule.js'; import { ServerService } from '@/server/ServerService.js'; +import { IdService } from '@/core/IdService.js'; describe('/drive/files/create', () => { let module: TestingModule; let server: FastifyInstance; - const s3Mock = mockClient(S3Client); let roleService: RoleService; + let idService: IdService; let root: MiUser; let role_tinyAttachment: MiRole; + let folder: MiDriveFolder; + beforeAll(async () => { module = await Test.createTestingModule({ imports: [GlobalModule, CoreModule, ServerModule], @@ -33,21 +35,34 @@ describe('/drive/files/create', () => { module.enableShutdownHooks(); const serverService = module.get(ServerService); - server = await serverService.launch(); + await serverService.launch(); + server = serverService.fastify; + + idService = module.get(IdService); const usersRepository = module.get(DI.usersRepository); + await usersRepository.delete({}); root = await usersRepository.insert({ - id: 'root', + id: idService.gen(), username: 'root', usernameLower: 'root', token: '1234567890123456', }).then(x => usersRepository.findOneByOrFail(x.identifiers[0])); const userProfilesRepository = module.get(DI.userProfilesRepository); + await userProfilesRepository.delete({}); await userProfilesRepository.insert({ userId: root.id, }); + const driveFoldersRepository = module.get(DI.driveFoldersRepository); + folder = await driveFoldersRepository.insertOne({ + id: idService.gen(), + name: 'root-folder', + parentId: null, + userId: root.id, + }); + roleService = module.get(RoleService); role_tinyAttachment = await roleService.create({ name: 'test-role001', @@ -65,8 +80,8 @@ describe('/drive/files/create', () => { }); beforeEach(async () => { - s3Mock.reset(); - await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {}); + await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => { + }); }); afterAll(async () => { @@ -74,35 +89,76 @@ describe('/drive/files/create', () => { await module.close(); }); - test('200 ok', async () => { - const result = await request(server.server) + async function postFile(props: { + name: string, + comment: string, + isSensitive: boolean, + force: boolean, + fileContent: Buffer | string, + }) { + const { name, comment, isSensitive, force, fileContent } = props; + + return await request(server.server) .post('/api/drive/files/create') .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(1024 * 1024))); + .attach('file', fileContent) + .field('name', name) + .field('comment', comment) + .field('isSensitive', isSensitive) + .field('force', force) + .field('folderId', folder.id) + .field('i', root.token ?? ''); + } + + test('200 ok', async () => { + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(1000 * 1000)), + }); expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); }); test('200 ok(with role)', async () => { await roleService.assign(root.id, role_tinyAttachment.id); - const result = await request(server.server) - .post('/api/drive/files/create') - .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(10))); + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(10)), + }); expect(result.statusCode).toBe(200); + expect(result.body.name).toBe(name + '.unknown'); + expect(result.body.comment).toBe(comment); + expect(result.body.isSensitive).toBe(true); + expect(result.body.folderId).toBe(folder.id); }); test('413 too large', async () => { await roleService.assign(root.id, role_tinyAttachment.id); - const result = await request(server.server) - .post('/api/drive/files/create') - .set('Content-Type', 'multipart/form-data') - .set('Authorization', `Bearer ${root.token}`) - .attach('file', Buffer.from('a'.repeat(11))); + const name = randomString(); + const comment = randomString(); + const result = await postFile({ + name: name, + comment: comment, + isSensitive: true, + force: true, + fileContent: Buffer.from('a'.repeat(11)), + }); expect(result.statusCode).toBe(413); - expect(result.body.error.code).toBe('FILE_SIZE_TOO_LARGE'); + expect(result.body.error.code).toBe('MAX_FILE_SIZE_EXCEEDED'); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 7b69cb04f4..7f2768488f 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -11,11 +11,12 @@ import { inspect } from 'node:util'; import WebSocket, { ClientOptions } from 'ws'; import fetch, { File, RequestInit, type Headers } from 'node-fetch'; import { DataSource } from 'typeorm'; -import { JSDOM } from 'jsdom'; +import { load as cheerio } from 'cheerio'; import { type Response } from 'node-fetch'; import Fastify from 'fastify'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; +import type { CheerioAPI } from 'cheerio'; import type * as misskey from 'misskey-js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; @@ -464,7 +465,7 @@ export function makeStreamCatcher( export type SimpleGetResponse = { status: number, - body: any | JSDOM | null, + body: any | CheerioAPI | null, type: string | null, location: string | null }; @@ -495,7 +496,7 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde const body = jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : - htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? cheerio(await res.text()) : await bodyExtractor(res); return { diff --git a/packages/frontend/assets/sharkey-text-src/README.md b/packages/frontend/assets/sharkey-text-src/README.md index 64b4557ccc..f153ec42e3 100644 --- a/packages/frontend/assets/sharkey-text-src/README.md +++ b/packages/frontend/assets/sharkey-text-src/README.md @@ -1,7 +1,17 @@ -This logo text was made in Inkscape by @sneexy@booping.synth.download +# Sharkey Logo -If you edit it, you can use -[svgo](https://jakearchibald.github.io/svgomg/) to generate a -browser-compatible file, to replace `../sharkey.svg` +This logo was made in Inkscape by [@sneexy@booping.synth.download](https://booping.synth.download/@sneexy). + +The font used is Lilita One ([Google Fonts](https://fonts.google.com/specimen/Lilita+One)). + +## Using Inkscape + +Preferably, you should use Inkscape when wanting to make modifications to the logo. Once you've made your edits, save the SVG here as is from Inkscape directly, then use [svgo](https://github.com/svg/svgo) ([Web UI](https://jakearchibald.github.io/svgomg), ensure `Multipass` is enabled) to produce an SVG compatible to be viewed in web browsers, which that file will then replace [`../sharkey.svg`](../sharkey.svg). + +## Not using Inkscape + +The logo should preferably be modified using Inkscape, but should work in any other software. If any weird issues appear, try opening it with Inkscape first or try using the version under [`../sharkey.svg`](../sharkey.svg) if you prefer to work with your software. Follow the instructions for Using Inkscape. + +## Licence This logo is distributed under the same licence as Sharkey (AGPL ≥3). diff --git a/packages/frontend/assets/sharkey-text-src/sharkey_v2.svg b/packages/frontend/assets/sharkey-text-src/sharkey.svg similarity index 100% rename from packages/frontend/assets/sharkey-text-src/sharkey_v2.svg rename to packages/frontend/assets/sharkey-text-src/sharkey.svg diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 8a0bbe348a..000ccf50bf 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -92,7 +92,6 @@ SPDX-License-Identifier: AGPL-3.0-only -
@@ -187,6 +186,7 @@ const posted = ref(false); const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); const poll = ref(null); +const initialPoll = ref(null); const useCw = ref(!!props.initialCw); const showPreview = ref(store.s.showPreview); watch(showPreview, () => store.set('showPreview', showPreview.value)); @@ -317,19 +317,10 @@ if (props.reply && (props.reply.user.username !== $i.username || (props.reply.us text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `; } -if (props.reply && props.reply.text != null) { - const ast = mfm.parse(props.reply.text); - const otherHost = props.reply.user.host; - - for (const x of extractMentions(ast)) { - const mention = x.host ? - `@${x.username}@${toASCII(x.host)}` : - (otherHost == null || otherHost === host) ? - `@${x.username}` : - `@${x.username}@${toASCII(otherHost)}`; - - // 自分は除外 - if ($i.username === x.username && (x.host == null || x.host === host)) continue; +if (props.reply && props.reply.mentionHandles) { + for (const [user, mention] of Object.entries(props.reply.mentionHandles)) { + // Don't mention ourself + if (user === $i.id) continue; // 重複は除外 if (text.value.includes(`${mention} `)) continue; @@ -425,7 +416,7 @@ function checkMissingMention() { const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { + if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) { hasNotSpecifiedMentions.value = true; return; } @@ -438,7 +429,7 @@ function addMissingMention() { const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { + if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) { misskeyApi('users/show', { username: x.username, host: x.host }).then(user => { pushVisibleUser(user); }); @@ -629,12 +620,32 @@ function showOtherSettings() { }, }] satisfies MenuItem[]; + if ($i.policies.scheduleNoteMax > 0) { + menuItems.push({ type: 'divider' }, { + type: 'button', + text: i18n.ts.schedulePost, + icon: 'ti ti-calendar-time', + action: toggleScheduleNote, + }, { + type: 'button', + text: i18n.ts.schedulePostList, + icon: 'ti ti-calendar-event', + action: () => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, { + closed: () => { + dispose(); + }, + }); + }, + }); + } + os.popupMenu(menuItems, otherSettingsButton.value); } //#endregion function pushVisibleUser(user: Misskey.entities.UserDetailed) { - if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { + if (!visibleUsers.value.some(u => u.username.toLowerCase() === user.username.toLowerCase() && u.host === user.host)) { visibleUsers.value.push(user); } } @@ -959,6 +970,15 @@ async function post(ev?: MouseEvent) { token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; } + if (postData.editId && postData.poll !== initialPoll.value) { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts._confirmPollEdit.title, + text: i18n.ts._confirmPollEdit.text, + }); + if (canceled) return; + } + posting.value = true; misskeyApi(postData.editId ? 'notes/edit' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => { if (props.freezeAfterPosted) { @@ -1121,32 +1141,6 @@ function toggleScheduleNote() { } } -function showOtherMenu(ev: MouseEvent) { - const menuItems: MenuItem[] = []; - - if ($i.policies.scheduleNoteMax > 0) { - menuItems.push({ - type: 'button', - text: i18n.ts.schedulePost, - icon: 'ti ti-calendar-time', - action: toggleScheduleNote, - }, { - type: 'button', - text: i18n.ts.schedulePostList, - icon: 'ti ti-calendar-event', - action: () => { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, { - closed: () => { - dispose(); - }, - }); - }, - }); - } - - os.popupMenu(menuItems, ev.currentTarget ?? ev.target); -} - onMounted(() => { if (props.autofocus) { focus(); @@ -1208,6 +1202,7 @@ onMounted(() => { expiresAt: init.poll.expiresAt ? (new Date(init.poll.expiresAt)).getTime() : null, expiredAfter: null, }; + if (props.editId) initialPoll.value = poll.value; } if (init.visibleUserIds) { misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => { diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue index 3d405c566f..155277c976 100644 --- a/packages/frontend/src/pages/admin/job-queue.vue +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -203,6 +203,7 @@ const QUEUE_TYPES = [ 'objectStorage', 'userWebhookDeliver', 'systemWebhookDeliver', + 'scheduleNotePost', ] as const; const tab: Ref = ref('-'); 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/package.json b/packages/misskey-js/package.json index 5949b0e2f6..d6073c9861 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.4.1", + "version": "2025.4.2-rc", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", 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 1868ba44d5..2b4c39c280 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 @@ -4659,6 +4668,9 @@ export type components = { /** @enum {string} */ visibility: 'public' | 'home' | 'followers' | 'specified'; mentions?: string[]; + mentionHandles?: { + [key: string]: string; + }; visibleUserIds?: string[]; fileIds?: string[]; files?: components['schemas']['DriveFile'][]; @@ -9431,7 +9443,7 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost'; /** @enum {string} */ state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed'; }; @@ -9577,8 +9589,8 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; - state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed')[]; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost'; + state: ('active' | 'paused' | 'wait' | 'delayed' | 'completed' | 'failed')[]; search?: string; }; }; @@ -9631,7 +9643,7 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost'; }; }; }; @@ -9683,7 +9695,7 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost'; }; }; }; @@ -9779,7 +9791,7 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost'; jobId: string; }; }; @@ -9832,7 +9844,7 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost'; jobId: string; }; }; @@ -9885,7 +9897,7 @@ export type operations = { content: { 'application/json': { /** @enum {string} */ - queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver'; + queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost'; jobId: string; }; }; @@ -13071,6 +13083,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. @@ -17529,6 +17593,8 @@ export type operations = { sort?: '+createdAt' | '-createdAt' | '+name' | '-name' | '+size' | '-size' | null; /** @default */ searchQuery?: string; + /** @default false */ + showAll?: boolean; }; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 691069f563..cce229ef5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,12 +161,6 @@ importers: '@twemoji/parser': specifier: 15.1.1 version: 15.1.1 - '@types/psl': - specifier: ^1.1.3 - version: 1.1.3 - '@types/redis-info': - specifier: 3.0.3 - version: 3.0.3 accepts: specifier: 1.3.8 version: 1.3.8 @@ -182,6 +176,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 @@ -209,6 +206,9 @@ importers: chalk-template: specifier: 1.1.0 version: 1.1.0 + cheerio: + specifier: 1.0.0 + version: 1.0.0 chokidar: specifier: 4.0.3 version: 4.0.3 @@ -233,9 +233,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 @@ -284,9 +281,6 @@ importers: js-yaml: specifier: 4.1.0 version: 4.1.0 - jsdom: - specifier: 26.1.0 - version: 26.1.0(bufferutil@4.0.9)(canvas@3.1.0)(utf-8-validate@6.0.5) json5: specifier: 2.2.3 version: 2.2.3 @@ -592,9 +586,6 @@ importers: '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 - '@types/jsdom': - specifier: 21.1.7 - version: 21.1.7 '@types/jsonld': specifier: 1.5.15 version: 1.5.15 @@ -628,6 +619,9 @@ importers: '@types/proxy-addr': specifier: ^2.0.3 version: 2.0.3 + '@types/psl': + specifier: ^1.1.3 + version: 1.1.3 '@types/pug': specifier: 2.0.10 version: 2.0.10 @@ -640,6 +634,9 @@ importers: '@types/ratelimiter': specifier: 3.4.6 version: 3.4.6 + '@types/redis-info': + specifier: 3.0.3 + version: 3.0.3 '@types/rename': specifier: 1.0.7 version: 1.0.7 @@ -1016,7 +1013,7 @@ importers: version: 8.31.0(eslint@9.25.1)(typescript@5.8.3) '@vitest/coverage-v8': specifier: 3.1.2 - version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) + version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) '@vue/compiler-core': specifier: 3.5.13 version: 3.5.13 @@ -1085,7 +1082,7 @@ importers: version: 1.0.3 vitest: specifier: 3.1.2 - version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) + version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vitest-fetch-mock: specifier: 0.4.5 version: 0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) @@ -2274,10 +2271,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'} @@ -4220,9 +4213,6 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/jsdom@21.1.7': - resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -4400,9 +4390,6 @@ packages: '@types/tmp@0.2.6': resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} - '@types/tough-cookie@4.0.2': - resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==} - '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -5455,10 +5442,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 +6296,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 +6411,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 +9927,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==} @@ -10327,10 +10291,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@5.28.2: - resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==} - engines: {node: '>=14.0'} - undici@5.29.0: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} @@ -10918,6 +10878,7 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 + optional: true '@aws-crypto/crc32@5.2.0': dependencies: @@ -11851,12 +11812,14 @@ snapshots: '@cropper/utils@2.0.0': {} - '@csstools/color-helpers@5.0.2': {} + '@csstools/color-helpers@5.0.2': + optional: true '@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': dependencies: '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 + optional: true '@csstools/css-color-parser@3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': dependencies: @@ -11864,12 +11827,15 @@ snapshots: '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 + optional: true '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': dependencies: '@csstools/css-tokenizer': 3.0.3 + optional: true - '@csstools/css-tokenizer@3.0.3': {} + '@csstools/css-tokenizer@3.0.3': + optional: true '@cypress/request@3.0.8': dependencies: @@ -11905,7 +11871,7 @@ snapshots: dependencies: ky: 0.33.3 ky-universal: 0.11.0(ky@0.33.3)(web-streams-polyfill@4.0.0) - undici: 5.28.2 + undici: 5.29.0 transitivePeerDependencies: - web-streams-polyfill @@ -12060,10 +12026,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': {} @@ -14454,12 +14416,6 @@ snapshots: '@types/js-yaml@4.0.9': {} - '@types/jsdom@21.1.7': - dependencies: - '@types/node': 22.15.2 - '@types/tough-cookie': 4.0.2 - parse5: 7.3.0 - '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -14634,8 +14590,6 @@ snapshots: '@types/tmp@0.2.6': {} - '@types/tough-cookie@4.0.2': {} - '@types/tough-cookie@4.0.5': {} '@types/unist@3.0.2': {} @@ -14749,24 +14703,6 @@ snapshots: vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vue: 3.5.13(typescript@5.8.3) - '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.0(supports-color@8.1.1) - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.1.7 - magic-string: 0.30.17 - magicast: 0.3.5 - std-env: 3.9.0 - test-exclude: 7.0.1 - tinyrainbow: 2.0.0 - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) - transitivePeerDependencies: - - supports-color - '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))': dependencies: '@ampproject/remapping': 2.3.0 @@ -15354,7 +15290,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 +15926,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 @@ -16181,6 +16110,7 @@ snapshots: dependencies: '@asamuzakjp/css-color': 3.1.1 rrweb-cssom: 0.8.0 + optional: true csstype@3.1.3: {} @@ -16241,6 +16171,7 @@ snapshots: dependencies: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + optional: true data-view-buffer@1.0.1: dependencies: @@ -16311,7 +16242,8 @@ snapshots: decamelize@1.2.0: {} - decimal.js@10.5.0: {} + decimal.js@10.5.0: + optional: true decode-bmp@0.2.1: dependencies: @@ -17122,21 +17054,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 +17215,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) @@ -17680,6 +17595,7 @@ snapshots: html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 + optional: true html-entities@2.5.2: {} @@ -17983,7 +17899,8 @@ snapshots: is-plain-object@5.0.0: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-promise@2.2.2: {} @@ -18496,7 +18413,7 @@ snapshots: jsdoc-type-pratt-parser@4.1.0: {} - jsdom@26.1.0: + jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5): dependencies: cssstyle: 4.3.0 data-urls: 5.0.0 @@ -18524,35 +18441,6 @@ snapshots: - utf-8-validate optional: true - jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.0)(utf-8-validate@6.0.5): - dependencies: - cssstyle: 4.3.0 - data-urls: 5.0.0 - decimal.js: 10.5.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.19 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.5) - xml-name-validator: 5.0.0 - optionalDependencies: - canvas: 3.1.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - jsesc@2.5.2: {} json-buffer@3.0.1: {} @@ -19561,7 +19449,8 @@ snapshots: dependencies: boolbase: 1.0.0 - nwsapi@2.2.19: {} + nwsapi@2.2.19: + optional: true oauth2orize-pkce@0.1.2: {} @@ -20650,7 +20539,8 @@ snapshots: transitivePeerDependencies: - supports-color - rrweb-cssom@0.8.0: {} + rrweb-cssom@0.8.0: + optional: true rss-parser@3.13.0: dependencies: @@ -20712,6 +20602,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true scheduler@0.26.0: {} @@ -21324,7 +21215,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.13.0 + qs: 6.14.0 transitivePeerDependencies: - supports-color @@ -21366,7 +21257,8 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true systeminformation@5.25.11: {} @@ -21438,8 +21330,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: @@ -21484,11 +21374,13 @@ snapshots: tinyspy@3.0.2: {} - tldts-core@6.1.63: {} + tldts-core@6.1.63: + optional: true tldts@6.1.63: dependencies: tldts-core: 6.1.63 + optional: true tmp@0.2.3: {} @@ -21532,12 +21424,14 @@ snapshots: tough-cookie@5.1.2: dependencies: tldts: 6.1.63 + optional: true tr46@0.0.3: {} tr46@5.1.0: dependencies: punycode: 2.3.1 + optional: true tree-kill@1.2.2: optional: true @@ -21743,10 +21637,6 @@ snapshots: undici-types@6.21.0: {} - undici@5.28.2: - dependencies: - '@fastify/busboy': 2.1.0 - undici@5.29.0: dependencies: '@fastify/busboy': 2.1.0 @@ -21941,49 +21831,7 @@ snapshots: vitest-fetch-mock@0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)): dependencies: - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) - - vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3): - dependencies: - '@vitest/expect': 3.1.2 - '@vitest/mocker': 3.1.2(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) - '@vitest/pretty-format': 3.1.2 - '@vitest/runner': 3.1.2 - '@vitest/snapshot': 3.1.2 - '@vitest/spy': 3.1.2 - '@vitest/utils': 3.1.2 - chai: 5.2.0 - debug: 4.4.0(supports-color@8.1.1) - expect-type: 1.2.1 - magic-string: 0.30.17 - pathe: 2.0.3 - std-env: 3.9.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.13 - tinypool: 1.0.2 - tinyrainbow: 2.0.0 - vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) - vite-node: 3.1.2(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 22.15.2 - happy-dom: 17.4.4 - jsdom: 26.1.0(bufferutil@4.0.9)(canvas@3.1.0)(utf-8-validate@6.0.5) - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml + vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3): dependencies: @@ -22012,7 +21860,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 22.15.2 happy-dom: 17.4.4 - jsdom: 26.1.0 + jsdom: 26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - jiti - less @@ -22132,6 +21980,7 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true wait-on@8.0.3(debug@4.4.0): dependencies: @@ -22192,6 +22041,7 @@ snapshots: dependencies: tr46: 5.1.0 webidl-conversions: 7.0.0 + optional: true whatwg-url@5.0.0: dependencies: @@ -22295,7 +22145,8 @@ snapshots: xml-name-validator@4.0.0: {} - xml-name-validator@5.0.0: {} + xml-name-validator@5.0.0: + optional: true xml2js@0.5.0: dependencies: @@ -22304,7 +22155,8 @@ snapshots: xmlbuilder@11.0.1: {} - xmlchars@2.2.0: {} + xmlchars@2.2.0: + optional: true xtend@4.0.2: {} diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 635684596b..a5c3d5f892 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -536,3 +536,7 @@ deleted: "Deleted" enableProxyAccount: "Enable the proxy account." enableProxyAccountDescription: "If disabled, then the proxy account will not be used. User lists will only include notes from local or followed users." + +_confirmPollEdit: + title: Are you sure you want to edit this poll + text: Editing this poll will cause it to lose all previous votes