diff options
| author | Julia <julia@insertdomain.name> | 2025-03-02 19:54:32 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2025-03-02 19:54:32 +0000 |
| commit | 9e13c375c5ef4103ad5ee87fea583b154e9e16f3 (patch) | |
| tree | fe9e7b1a474e22fb0c37bd68cfd260f7ba39be74 /packages/backend/src | |
| parent | merge: pin corepack version (!885) (diff) | |
| parent | bump version (diff) | |
| download | sharkey-9e13c375c5ef4103ad5ee87fea583b154e9e16f3.tar.gz sharkey-9e13c375c5ef4103ad5ee87fea583b154e9e16f3.tar.bz2 sharkey-9e13c375c5ef4103ad5ee87fea583b154e9e16f3.zip | |
merge: 2025.2.2 (!927)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/927
Approved-by: Marie <github@yuugi.dev>
Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/backend/src')
210 files changed, 6136 insertions, 4777 deletions
diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 6ae8ccfbb3..7ca566477d 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -7,14 +7,14 @@ import { Global, Inject, Module } from '@nestjs/common'; import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; import { MeiliSearch } from 'meilisearch'; +import { MiMeta } from '@/models/Meta.js'; import { DI } from './di-symbols.js'; import { Config, loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import { allSettled } from './misc/promise-tracker.js'; -import type { Provider, OnApplicationShutdown } from '@nestjs/common'; -import { MiMeta } from '@/models/Meta.js'; import { GlobalEvents } from './core/GlobalEventService.js'; +import type { Provider, OnApplicationShutdown } from '@nestjs/common'; const $config: Provider = { provide: DI.config, @@ -33,7 +33,11 @@ const $db: Provider = { const $meilisearch: Provider = { provide: DI.meilisearch, useFactory: (config: Config) => { - if (config.meilisearch) { + if (config.fulltextSearch?.provider === 'meilisearch') { + if (!config.meilisearch) { + throw new Error('MeiliSearch is enabled but no configuration is provided'); + } + return new MeiliSearch({ host: `${config.meilisearch.ssl ? 'https' : 'http'}://${config.meilisearch.host}:${config.meilisearch.port}`, apiKey: config.meilisearch.apiKey, @@ -88,6 +92,14 @@ const $redisForReactions: Provider = { inject: [DI.config], }; +const $redisForRateLimit: Provider = { + provide: DI.redisForRateLimit, + useFactory: (config: Config) => { + return new Redis.Redis(config.redisForRateLimit); + }, + inject: [DI.config], +}; + const $meta: Provider = { provide: DI.meta, useFactory: async (db: DataSource, redisForSub: Redis.Redis) => { @@ -148,8 +160,8 @@ const $meta: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions], - exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule], + providers: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, $redisForRateLimit], + exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, $redisForRateLimit, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @@ -159,6 +171,7 @@ export class GlobalModule implements OnApplicationShutdown { @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, @Inject(DI.redisForReactions) private redisForReactions: Redis.Redis, + @Inject(DI.redisForRateLimit) private redisForRateLimit: Redis.Redis, ) { } public async dispose(): Promise<void> { @@ -172,6 +185,7 @@ export class GlobalModule implements OnApplicationShutdown { this.redisForSub.disconnect(), this.redisForTimelines.disconnect(), this.redisForReactions.disconnect(), + this.redisForRateLimit.disconnect(), ]); } diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts index ad59a55688..2f97980e9a 100644 --- a/packages/backend/src/boot/common.ts +++ b/packages/backend/src/boot/common.ts @@ -13,6 +13,7 @@ import { ServerStatsService } from '@/daemons/ServerStatsService.js'; import { ServerService } from '@/server/ServerService.js'; import { MainModule } from '@/MainModule.js'; import { envOption } from '@/env.js'; +import { ApLogCleanupService } from '@/daemons/ApLogCleanupService.js'; export async function server() { const app = await NestFactory.createApplicationContext(MainModule, { @@ -28,6 +29,7 @@ export async function server() { if (!envOption.noDaemons) { app.get(QueueStatsService).start(); app.get(ServerStatsService).start(); + app.get(ApLogCleanupService).start(); } return app; diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 56128a7ab9..735a0f4666 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -70,14 +70,22 @@ async function main() { }); //#endregion - if (cluster.isPrimary || envOption.disableClustering) { - await masterMain(); + if (!envOption.disableClustering) { if (cluster.isPrimary) { + logger.info(`Start main process... pid: ${process.pid}`); + await masterMain(); ev.mount(); + } else if (cluster.isWorker) { + logger.info(`Start worker process... pid: ${process.pid}`); + await workerMain(); + } else { + throw new Error('Unknown process type'); } - } - if (cluster.isWorker) { - await workerMain(); + } else { + // 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない) + logger.info(`Start main process... pid: ${process.pid}`); + await masterMain(); + ev.mount(); } readyRef.value = true; diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 355e095c12..cf9e9a9bae 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -18,6 +18,7 @@ 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); @@ -32,7 +33,6 @@ const themeColor = chalk.hex('#86b300'); function greet() { if (!envOption.quiet) { //#region Misskey logo - const v = `v${meta.version}`; console.log(themeColor(' _____ _ _ ')); console.log(themeColor('/ ___| | | | ')); console.log(themeColor('\\ `--.| |__ __ _ _ __| | _____ _ _ ')); @@ -91,42 +91,59 @@ export async function masterMain() { maxBreadcrumbs: 0, // Set release version - release: "Sharkey@" + meta.version, + release: 'Sharkey@' + meta.version, ...config.sentryForBackend.options, }); } - if (envOption.disableClustering) { + bootLogger.info( + `mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`, + ); + + if (envOption.onlyServer && envOption.onlyQueue) { + bootLogger.error('Configuration error: onlyServer and onlyQueue cannot both be set. To run both server and queue workers, disable / remove both options.'); + process.exit(1); + } + + if (!envOption.disableClustering) { + // clusterモジュール有効時 + if (envOption.onlyServer) { - await server(); + // onlyServer かつ enableCluster な場合、メインプロセスはforkのみに制限する(listenしない)。 + // ワーカープロセス側でlistenすると、メインプロセスでポートへの着信を受け入れてワーカープロセスへの分配を行う動作をする。 + // そのため、メインプロセスでも直接listenするとポートの競合が発生して起動に失敗してしまう。 + // see: https://nodejs.org/api/cluster.html#cluster } else if (envOption.onlyQueue) { await jobQueue(); } else { await server(); - await jobQueue(); - } - } else { - if (envOption.onlyServer) { - // nop - } else if (envOption.onlyQueue) { - // nop - } else { - await server(); } if (config.clusterLimit === 0) { - bootLogger.error("Configuration error: we can't create workers, `config.clusterLimit` is 0 (if you don't want to use clustering, set the environment variable `MK_DISABLE_CLUSTERING` to a non-empty value instead)", null, true); + bootLogger.error('Configuration error: we can\'t create workers, `config.clusterLimit` is 0 (if you don\'t want to use clustering, set the environment variable `MK_DISABLE_CLUSTERING` to a non-empty value instead)', null, true); process.exit(1); } await spawnWorkers(config.clusterLimit); + } else { + // clusterモジュール無効時 + + if (envOption.onlyServer) { + await server(); + } else if (envOption.onlyQueue) { + await jobQueue(); + } else { + await server(); + await jobQueue(); + } } if (envOption.onlyQueue) { bootLogger.succ('Queue started', null, true); } else { - bootLogger.succ(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on port ${config.port} on ${config.url}`, null, true); + const addressString = net.isIPv6(config.address) ? `[${config.address}]` : config.address; + bootLogger.succ(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on ${addressString}:${config.port} on ${config.url}`, null, true); } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 4af1140f36..c571c227a1 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -27,6 +27,7 @@ type RedisOptionsSource = Partial<RedisOptions> & { type Source = { url?: string; port?: number; + address?: string; socket?: string; chmodSocket?: string; disableHsts?: boolean; @@ -52,6 +53,10 @@ type Source = { redisForJobQueue?: RedisOptionsSource; redisForTimelines?: RedisOptionsSource; redisForReactions?: RedisOptionsSource; + redisForRateLimit?: RedisOptionsSource; + fulltextSearch?: { + provider?: FulltextSearchProvider; + }; meilisearch?: { host: string; port: string; @@ -118,11 +123,25 @@ type Source = { pidFile: string; filePermissionBits?: string; + + logging?: { + sql?: { + disableQueryTruncation? : boolean, + enableQueryParamLogging? : boolean, + } + } + + activityLogging?: { + enabled?: boolean; + preSave?: boolean; + maxAge?: number; + }; }; export type Config = { url: string; port: number; + address: string; socket: string | undefined; chmodSocket: string | undefined; disableHsts: boolean | undefined; @@ -143,6 +162,9 @@ export type Config = { user: string; pass: string; }[] | undefined; + fulltextSearch?: { + provider?: FulltextSearchProvider; + }; meilisearch: { host: string; port: string; @@ -179,6 +201,12 @@ export type Config = { signToActivityPubGet: boolean; attachLdSignatureForRelays: boolean; checkActivityPubGetSignature: boolean | undefined; + logging?: { + sql?: { + disableQueryTruncation? : boolean, + enableQueryParamLogging? : boolean, + } + } version: string; publishTarballInsteadOfProvideRepositoryUrl: boolean; @@ -204,6 +232,7 @@ export type Config = { redisForJobQueue: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource; redisForReactions: RedisOptions & RedisOptionsSource; + redisForRateLimit: RedisOptions & RedisOptionsSource; sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined; sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined; perChannelMaxNoteCacheCount: number; @@ -217,8 +246,16 @@ export type Config = { pidFile: string; filePermissionBits?: string; + + activityLogging: { + enabled: boolean; + preSave: boolean; + maxAge: number; + }; }; +export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch' | 'sqlTsvector'; + const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -288,6 +325,7 @@ export function loadConfig(): Config { setupPassword: config.setupPassword, url: url.origin, port: config.port ?? parseInt(process.env.PORT ?? '3000', 10), + address: config.address ?? '0.0.0.0', socket: config.socket, chmodSocket: config.chmodSocket, disableHsts: config.disableHsts, @@ -302,12 +340,14 @@ export function loadConfig(): Config { db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass }, dbReplications: config.dbReplications, dbSlaves: config.dbSlaves, + fulltextSearch: config.fulltextSearch, meilisearch: config.meilisearch, redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis, + redisForRateLimit: config.redisForRateLimit ? convertRedisOptions(config.redisForRateLimit, host) : redis, sentryForBackend: config.sentryForBackend, sentryForFrontend: config.sentryForFrontend, id: config.id, @@ -354,6 +394,12 @@ export function loadConfig(): Config { import: config.import, pidFile: config.pidFile, filePermissionBits: config.filePermissionBits, + logging: config.logging, + activityLogging: { + enabled: config.activityLogging?.enabled ?? false, + preSave: config.activityLogging?.preSave ?? false, + maxAge: config.activityLogging?.maxAge ?? (1000 * 60 * 60 * 24 * 30), + }, }; } @@ -488,13 +534,14 @@ function applyEnvOverrides(config: Source) { // these are all the settings that can be overridden - _apply_top([['url', 'port', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications']]); + _apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications']]); _apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'disableCache']]); _apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]); _apply_top([ - ['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions'], + ['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions', 'redisForRateLimit'], ['host', 'port', 'username', 'pass', 'db', 'prefix'], ]); + _apply_top(['fulltextSearch', 'provider']); _apply_top(['meilisearch', ['host', 'port', 'apikey', 'ssl', 'index', 'scope']]); _apply_top([['sentryForFrontend', 'sentryForBackend'], 'options', ['dsn', 'profileSampleRate', 'serverName', 'includeLocalVariables', 'proxy', 'keepAlive', 'caCerts']]); _apply_top(['sentryForBackend', 'enableNodeProfiling']); @@ -503,4 +550,6 @@ function applyEnvOverrides(config: Source) { _apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]); _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword']]); + _apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]); + _apply_top(['activityLogging', ['enabled', 'preSave', 'maxAge']]); } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index adb0a63ad7..e2c492ff80 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -8,6 +8,18 @@ export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; +export const FILE_TYPE_IMAGE = [ + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/apng', + 'image/bmp', + 'image/tiff', + 'image/x-icon', +]; + // ブラウザで直接表示することを許可するファイルの種類のリスト // ここに含まれないものは application/octet-stream としてレスポンスされる // SVGはXSSを生むので許可しない diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index 742e2621fd..9bca795479 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -160,22 +160,22 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { }; }); - const recipientWebhookIds = await this.fetchWebhookRecipients() - .then(it => it - .filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook') - .map(it => it.systemWebhookId) - .filter(x => x != null)); - for (const webhookId of recipientWebhookIds) { - await Promise.all( - convertedReports.map(it => { - return this.systemWebhookService.enqueueSystemWebhook( - webhookId, - type, - it, - ); - }), - ); - } + const inactiveRecipients = await this.fetchWebhookRecipients() + .then(it => it.filter(it => !it.isActive)); + const withoutWebhookIds = inactiveRecipients + .map(it => it.systemWebhookId) + .filter(x => x != null); + return Promise.all( + convertedReports.map(it => { + return this.systemWebhookService.enqueueSystemWebhook( + type, + it, + { + excludes: withoutWebhookIds, + }, + ); + }), + ); } /** diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 24d11f29ff..e24fefb4b5 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, UserListMembershipsRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { IdService } from '@/core/IdService.js'; @@ -49,6 +49,9 @@ export class AccountMoveService { @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + private userEntityService: UserEntityService, private idService: IdService, private apPersonService: ApPersonService, @@ -119,6 +122,7 @@ export class AccountMoveService { await Promise.all([ this.copyBlocking(src, dst), this.copyMutings(src, dst), + this.deleteScheduledNotes(src), this.updateLists(src, dst), ]); } catch { @@ -201,6 +205,21 @@ export class AccountMoveService { await this.mutingsRepository.insert(arrayToInsert); } + @bindThis + public async deleteScheduledNotes(src: ThinUser): Promise<void> { + const scheduledNotes = await this.noteScheduleRepository.findBy({ + userId: src.id, + }) as MiNoteSchedule[]; + + for (const note of scheduledNotes) { + await this.queueService.ScheduleNotePostQueue.remove(`schedNote:${note.id}`); + } + + await this.noteScheduleRepository.delete({ + userId: src.id, + }); + } + /** * Update lists while moving accounts. * - No removal of the old account from the lists diff --git a/packages/backend/src/core/ApLogService.ts b/packages/backend/src/core/ApLogService.ts new file mode 100644 index 0000000000..096ec21de7 --- /dev/null +++ b/packages/backend/src/core/ApLogService.ts @@ -0,0 +1,207 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHash } from 'crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { In, LessThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { SkApFetchLog, SkApInboxLog, SkApContext } from '@/models/_.js'; +import type { ApContextsRepository, ApFetchLogsRepository, ApInboxLogsRepository } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { JsonValue } from '@/misc/json-value.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { IdService } from '@/core/IdService.js'; +import { IActivity, IObject } from './activitypub/type.js'; + +@Injectable() +export class ApLogService { + constructor( + @Inject(DI.config) + private readonly config: Config, + + @Inject(DI.apContextsRepository) + private apContextsRepository: ApContextsRepository, + + @Inject(DI.apInboxLogsRepository) + private readonly apInboxLogsRepository: ApInboxLogsRepository, + + @Inject(DI.apFetchLogsRepository) + private readonly apFetchLogsRepository: ApFetchLogsRepository, + + private readonly utilityService: UtilityService, + private readonly idService: IdService, + ) {} + + /** + * Creates an inbox log from an activity, and saves it if pre-save is enabled. + */ + public async createInboxLog(data: Partial<SkApInboxLog> & { + activity: IActivity, + keyId: string, + }): Promise<SkApInboxLog> { + const { object: activity, context, contextHash } = extractObjectContext(data.activity); + const host = this.utilityService.extractDbHost(data.keyId); + + const log = new SkApInboxLog({ + id: this.idService.gen(), + at: new Date(), + verified: false, + accepted: false, + host, + ...data, + activity, + context, + contextHash, + }); + + if (this.config.activityLogging.preSave) { + await this.saveInboxLog(log); + } + + return log; + } + + /** + * Saves or finalizes an inbox log. + */ + public async saveInboxLog(log: SkApInboxLog): Promise<SkApInboxLog> { + if (log.context) { + await this.saveContext(log.context); + } + + // Will be UPDATE with preSave, and INSERT without. + await this.apInboxLogsRepository.upsert(log, ['id']); + return log; + } + + /** + * Creates a fetch log from an activity, and saves it if pre-save is enabled. + */ + public async createFetchLog(data: Partial<SkApFetchLog> & { + requestUri: string + host: string, + }): Promise<SkApFetchLog> { + const log = new SkApFetchLog({ + id: this.idService.gen(), + at: new Date(), + accepted: false, + ...data, + }); + + if (this.config.activityLogging.preSave) { + await this.saveFetchLog(log); + } + + return log; + } + + /** + * Saves or finalizes a fetch log. + */ + public async saveFetchLog(log: SkApFetchLog): Promise<SkApFetchLog> { + if (log.context) { + await this.saveContext(log.context); + } + + // Will be UPDATE with preSave, and INSERT without. + await this.apFetchLogsRepository.upsert(log, ['id']); + return log; + } + + private async saveContext(context: SkApContext): Promise<void> { + // https://stackoverflow.com/a/47064558 + await this.apContextsRepository + .createQueryBuilder('activity_context') + .insert() + .into(SkApContext) + .values(context) + .orIgnore('md5') + .execute(); + } + + /** + * Deletes all logged copies of an object or objects + * @param objectUris URIs / AP IDs of the objects to delete + */ + public async deleteObjectLogs(objectUris: string | string[]): Promise<number> { + if (Array.isArray(objectUris)) { + const logsDeleted = await this.apFetchLogsRepository.delete({ + objectUri: In(objectUris), + }); + return logsDeleted.affected ?? 0; + } else { + const logsDeleted = await this.apFetchLogsRepository.delete({ + objectUri: objectUris, + }); + return logsDeleted.affected ?? 0; + } + } + + /** + * Deletes all expired AP logs and garbage-collects the AP context cache. + * Returns the total number of deleted rows. + */ + public async deleteExpiredLogs(): Promise<number> { + // This is the date in UTC of the oldest log to KEEP + const oldestAllowed = new Date(Date.now() - this.config.activityLogging.maxAge); + + // Delete all logs older than the threshold. + const inboxDeleted = await this.deleteExpiredInboxLogs(oldestAllowed); + const fetchDeleted = await this.deleteExpiredFetchLogs(oldestAllowed); + + return inboxDeleted + fetchDeleted; + } + + private async deleteExpiredInboxLogs(oldestAllowed: Date): Promise<number> { + const { affected } = await this.apInboxLogsRepository.delete({ + at: LessThan(oldestAllowed), + }); + + return affected ?? 0; + } + + private async deleteExpiredFetchLogs(oldestAllowed: Date): Promise<number> { + const { affected } = await this.apFetchLogsRepository.delete({ + at: LessThan(oldestAllowed), + }); + + return affected ?? 0; + } +} + +export function extractObjectContext<T extends IObject>(input: T) { + const object = Object.assign({}, input, { '@context': undefined }) as Omit<T, '@context'>; + const { context, contextHash } = parseContext(input['@context']); + + return { object, context, contextHash }; +} + +export function parseContext(input: JsonValue | undefined): { contextHash: string | null, context: SkApContext | null } { + // Empty contexts are excluded for easier querying + if (input == null) { + return { + contextHash: null, + context: null, + }; + } + + const contextHash = createHash('md5').update(JSON.stringify(input)).digest('base64'); + const context = new SkApContext({ + md5: contextHash, + json: input, + }); + return { contextHash, context }; +} + +export function calculateDurationSince(startTime: bigint): number { + // Calculate the processing time with correct rounding and decimals. + // 1. Truncate nanoseconds to microseconds + // 2. Scale to 1/10 millisecond ticks. + // 3. Round to nearest tick. + // 4. Sale to milliseconds + // Example: 123,456,789 ns -> 123,456 us -> 12,345.6 ticks -> 12,346 ticks -> 123.46 ms + const endTime = process.hrtime.bigint(); + return Math.round(Number((endTime - startTime) / 1000n) / 10) / 100; +} diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index 5b1ab00cfe..d17101ac97 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -6,6 +6,69 @@ import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/Meta.js'; +import Logger from '@/logger.js'; +import { LoggerService } from './LoggerService.js'; + +export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'fc', 'testcaptcha'] as const; +export type CaptchaProvider = typeof supportedCaptchaProviders[number]; + +export const captchaErrorCodes = { + invalidProvider: Symbol('invalidProvider'), + invalidParameters: Symbol('invalidParameters'), + noResponseProvided: Symbol('noResponseProvided'), + requestFailed: Symbol('requestFailed'), + verificationFailed: Symbol('verificationFailed'), + unknown: Symbol('unknown'), +} as const; +export type CaptchaErrorCode = typeof captchaErrorCodes[keyof typeof captchaErrorCodes]; + +export type CaptchaSetting = { + provider: CaptchaProvider; + hcaptcha: { + siteKey: string | null; + secretKey: string | null; + } + mcaptcha: { + siteKey: string | null; + secretKey: string | null; + instanceUrl: string | null; + } + recaptcha: { + siteKey: string | null; + secretKey: string | null; + } + turnstile: { + siteKey: string | null; + secretKey: string | null; + } + fc: { + siteKey: string | null; + secretKey: string | null; + } +} + +export class CaptchaError extends Error { + public readonly code: CaptchaErrorCode; + public readonly cause?: unknown; + + constructor(code: CaptchaErrorCode, message: string, cause?: unknown) { + super(message); + this.code = code; + this.cause = cause; + this.name = 'CaptchaError'; + } +} + +export type CaptchaSaveSuccess = { + success: true; +} +export type CaptchaSaveFailure = { + success: false; + error: CaptchaError; +} +export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure; type CaptchaResponse = { success: boolean; @@ -15,9 +78,14 @@ type CaptchaResponse = { @Injectable() export class CaptchaService { + private readonly logger: Logger; + constructor( private httpRequestService: HttpRequestService, + private metaService: MetaService, + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('captcha'); } @bindThis @@ -45,39 +113,39 @@ export class CaptchaService { @bindThis public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw new Error('recaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'recaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { - throw new Error(`recaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`recaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`); } } @bindThis public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw new Error('hcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'hcaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { - throw new Error(`hcaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`hcaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `hcaptcha-failed: ${errorCodes}`); } } @bindThis public async verifyFriendlyCaptcha(secret: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw new Error('frc-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'frc-failed: no response provided'); } const result = await this.httpRequestService.send('https://api.friendlycaptcha.com/api/v1/siteverify', { @@ -89,17 +157,17 @@ export class CaptchaService { headers: { 'Content-Type': 'application/json', }, - }); + }, { throwErrorWhenResponseNotOk: false }); if (result.status !== 200) { - throw new Error('frc-failed: frc didn\'t return 200 OK'); + throw new CaptchaError(captchaErrorCodes.requestFailed, `frc-request-failed: ${result.status}`); } const resp = await result.json() as CaptchaResponse; if (resp.success !== true) { const errorCodes = resp['errors'] ? resp['errors'].join(', ') : ''; - throw new Error(`frc-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `frc-failed: ${errorCodes}`); } } @@ -107,7 +175,7 @@ export class CaptchaService { @bindThis public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw new Error('mcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'mcaptcha-failed: no response provided'); } const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost); @@ -121,46 +189,272 @@ export class CaptchaService { headers: { 'Content-Type': 'application/json', }, - }); + }, { throwErrorWhenResponseNotOk: false }); if (result.status !== 200) { - throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK'); + throw new CaptchaError(captchaErrorCodes.requestFailed, 'mcaptcha-failed: mcaptcha didn\'t return 200 OK'); } const resp = (await result.json()) as { valid: boolean }; if (!resp.valid) { - throw new Error('mcaptcha-request-failed'); + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed'); } } @bindThis public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> { if (response == null) { - throw new Error('turnstile-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'turnstile-failed: no response provided'); } const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { - throw new Error(`turnstile-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`turnstile-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`); } } @bindThis public async verifyTestcaptcha(response: string | null | undefined): Promise<void> { if (response == null) { - throw new Error('testcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided'); } const success = response === 'testcaptcha-passed'; if (!success) { - throw new Error('testcaptcha-failed'); + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'testcaptcha-failed'); + } + } + + @bindThis + public async get(): Promise<CaptchaSetting> { + const meta = await this.metaService.fetch(true); + + let provider: CaptchaProvider; + switch (true) { + case meta.enableHcaptcha: { + provider = 'hcaptcha'; + break; + } + case meta.enableMcaptcha: { + provider = 'mcaptcha'; + break; + } + case meta.enableRecaptcha: { + provider = 'recaptcha'; + break; + } + case meta.enableTurnstile: { + provider = 'turnstile'; + break; + } + case meta.enableTestcaptcha: { + provider = 'testcaptcha'; + break; + } + case meta.enableFC: { + provider = 'fc'; + break; + } + default: { + provider = 'none'; + break; + } + } + + return { + provider: provider, + hcaptcha: { + siteKey: meta.hcaptchaSiteKey, + secretKey: meta.hcaptchaSecretKey, + }, + mcaptcha: { + siteKey: meta.mcaptchaSitekey, + secretKey: meta.mcaptchaSecretKey, + instanceUrl: meta.mcaptchaInstanceUrl, + }, + recaptcha: { + siteKey: meta.recaptchaSiteKey, + secretKey: meta.recaptchaSecretKey, + }, + turnstile: { + siteKey: meta.turnstileSiteKey, + secretKey: meta.turnstileSecretKey, + }, + fc: { + siteKey: meta.fcSiteKey, + secretKey: meta.fcSecretKey, + }, + }; + } + + /** + * captchaの設定を更新します. その際、フロントエンド側で受け取ったcaptchaからの戻り値を検証し、passした場合のみ設定を更新します. + * 実際の検証処理はサービス内で定義されている各captchaプロバイダの検証関数に委譲します. + * + * @param provider 検証するcaptchaのプロバイダ + * @param params + * @param params.sitekey hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsitekey. それ以外のプロバイダでは無視されます + * @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret. それ以外のプロバイダでは無視されます + * @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL. それ以外のプロバイダでは無視されます + * @param params.captchaResult フロントエンド側で受け取ったcaptchaプロバイダからの戻り値. この値を使ってサーバサイドでの検証を行います + * @see verifyHcaptcha + * @see verifyMcaptcha + * @see verifyRecaptcha + * @see verifyTurnstile + * @see verifyTestcaptcha + */ + @bindThis + public async save( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + captchaResult?: string | null; + }, + ): Promise<CaptchaSaveResult> { + if (!supportedCaptchaProviders.includes(provider)) { + return { + success: false, + error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`), + }; } + + const operation = { + none: async () => { + await this.updateMeta(provider, params); + }, + hcaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required'); + } + + await this.verifyHcaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + mcaptcha: async () => { + if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required'); + } + + await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult); + await this.updateMeta(provider, params); + }, + recaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required'); + } + + await this.verifyRecaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + turnstile: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required'); + } + + await this.verifyTurnstile(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + testcaptcha: async () => { + if (!params?.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required'); + } + + await this.verifyTestcaptcha(params.captchaResult); + await this.updateMeta(provider, params); + }, + fc: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'frc-failed: secret and captureResult are required'); + } + + await this.verifyFriendlyCaptcha(params.captchaResult, params.captchaResult); + await this.updateMeta(provider, params); + }, + }[provider]; + + return operation() + .then(() => ({ success: true }) as CaptchaSaveSuccess) + .catch(err => { + this.logger.info(err); + const error = err instanceof CaptchaError + ? err + : new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`); + return { + success: false, + error, + }; + }); + } + + @bindThis + private async updateMeta( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + }, + ) { + const metaPartial: Partial< + Pick< + MiMeta, + ('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') | + ('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') | + ('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') | + ('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') | + ('enableTestcaptcha' | 'enableFC' | 'fcSiteKey' | 'fcSecretKey') + > + > = { + enableHcaptcha: provider === 'hcaptcha', + enableMcaptcha: provider === 'mcaptcha', + enableRecaptcha: provider === 'recaptcha', + enableTurnstile: provider === 'turnstile', + enableTestcaptcha: provider === 'testcaptcha', + enableFC: provider === 'fc', + }; + + const updateIfNotUndefined = <K extends keyof typeof metaPartial>(key: K, value: typeof metaPartial[K]) => { + if (value !== undefined) { + metaPartial[key] = value; + } + }; + switch (provider) { + case 'hcaptcha': { + updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('hcaptchaSecretKey', params?.secret); + break; + } + case 'mcaptcha': { + updateIfNotUndefined('mcaptchaSitekey', params?.sitekey); + updateIfNotUndefined('mcaptchaSecretKey', params?.secret); + updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl); + break; + } + case 'recaptcha': { + updateIfNotUndefined('recaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('recaptchaSecretKey', params?.secret); + break; + } + case 'turnstile': { + updateIfNotUndefined('turnstileSiteKey', params?.sitekey); + updateIfNotUndefined('turnstileSecretKey', params?.secret); + break; + } + case 'fc': { + updateIfNotUndefined('fcSiteKey', params?.sitekey); + updateIfNotUndefined('fcSecretKey', params?.secret); + } + } + + await this.metaService.update(metaPartial); } } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 141f905d7f..3c35dfc4ff 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -17,6 +17,8 @@ import { WebhookTestService } from '@/core/WebhookTestService.js'; import { FlashService } from '@/core/FlashService.js'; import { TimeService } from '@/core/TimeService.js'; import { EnvService } from '@/core/EnvService.js'; +import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; +import { ApLogService } from '@/core/ApLogService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AnnouncementService } from './AnnouncementService.js'; @@ -166,6 +168,7 @@ const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisti const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; +const $ApLogService: Provider = { provide: 'ApLogService', useExisting: ApLogService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; @@ -232,6 +235,8 @@ const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpo const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; +const $TimeService: Provider = { provide: 'TimeService', useExisting: TimeService }; +const $EnvService: Provider = { provide: 'EnvService', useExisting: EnvService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; @@ -304,6 +309,7 @@ const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting: const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService }; const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService }; const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService }; +const $ApUtilityService: Provider = { provide: 'ApUtilityService', useExisting: ApUtilityService }; //#endregion const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: SponsorsService }; @@ -320,6 +326,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AccountUpdateService, AnnouncementService, AntennaService, + ApLogService, AppLockService, AchievementService, AvatarDecorationService, @@ -460,6 +467,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp ApNoteService, ApPersonService, ApQuestionService, + ApUtilityService, QueueService, SponsorsService, @@ -472,6 +480,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AccountUpdateService, $AnnouncementService, $AntennaService, + $ApLogService, $AppLockService, $AchievementService, $AvatarDecorationService, @@ -538,6 +547,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $ChannelFollowingService, $RegistryApiService, $ReversiService, + $TimeService, + $EnvService, $ChartLoggerService, $FederationChart, @@ -610,6 +621,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $ApNoteService, $ApPersonService, $ApQuestionService, + $ApUtilityService, //#endregion $SponsorsService, @@ -623,6 +635,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AccountUpdateService, AnnouncementService, AntennaService, + ApLogService, AppLockService, AchievementService, AvatarDecorationService, @@ -762,6 +775,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp ApNoteService, ApPersonService, ApQuestionService, + ApUtilityService, QueueService, SponsorsService, @@ -774,6 +788,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AccountUpdateService, $AnnouncementService, $AntennaService, + $ApLogService, $AppLockService, $AchievementService, $AvatarDecorationService, @@ -839,6 +854,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $ChannelFollowingService, $RegistryApiService, $ReversiService, + $TimeService, + $EnvService, $FederationChart, $NotesChart, @@ -910,6 +927,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $ApNoteService, $ApPersonService, $ApQuestionService, + $ApUtilityService, //#endregion $SponsorsService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index cc33fb5c0b..2e4eddf797 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -4,19 +4,18 @@ */ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import { In, IsNull } from 'typeorm'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import type { DriveFilesRepository, EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { query } from '@/misc/prelude/url.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import type { DriveFilesRepository, EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import type { MiEmoji } from '@/models/Emoji.js'; import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import type { Config } from '@/config.js'; @@ -24,6 +23,42 @@ import { DriveService } from './DriveService.js'; const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; +export const fetchEmojisHostTypes = [ + 'local', + 'remote', + 'all', +] as const; +export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number]; +export const fetchEmojisSortKeys = [ + '+id', + '-id', + '+updatedAt', + '-updatedAt', + '+name', + '-name', + '+host', + '-host', + '+uri', + '-uri', + '+publicUrl', + '-publicUrl', + '+type', + '-type', + '+aliases', + '-aliases', + '+category', + '-category', + '+license', + '-license', + '+isSensitive', + '-isSensitive', + '+localOnly', + '-localOnly', + '+roleIdsThatCanBeUsedThisEmojiAsReaction', + '-roleIdsThatCanBeUsedThisEmojiAsReaction', +] as const; +export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number]; + @Injectable() export class CustomEmojiService implements OnApplicationShutdown { private emojisCache: MemoryKVCache<MiEmoji | null>; @@ -32,16 +67,12 @@ export class CustomEmojiService implements OnApplicationShutdown { constructor( @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.config) private config: Config, - @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private utilityService: UtilityService, private idService: IdService, private emojiEntityService: EmojiEntityService, @@ -67,7 +98,9 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async add(data: { - driveFile: MiDriveFile; + originalUrl: string; + publicUrl: string; + fileType: string; name: string; category: string | null; aliases: string[]; @@ -84,9 +117,9 @@ export class CustomEmojiService implements OnApplicationShutdown { category: data.category, host: data.host, aliases: data.aliases, - originalUrl: data.driveFile.url, - publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, - type: data.driveFile.webpublicType ?? data.driveFile.type, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, @@ -115,7 +148,9 @@ export class CustomEmojiService implements OnApplicationShutdown { public async update(data: ( { id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], } ) & { - driveFile?: MiDriveFile; + originalUrl?: string; + publicUrl?: string; + fileType?: string; category?: string | null; aliases?: string[]; license?: string | null; @@ -148,18 +183,22 @@ export class CustomEmojiService implements OnApplicationShutdown { license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, - originalUrl: data.driveFile != null ? data.driveFile.url : undefined, - publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined, - type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, }); this.localEmojisCache.refresh(); - if (data.driveFile != null) { - const file = await this.driveFilesRepository.findOneBy({ url: emoji.originalUrl, userHost: emoji.host ? emoji.host : IsNull() }); - if (file && file.id !== data.driveFile.id) { - await this.driveService.deleteFile(file, false, moderator ? moderator : undefined); + // If we're changing the file, then we need to delete the old one + if (data.originalUrl != null && data.originalUrl !== emoji.originalUrl) { + const oldFile = await this.driveFilesRepository.findOneBy({ url: emoji.originalUrl, userHost: emoji.host ? emoji.host : IsNull() }); + const newFile = await this.driveFilesRepository.findOneBy({ url: data.originalUrl, userHost: emoji.host ? emoji.host : IsNull() }); + + // But DON'T delete if this is the same file reference, otherwise we'll break the emoji! + if (oldFile && newFile && oldFile.id !== newFile.id) { + await this.driveService.deleteFile(oldFile, false, moderator ? moderator : undefined); } } @@ -336,7 +375,7 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { - // クエリに使うホスト + // クエリに使うホスト let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 @@ -445,6 +484,151 @@ export class CustomEmojiService implements OnApplicationShutdown { } @bindThis + public async fetchEmojis( + params?: { + query?: { + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + hostType?: FetchEmojisHostTypes; + roleIds?: string[]; + }, + sinceId?: string; + untilId?: string; + }, + opts?: { + limit?: number; + page?: number; + sortKeys?: FetchEmojisSortKeys[] + }, + ) { + function multipleWordsToQuery(words: string) { + return words.split(/\s/).filter(x => x.length > 0).map(x => `%${sqlLikeEscape(x)}%`); + } + + const builder = this.emojisRepository.createQueryBuilder('emoji'); + if (params?.query) { + const q = params.query; + if (q.updatedAtFrom) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) >= :updatedAtFrom', { updatedAtFrom: q.updatedAtFrom }); + } + if (q.updatedAtTo) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) <= :updatedAtTo', { updatedAtTo: q.updatedAtTo }); + } + if (q.name) { + builder.andWhere('emoji.name ~~ ANY(ARRAY[:...name])', { name: multipleWordsToQuery(q.name) }); + } + + switch (true) { + case q.hostType === 'local': { + builder.andWhere('emoji.host IS NULL'); + break; + } + case q.hostType === 'remote': { + if (q.host) { + // noIndexScan + builder.andWhere('emoji.host ~~ ANY(ARRAY[:...host])', { host: multipleWordsToQuery(q.host) }); + } else { + builder.andWhere('emoji.host IS NOT NULL'); + } + break; + } + } + + if (q.uri) { + // noIndexScan + builder.andWhere('emoji.uri ~~ ANY(ARRAY[:...uri])', { uri: multipleWordsToQuery(q.uri) }); + } + if (q.publicUrl) { + // noIndexScan + builder.andWhere('emoji.publicUrl ~~ ANY(ARRAY[:...publicUrl])', { publicUrl: multipleWordsToQuery(q.publicUrl) }); + } + if (q.type) { + // noIndexScan + builder.andWhere('emoji.type ~~ ANY(ARRAY[:...type])', { type: multipleWordsToQuery(q.type) }); + } + if (q.aliases) { + // noIndexScan + const subQueryBuilder = builder.subQuery() + .select('COUNT(0)', 'count') + .from( + sq2 => sq2 + .select('unnest(subEmoji.aliases)', 'alias') + .addSelect('subEmoji.id', 'id') + .from('emoji', 'subEmoji'), + 'aliasTable', + ) + .where('"emoji"."id" = "aliasTable"."id"') + .andWhere('"aliasTable"."alias" ~~ ANY(ARRAY[:...aliases])', { aliases: multipleWordsToQuery(q.aliases) }); + + builder.andWhere(`(${subQueryBuilder.getQuery()}) > 0`); + } + if (q.category) { + builder.andWhere('emoji.category ~~ ANY(ARRAY[:...category])', { category: multipleWordsToQuery(q.category) }); + } + if (q.license) { + // noIndexScan + builder.andWhere('emoji.license ~~ ANY(ARRAY[:...license])', { license: multipleWordsToQuery(q.license) }); + } + if (q.isSensitive != null) { + // noIndexScan + builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive }); + } + if (q.localOnly != null) { + // noIndexScan + builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly }); + } + if (q.roleIds && q.roleIds.length > 0) { + builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction && ARRAY[:...roleIds]::VARCHAR[]', { roleIds: q.roleIds }); + } + } + + if (params?.sinceId) { + builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId }); + } + if (params?.untilId) { + builder.andWhere('emoji.id < :untilId', { untilId: params.untilId }); + } + + if (opts?.sortKeys && opts.sortKeys.length > 0) { + for (const sortKey of opts.sortKeys) { + const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC'; + const key = sortKey.replace(/^[+-]/, ''); + builder.addOrderBy(`emoji.${key}`, direction); + } + } else { + builder.addOrderBy('emoji.id', 'DESC'); + } + + const limit = opts?.limit ?? 10; + if (opts?.page) { + builder.skip((opts.page - 1) * limit); + } + + builder.take(limit); + + const [emojis, count] = await builder.getManyAndCount(); + + return { + emojis, + count: (count > limit ? emojis.length : count), + allCount: count, + allPages: Math.ceil(count / limit), + }; + } + + @bindThis public dispose(): void { this.emojisCache.dispose(); } diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 086f2f94d5..a65059b417 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -37,6 +37,7 @@ import { InternalStorageService } from '@/core/InternalStorageService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { FileInfoService } from '@/core/FileInfoService.js'; +import type { FileInfo } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { correctFilename } from '@/misc/correct-filename.js'; @@ -139,15 +140,18 @@ export class DriveService { /*** * Save file + * @param file * @param path Path for original * @param name Name for original (should be extention corrected) - * @param type Content-Type for original - * @param hash Hash for original - * @param size Size for original + * @param info File metadata */ @bindThis - private async save(file: MiDriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<MiDriveFile> { - // thunbnail, webpublic を必要なら生成 + private async save(file: MiDriveFile, path: string, name: string, info: FileInfo): Promise<MiDriveFile> { + const type = info.type.mime; + const hash = info.md5; + const size = info.size; + + // thunbnail, webpublic を必要なら生成 const alts = await this.generateAlts(path, type, !file.uri); if (this.meta.useObjectStorage) { @@ -223,9 +227,11 @@ export class DriveService { return await this.driveFilesRepository.insertOne(file); } else { // use internal storage - const accessKey = randomUUID(); - const thumbnailAccessKey = 'thumbnail-' + randomUUID(); - const webpublicAccessKey = 'webpublic-' + randomUUID(); + const ext = FILE_TYPE_BROWSERSAFE.includes(type) ? info.type.ext : null; + + const accessKey = makeFileKey(ext); + const thumbnailAccessKey = makeFileKey(ext, 'thumbnail'); + const webpublicAccessKey = makeFileKey(ext, 'webpublic'); // Ugly type is just to help TS figure out that 2nd / 3rd promises are optional. const promises: [Promise<string>, ...(Promise<string> | undefined)[]] = [ @@ -514,7 +520,7 @@ export class DriveService { // If usage limit exceeded if (driveCapacity < usage + info.size) { if (isLocalUser) { - throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); + throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.', true); } await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as MiRemoteUser, driveCapacity - info.size); } @@ -616,7 +622,7 @@ export class DriveService { } } } else { - file = await (this.save(file, path, detectedName, info.type.mime, info.md5, info.size)); + file = await (this.save(file, path, detectedName, info)); } this.registerLogger.succ(`drive file has been created ${file.id}`); @@ -862,3 +868,16 @@ export class DriveService { } } } + +function makeFileKey(ext: string | null, prefix?: string): string { + const parts: string[] = [randomUUID()]; + + if (prefix) { + parts.unshift(prefix, '-'); + } + if (ext) { + parts.push('.', ext); + } + + return parts.join(''); +} diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index fca3ad847a..3f7ed99348 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { QueryFailedError } from 'typeorm'; import type { InstancesRepository } from '@/models/_.js'; import type { MiInstance } from '@/models/Instance.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; @@ -12,7 +13,6 @@ import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import { QueryFailedError } from 'typeorm'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; @Injectable() diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 987999bce7..ce3af7c774 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -181,7 +181,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> { + private async fetchDom(instance: MiInstance): Promise<Document> { this.logger.info(`Fetching HTML of ${instance.host} ...`); const url = 'https://' + instance.host; @@ -206,7 +206,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> { + private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise<string | null> { const url = 'https://' + instance.host; if (doc) { @@ -232,7 +232,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { 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; @@ -261,7 +261,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; if (themeColor) { @@ -273,7 +273,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { if (info && info.metadata) { if (typeof info.metadata.nodeName === 'string') { return info.metadata.nodeName; @@ -298,7 +298,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { if (info && info.metadata) { if (typeof info.metadata.nodeDescription === 'string') { return info.metadata.nodeDescription; diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 083153940a..19992a7597 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -16,8 +16,8 @@ import type { Config } from '@/config.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js'; -import type { IObject } from '@/core/activitypub/type.js'; +import { IObject } from '@/core/activitypub/type.js'; +import { ApUtilityService } from './activitypub/ApUtilityService.js'; import type { Response } from 'node-fetch'; import type { URL } from 'node:url'; @@ -145,6 +145,7 @@ export class HttpRequestService { constructor( @Inject(DI.config) private config: Config, + private readonly apUtilityService: ApUtilityService, ) { const cache = new CacheableLookup({ maxTtl: 3600, // 1hours @@ -198,6 +199,7 @@ export class HttpRequestService { * Get agent by URL * @param url URL * @param bypassProxy Allways bypass proxy + * @param isLocalAddressAllowed */ @bindThis public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent { @@ -229,10 +231,11 @@ export class HttpRequestService { validators: [validateContentTypeSetAsActivityPub], }); - const finalUrl = res.url; // redirects may have been involved const activity = await res.json() as IObject; - assertActivityMatchesUrls(activity, [finalUrl]); + // Make sure the object ID matches the final URL (which is where it actually exists). + // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); return activity; } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 42676d6f98..6c2f673217 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -179,6 +179,40 @@ export class MfmService { break; } + // this is here only to catch upstream changes! + case 'ruby--': { + let ruby: [string, string][] = []; + for (const child of node.childNodes) { + if (child.nodeName === 'rp') { + continue; + } + if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) { + ruby.push([child.value, '']); + continue; + } + if (child.nodeName === 'rt' && ruby.length > 0) { + const rt = getText(child); + if (/\s|\[|\]/.test(rt)) { + // If any space is included in rt, it is treated as a normal text + ruby = []; + appendChildren(node.childNodes); + break; + } else { + ruby.at(-1)![1] = rt; + continue; + } + } + // If any other element is included in ruby, it is treated as a normal text + ruby = []; + appendChildren(node.childNodes); + break; + } + for (const [base, rt] of ruby) { + text += `$[ruby ${base} ${rt}]`; + } + break; + } + // block code (<pre><code>) case 'pre': { if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') { @@ -230,6 +264,75 @@ export class MfmService { break; } + case 'rp': break; + case 'rt': { + appendChildren(node.childNodes); + break; + } + case 'ruby': { + if (node.childNodes) { + /* + we get: + ``` + <ruby> + some text <rp>(</rp> <rt>annotation</rt> <rp>)</rp> + more text <rt>more annotation<rt> + </ruby> + ``` + + and we want to produce: + ``` + $[ruby $[group some text] annotation] + $[ruby $[group more text] more annotation] + ``` + + that `group` is a hack, because when the `ruby` render + sees just text inside the `$[ruby]`, it splits on + whitespace, considers the first "word" to be the main + content, and the rest the annotation + + with that `group`, we force it to consider the whole + group as the main content + + (note that the `rp` are to be ignored, they only exist + for browsers who don't understand ruby) + */ + let nonRtNodes = []; + // scan children, ignore `rp`, split on `rt` + for (const child of node.childNodes) { + if (treeAdapter.isTextNode(child)) { + nonRtNodes.push(child); + continue; + } + if (!treeAdapter.isElementNode(child)) { + continue; + } + if (child.nodeName === 'rp') { + continue; + } + if (child.nodeName === 'rt') { + // the only case in which we don't need a `$[group ]` + // is when both sides of the ruby are simple words + const needsGroup = nonRtNodes.length > 1 || + /\s|\[|\]/.test(getText(nonRtNodes[0])) || + /\s|\[|\]/.test(getText(child)); + text += '$[ruby '; + if (needsGroup) text += '$[group '; + appendChildren(nonRtNodes); + if (needsGroup) text += ']'; + text += ' '; + analyze(child); + text += ']'; + nonRtNodes = []; + continue; + } + nonRtNodes.push(child); + } + appendChildren(nonRtNodes); + } + break; + } + default: // includes inline elements { appendChildren(node.childNodes); @@ -348,6 +451,14 @@ export class MfmService { } } + // hack for ruby, should never be needed because we should + // never send this out to other instances + case 'group': { + const el = doc.createElement('span'); + appendChildren(node.children, el); + return el; + } + default: { return fnDefault(node); } @@ -526,11 +637,65 @@ export class MfmService { }, async fn(node) { - const el = doc.createElement('span'); - el.textContent = '*'; - await appendChildren(node.children, el); - el.textContent += '*'; - return el; + switch (node.props.name) { + case 'group': { // hack for ruby + const el = doc.createElement('span'); + await appendChildren(node.children, el); + return el; + } + case 'ruby': { + if (node.children.length === 1) { + const child = node.children[0]; + const text = child.type === 'text' ? child.props.text : ''; + const rubyEl = doc.createElement('ruby'); + const rtEl = doc.createElement('rt'); + + const rpStartEl = doc.createElement('rp'); + rpStartEl.appendChild(doc.createTextNode('(')); + const rpEndEl = doc.createElement('rp'); + rpEndEl.appendChild(doc.createTextNode(')')); + + rubyEl.appendChild(doc.createTextNode(text.split(' ')[0])); + rtEl.appendChild(doc.createTextNode(text.split(' ')[1])); + rubyEl.appendChild(rpStartEl); + rubyEl.appendChild(rtEl); + rubyEl.appendChild(rpEndEl); + return rubyEl; + } else { + const rt = node.children.at(-1); + + if (!rt) { + const el = doc.createElement('span'); + await appendChildren(node.children, el); + return el; + } + + const text = rt.type === 'text' ? rt.props.text : ''; + const rubyEl = doc.createElement('ruby'); + const rtEl = doc.createElement('rt'); + + const rpStartEl = doc.createElement('rp'); + rpStartEl.appendChild(doc.createTextNode('(')); + const rpEndEl = doc.createElement('rp'); + rpEndEl.appendChild(doc.createTextNode(')')); + + await appendChildren(node.children.slice(0, node.children.length - 1), rubyEl); + rtEl.appendChild(doc.createTextNode(text.trim())); + rubyEl.appendChild(rpStartEl); + rubyEl.appendChild(rtEl); + rubyEl.appendChild(rpEndEl); + return rubyEl; + } + } + + default: { + const el = doc.createElement('span'); + el.textContent = '*'; + await appendChildren(node.children, el); + el.textContent += '*'; + return el; + } + } }, blockCode(node) { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 96bb30a0d6..df31cb4247 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -144,6 +144,7 @@ type Option = { uri?: string | null; url?: string | null; app?: MiApp | null; + processErrors?: string[] | null; }; export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] }); @@ -228,7 +229,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public async create(user: { + public async create(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -309,6 +310,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } + // Check quote permissions + await this.checkQuotePermissions(data, user); + // Check blocking if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { @@ -435,7 +439,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public async import(user: { + public async import(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -482,14 +486,15 @@ export class NoteCreateService implements OnApplicationShutdown { renoteUserId: data.renote ? data.renote.userId : null, renoteUserHost: data.renote ? data.renote.userHost : null, userHost: user.host, + processErrors: data.processErrors, }); // should really not happen, but better safe than sorry if (data.reply?.id === insert.id) { - throw new Error("A note can't reply to itself"); + throw new Error('A note can\'t reply to itself'); } if (data.renote?.id === insert.id) { - throw new Error("A note can't renote itself"); + throw new Error('A note can\'t renote itself'); } if (data.uri != null) insert.uri = data.uri; @@ -552,7 +557,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private async postNoteCreated(note: MiNote, user: { + private async postNoteCreated(note: MiNote, user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -678,14 +683,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.roleService.addNoteToRoleTimeline(noteObj); - this.webhookService.getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'note', { - note: noteObj, - }); - } - }); + this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj }); const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); @@ -717,13 +715,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (!isThreadMuted && !muted) { nm.push(data.reply.userId, 'reply'); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'reply', { - note: noteObj, - }); - } + this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj }); } } } @@ -757,22 +749,16 @@ export class NoteCreateService implements OnApplicationShutdown { // Publish event if ((user.id !== data.renote.userId) && data.renote.userHost === null) { this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'renote', { - note: noteObj, - }); - } + this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj }); } } nm.notify(); //#region AP deliver - if (this.userEntityService.isLocalUser(user)) { + if (!data.localOnly && this.userEntityService.isLocalUser(user)) { (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note); + const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -905,13 +891,7 @@ export class NoteCreateService implements OnApplicationShutdown { }); this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'mention', { - note: detailPackedNote, - }); - } + this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote }); // Create notification nm.push(u.id, 'mention'); @@ -924,12 +904,12 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { if (data.localOnly) return null; const content = this.isRenote(data) && !this.isQuote(data) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note); return this.apRendererService.addContext(content); } @@ -1172,4 +1152,29 @@ export class NoteCreateService implements OnApplicationShutdown { public async onApplicationShutdown(signal?: string | undefined): Promise<void> { await this.dispose(); } + + @bindThis + public async checkQuotePermissions(data: Option, user: MiUser): Promise<void> { + // Not a quote + if (!this.isRenote(data) || !this.isQuote(data)) return; + + // User cannot quote + if (user.rejectQuotes) { + if (user.host == null) { + throw new IdentifiableError('1c0ea108-d1e3-4e8e-aa3f-4d2487626153', 'QUOTE_DISABLED_FOR_USER'); + } else { + (data as Option).renote = null; + (data.processErrors ??= []).push('quoteUnavailable'); + } + } + + // Instance cannot quote + if (user.host) { + const instance = await this.federatedInstanceService.fetch(user.host); + if (instance?.rejectQuotes) { + (data as Option).renote = null; + (data.processErrors ??= []).push('quoteUnavailable'); + } + } + } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index b51a3143c9..1f94e65809 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,9 +24,14 @@ import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { LatestNoteService } from '@/core/LatestNoteService.js'; +import { ApLogService } from '@/core/ApLogService.js'; +import Logger from '@/logger.js'; +import { LoggerService } from './LoggerService.js'; @Injectable() export class NoteDeleteService { + private readonly logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -55,7 +60,11 @@ export class NoteDeleteService { private perUserNotesChart: PerUserNotesChart, private instanceChart: InstanceChart, private latestNoteService: LatestNoteService, - ) {} + private readonly apLogService: ApLogService, + loggerService: LoggerService, + ) { + this.logger = loggerService.getLogger('note-delete-service'); + } /** * 投稿を削除します。 @@ -153,9 +162,13 @@ export class NoteDeleteService { noteUserId: note.userId, noteUserUsername: user.username, noteUserHost: user.host, - note: note, }); } + + if (note.uri) { + this.apLogService.deleteObjectLogs(note.uri) + .catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`)); + } } @bindThis diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index f1c7bcbea5..7851af86b7 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -140,6 +140,7 @@ type Option = { app?: MiApp | null; updatedAt?: Date | null; editcount?: boolean | null; + processErrors?: string[] | null; }; @Injectable() @@ -224,7 +225,7 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - public async edit(user: { + public async edit(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -309,7 +310,7 @@ export class NoteEditService implements OnApplicationShutdown { if (this.isRenote(data)) { if (data.renote.id === oldnote.id) { - throw new Error("A note can't renote itself"); + throw new Error('A note can\'t renote itself'); } switch (data.renote.visibility) { @@ -337,6 +338,9 @@ export class NoteEditService implements OnApplicationShutdown { } } + // Check quote permissions + await this.noteCreateService.checkQuotePermissions(data, user); + // Check blocking if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { @@ -529,6 +533,7 @@ export class NoteEditService implements OnApplicationShutdown { if (data.uri != null) note.uri = data.uri; if (data.url != null) note.url = data.url; + if (data.processErrors !== undefined) note.processErrors = data.processErrors; if (mentionedUsers.length > 0) { note.mentions = mentionedUsers.map(u => u.id); @@ -584,7 +589,7 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - private async postNoteEdited(note: MiNote, oldNote: MiNote, user: { + private async postNoteEdited(note: MiNote, oldNote: MiNote, user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -664,14 +669,7 @@ export class NoteEditService implements OnApplicationShutdown { this.roleService.addNoteToRoleTimeline(noteObj); - this.webhookService.getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'note', { - note: noteObj, - }); - } - }); + this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj }); const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); @@ -700,12 +698,7 @@ export class NoteEditService implements OnApplicationShutdown { nm.push(data.reply.userId, 'edited'); this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj); - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('edited')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'edited', { - note: noteObj, - }); - } + this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj }); } } } @@ -713,9 +706,9 @@ export class NoteEditService implements OnApplicationShutdown { nm.notify(); //#region AP deliver - if (this.userEntityService.isLocalUser(user)) { + if (!data.localOnly && this.userEntityService.isLocalUser(user)) { (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note); + const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -810,6 +803,7 @@ export class NoteEditService implements OnApplicationShutdown { (note.files != null && note.files.length > 0); } + // TODO why is this unused? @bindThis private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { @@ -837,13 +831,7 @@ export class NoteEditService implements OnApplicationShutdown { }); this.globalEventService.publishMainStream(u.id, 'edited', detailPackedNote); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('edited')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'edited', { - note: detailPackedNote, - }); - } + this.webhookService.enqueueUserWebhook(u.id, 'edited', { note: detailPackedNote }); // Create notification nm.push(u.id, 'edited'); @@ -851,14 +839,12 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { if (data.localOnly) return null; - const user = await this.usersRepository.findOneBy({ id: note.userId }); - if (user == null) throw new Error('user not found'); const content = this.isRenote(data) && !this.isQuote(data) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, false), user); + : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user); return this.apRendererService.addContext(content); } diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index 6c96ab16cf..d6364613bd 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -100,7 +100,7 @@ export class PollService { if (user == null) throw new Error('note not found'); if (this.userEntityService.isLocalUser(user)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); + const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, user, false), user)); this.apDeliverManagerService.deliverToFollowers(user, content); this.relayService.deliverToRelays(user, content); } diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index bb2a463354..37721d2bf1 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -28,7 +28,7 @@ export class S3Service { ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent - const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); + const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy, true); const handlerOption: NodeHttpHandlerOptions = {}; if (meta.objectStorageUseSSL) { handlerOption.httpsAgent = agent as https.Agent; diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 6dc3e85fc8..4782a6c7b0 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -6,16 +6,17 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; +import { type Config, FulltextSearchProvider } from '@/config.js'; import { bindThis } from '@/decorators.js'; import { MiNote } from '@/models/Note.js'; -import { MiUser } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; +import { MiUser } from '@/models/_.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { CacheService } from '@/core/CacheService.js'; import { QueryService } from '@/core/QueryService.js'; import { IdService } from '@/core/IdService.js'; +import { LoggerService } from '@/core/LoggerService.js'; import type { Index, MeiliSearch } from 'meilisearch'; type K = string; @@ -27,12 +28,81 @@ type Q = { op: '<', k: K, v: number } | { op: '>=', k: K, v: number } | { op: '<=', k: K, v: number } | - { op: 'is null', k: K} | - { op: 'is not null', k: K} | + { op: 'is null', k: K } | + { op: 'is not null', k: K } | { op: 'and', qs: Q[] } | { op: 'or', qs: Q[] } | { op: 'not', q: Q }; +const fileTypes = { + image: [ + 'image/webp', + 'image/png', + 'image/jpeg', + 'image/avif', + 'image/apng', + 'image/gif', + ], + video: [ + 'video/mp4', + 'video/webm', + 'video/mpeg', + 'video/x-m4v', + ], + audio: [ + 'audio/mpeg', + 'audio/flac', + 'audio/wav', + 'audio/aac', + 'audio/webm', + 'audio/opus', + 'audio/ogg', + 'audio/x-m4a', + 'audio/mod', + 'audio/s3m', + 'audio/xm', + 'audio/it', + 'audio/x-mod', + 'audio/x-s3m', + 'audio/x-xm', + 'audio/x-it', + ], + // Keep in sync with frontend-shared/js/const.ts + module: [ + 'audio/mod', + 'audio/x-mod', + 'audio/s3m', + 'audio/x-s3m', + 'audio/xm', + 'audio/x-xm', + 'audio/it', + 'audio/x-it', + ], + flash: [ + 'application/x-shockwave-flash', + 'application/vnd.adobe.flash.movie', + ], +}; + +// Make sure to regenerate misskey-js and check search.note.vue after changing these +export const fileTypeCategories = ['image', 'video', 'audio', 'module', 'flash', null] as const; +export type FileTypeCategory = typeof fileTypeCategories[number]; + +export type SearchOpts = { + userId?: MiNote['userId'] | null; + channelId?: MiNote['channelId'] | null; + host?: string | null; + filetype?: FileTypeCategory; + order?: string | null; + disableMeili?: boolean | null; +}; + +export type SearchPagination = { + untilId?: MiNote['id']; + sinceId?: MiNote['id']; + limit: number; +}; + function compileValue(value: V): string { if (typeof value === 'string') { return `'${value}'`; // TODO: escape @@ -64,7 +134,8 @@ function compileQuery(q: Q): string { @Injectable() export class SearchService { private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local'; - private meilisearchNoteIndex: Index | null = null; + private readonly meilisearchNoteIndex: Index | null = null; + private readonly provider: FulltextSearchProvider; constructor( @Inject(DI.config) @@ -79,6 +150,7 @@ export class SearchService { private cacheService: CacheService, private queryService: QueryService, private idService: IdService, + private loggerService: LoggerService, ) { if (meilisearch) { this.meilisearchNoteIndex = meilisearch.index(`${this.config.meilisearch?.index}---notes`); @@ -110,189 +182,198 @@ export class SearchService { if (this.config.meilisearch?.scope) { this.meilisearchIndexScope = this.config.meilisearch.scope; } + + this.provider = config.fulltextSearch?.provider ?? 'sqlLike'; + this.loggerService.getLogger('SearchService').info(`-- Provider: ${this.provider}`); } @bindThis public async indexNote(note: MiNote): Promise<void> { + if (!this.meilisearch) return; if (note.text == null && note.cw == null) return; if (!['home', 'public'].includes(note.visibility)) return; - if (this.meilisearch) { - switch (this.meilisearchIndexScope) { - case 'global': - break; + switch (this.meilisearchIndexScope) { + case 'global': + break; - case 'local': - if (note.userHost == null) break; - return; + case 'local': + if (note.userHost == null) break; + return; - default: { - if (note.userHost == null) break; - if (this.meilisearchIndexScope.includes(note.userHost)) break; - return; - } + default: { + if (note.userHost == null) break; + if (this.meilisearchIndexScope.includes(note.userHost)) break; + return; } - - await this.meilisearchNoteIndex?.addDocuments([{ - id: note.id, - createdAt: this.idService.parse(note.id).date.getTime(), - userId: note.userId, - userHost: note.userHost, - channelId: note.channelId, - cw: note.cw, - text: note.text, - tags: note.tags, - attachedFileTypes: note.attachedFileTypes, - }], { - primaryKey: 'id', - }); } + + await this.meilisearchNoteIndex?.addDocuments([{ + id: note.id, + createdAt: this.idService.parse(note.id).date.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + tags: note.tags, + attachedFileTypes: note.attachedFileTypes, + }], { + primaryKey: 'id', + }); } @bindThis public async unindexNote(note: MiNote): Promise<void> { + if (!this.meilisearch) return; if (!['home', 'public'].includes(note.visibility)) return; - if (this.meilisearch) { - this.meilisearchNoteIndex!.deleteDocument(note.id); - } + await this.meilisearchNoteIndex?.deleteDocument(note.id); } @bindThis - public async searchNote(q: string, me: MiUser | null, opts: { - userId?: MiNote['userId'] | null; - channelId?: MiNote['channelId'] | null; - host?: string | null; - filetype?: string | null; - order?: string | null; - disableMeili?: boolean | null; - }, pagination: { - untilId?: MiNote['id']; - sinceId?: MiNote['id']; - limit?: number; - }): Promise<MiNote[]> { - if (this.meilisearch && !opts.disableMeili) { - const filter: Q = { - op: 'and', - qs: [], - }; - if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() }); - if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() }); - if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); - if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); - if (opts.host) { - if (opts.host === '.') { - filter.qs.push({ op: 'is null', k: 'userHost' }); - } else { - filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); - } + public async searchNote( + q: string, + me: MiUser | null, + opts: SearchOpts, + pagination: SearchPagination, + ): Promise<MiNote[]> { + switch (this.provider) { + case 'sqlLike': + case 'sqlPgroonga': + case 'sqlTsvector': { + // ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている. + // 今後の拡張で差が出る用であれば関数を分ける. + return this.searchNoteByLike(q, me, opts, pagination); } - if (opts.filetype) { - if (opts.filetype === 'image') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'image/webp' }, - { op: '=', k: 'attachedFileTypes', v: 'image/png' }, - { op: '=', k: 'attachedFileTypes', v: 'image/jpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'image/avif' }, - { op: '=', k: 'attachedFileTypes', v: 'image/apng' }, - { op: '=', k: 'attachedFileTypes', v: 'image/gif' }, - ] }); - } else if (opts.filetype === 'video') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'video/mp4' }, - { op: '=', k: 'attachedFileTypes', v: 'video/webm' }, - { op: '=', k: 'attachedFileTypes', v: 'video/mpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'video/x-m4v' }, - ] }); - } else if (opts.filetype === 'audio') { - filter.qs.push({ op: 'or', qs: [ - { op: '=', k: 'attachedFileTypes', v: 'audio/mpeg' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/flac' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/wav' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/aac' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/webm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/opus' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/ogg' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-m4a' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/mod' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/s3m' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/xm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/it' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-mod' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-s3m' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-xm' }, - { op: '=', k: 'attachedFileTypes', v: 'audio/x-it' }, - ] }); - } + case 'meilisearch': { + return this.searchNoteByMeiliSearch(q, me, opts, pagination); } - const res = await this.meilisearchNoteIndex!.search(q, { - sort: [`createdAt:${opts.order ? opts.order : 'desc'}`], - matchingStrategy: 'all', - attributesToRetrieve: ['id', 'createdAt'], - filter: compileQuery(filter), - limit: pagination.limit, - }); - if (res.hits.length === 0) return []; - const [ - userIdsWhoMeMuting, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set<string>(), new Set<string>()]; - const notes = (await this.notesRepository.findBy({ - id: In(res.hits.map(x => x.id)), - })).filter(note => { - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - return true; - }); - return notes.sort((a, b) => a.id > b.id ? -1 : 1); + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const typeCheck: never = this.provider; + return []; + } + } + } + + @bindThis + private async searchNoteByLike( + q: string, + me: MiUser | null, + opts: SearchOpts, + pagination: SearchPagination, + ): Promise<MiNote[]> { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); + + if (opts.userId) { + query.andWhere('note.userId = :userId', { userId: opts.userId }); + } else if (opts.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); + } + + query + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (this.config.fulltextSearch?.provider === 'sqlPgroonga') { + query.andWhere('note.text &@~ :q', { q }); + } else if (this.config.fulltextSearch?.provider === 'sqlTsvector') { + query.andWhere('note.tsvector_embedding @@ websearch_to_tsquery(:q)', { q }); } else { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); + query.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }); + } - if (opts.userId) { - query.andWhere('note.userId = :userId', { userId: opts.userId }); - } else if (opts.channelId) { - query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); + if (opts.host) { + if (opts.host === '.') { + query.andWhere('note.userHost IS NULL'); + } else { + query.andWhere('note.userHost = :host', { host: opts.host }); } + } - query - .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + if (opts.filetype) { + query.andWhere('note."attachedFileTypes" && :types', { types: fileTypes[opts.filetype] }); + } - if (opts.host) { - if (opts.host === '.') { - query.andWhere('user.host IS NULL'); - } else { - query.andWhere('user.host = :host', { host: opts.host }); - } - } + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + + return await query.limit(pagination.limit).getMany(); + } - if (opts.filetype) { - /* this is very ugly, but the "correct" solution would - be `and exists (select 1 from - unnest(note."attachedFileTypes") x(t) where t like - :type)` and I can't find a way to get TypeORM to - generate that; this hack works because `~*` is - "regexp match, ignoring case" and the stringified - version of an array of varchars (which is what - `attachedFileTypes` is) looks like `{foo,bar}`, so - we're looking for opts.filetype as the first half of - a MIME type, either at start of the array (after the - `{`) or later (after a `,`) */ - query.andWhere(`note."attachedFileTypes"::varchar ~* :type`, { type: `[{,]${opts.filetype}/` }); + @bindThis + private async searchNoteByMeiliSearch( + q: string, + me: MiUser | null, + opts: SearchOpts, + pagination: SearchPagination, + ): Promise<MiNote[]> { + if (!this.meilisearch || !this.meilisearchNoteIndex) { + throw new Error('MeiliSearch is not available'); + } + + const filter: Q = { + op: 'and', + qs: [], + }; + if (pagination.untilId) filter.qs.push({ + op: '<', + k: 'createdAt', + v: this.idService.parse(pagination.untilId).date.getTime(), + }); + if (pagination.sinceId) filter.qs.push({ + op: '>', + k: 'createdAt', + v: this.idService.parse(pagination.sinceId).date.getTime(), + }); + if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); + if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); + if (opts.host) { + if (opts.host === '.') { + filter.qs.push({ op: 'is null', k: 'userHost' }); + } else { + filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); } + } - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + if (opts.filetype) { + const filters = fileTypes[opts.filetype].map(mime => ({ op: '=' as const, k: 'attachedFileTypes', v: mime })); + filter.qs.push({ op: 'or', qs: filters }); + } - return await query.limit(pagination.limit).getMany(); + const res = await this.meilisearchNoteIndex.search(q, { + sort: [`createdAt:${opts.order ? opts.order : 'desc'}`], + matchingStrategy: 'all', + attributesToRetrieve: ['id', 'createdAt'], + filter: compileQuery(filter), + limit: pagination.limit, + }); + if (res.hits.length === 0) { + return []; } + + const [ + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + ] = me + ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) + : [new Set<string>(), new Set<string>()]; + const notes = (await this.notesRepository.findBy({ + id: In(res.hits.map(x => x.id)), + })).filter(note => { + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + return true; + }); + + return notes.sort((a, b) => a.id > b.id ? -1 : 1); } } diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 0ad448e95f..9fc0c2b34a 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -162,3 +162,4 @@ export class SignupService { return { account, secret }; } } + diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts index de00169612..8239490adc 100644 --- a/packages/backend/src/core/SystemWebhookService.ts +++ b/packages/backend/src/core/SystemWebhookService.ts @@ -50,7 +50,6 @@ export type SystemWebhookPayload<T extends SystemWebhookEventType> = @Injectable() export class SystemWebhookService implements OnApplicationShutdown { - private logger: Logger; private activeSystemWebhooksFetched = false; private activeSystemWebhooks: MiSystemWebhook[] = []; @@ -62,11 +61,9 @@ export class SystemWebhookService implements OnApplicationShutdown { private idService: IdService, private queueService: QueueService, private moderationLogService: ModerationLogService, - private loggerService: LoggerService, private globalEventService: GlobalEventService, ) { this.redisForSub.on('message', this.onMessage); - this.logger = this.loggerService.getLogger('webhook'); } @bindThis @@ -193,28 +190,24 @@ export class SystemWebhookService implements OnApplicationShutdown { /** * SystemWebhook をWebhook配送キューに追加する * @see QueueService.systemWebhookDeliver - * // TODO: contentの型を厳格化する */ @bindThis public async enqueueSystemWebhook<T extends SystemWebhookEventType>( - webhook: MiSystemWebhook | MiSystemWebhook['id'], type: T, content: SystemWebhookPayload<T>, + opts?: { + excludes?: MiSystemWebhook['id'][]; + }, ) { - const webhookEntity = typeof webhook === 'string' - ? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook) - : webhook; - if (!webhookEntity || !webhookEntity.isActive) { - this.logger.info(`SystemWebhook is not active or not found : ${webhook}`); - return; - } - - if (!webhookEntity.on.includes(type)) { - this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`); - return; - } - - return this.queueService.systemWebhookDeliver(webhookEntity, type, content); + const webhooks = await this.fetchActiveSystemWebhooks() + .then(webhooks => { + return webhooks.filter(webhook => !opts?.excludes?.includes(webhook.id) && webhook.on.includes(type)); + }); + return Promise.all( + webhooks.map(webhook => { + return this.queueService.systemWebhookDeliver(webhook, type, content); + }), + ); } @bindThis diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 2f1310b8ef..8da1bb2092 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -118,13 +118,7 @@ export class UserBlockingService implements OnModuleInit { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packed }); }); } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 8963003057..b98ca97ec9 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -333,13 +333,7 @@ export class UserFollowingService implements OnModuleInit { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'follow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'follow', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'follow', { user: packed }); }); } @@ -347,13 +341,7 @@ export class UserFollowingService implements OnModuleInit { if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(async packed => { this.globalEventService.publishMainStream(followee.id, 'followed', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'followed', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(followee.id, 'followed', { user: packed }); }); // 通知を作成 @@ -400,13 +388,7 @@ export class UserFollowingService implements OnModuleInit { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packed }); }); } @@ -744,13 +726,7 @@ export class UserFollowingService implements OnModuleInit { }); this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'unfollow', { - user: packedFollowee, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packedFollowee }); } @bindThis diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 6333356fe9..4f4d59a02c 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -58,7 +58,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { } async onModuleInit() { - this.roleService = this.moduleRef.get(RoleService.name); + this.roleService = this.moduleRef.get('RoleService'); } @bindThis diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts index 9b1961c631..1f471513f3 100644 --- a/packages/backend/src/core/UserService.ts +++ b/packages/backend/src/core/UserService.ts @@ -63,13 +63,6 @@ export class UserService { @bindThis public async notifySystemWebhook(user: MiUser, type: 'userCreated') { const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' }); - const recipientWebhookIds = await this.systemWebhookService.fetchSystemWebhooks({ isActive: true, on: [type] }); - for (const webhookId of recipientWebhookIds) { - await this.systemWebhookService.enqueueSystemWebhook( - webhookId, - type, - packedUser, - ); - } + return this.systemWebhookService.enqueueSystemWebhook(type, packedUser); } } diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts index 911efdf768..08db4c9afc 100644 --- a/packages/backend/src/core/UserWebhookService.ts +++ b/packages/backend/src/core/UserWebhookService.ts @@ -5,13 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import { type WebhooksRepository } from '@/models/_.js'; +import { MiUser, type WebhooksRepository } from '@/models/_.js'; import { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { GlobalEvents } from '@/core/GlobalEventService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; export type UserWebhookPayload<T extends WebhookEventTypes> = T extends 'note' | 'reply' | 'renote' |'mention' | 'edited' ? { @@ -34,6 +35,7 @@ export class UserWebhookService implements OnApplicationShutdown { private redisForSub: Redis.Redis, @Inject(DI.webhooksRepository) private webhooksRepository: WebhooksRepository, + private queueService: QueueService, ) { this.redisForSub.on('message', this.onMessage); } @@ -75,6 +77,25 @@ export class UserWebhookService implements OnApplicationShutdown { return query.getMany(); } + /** + * UserWebhook をWebhook配送キューに追加する + * @see QueueService.userWebhookDeliver + */ + @bindThis + public async enqueueUserWebhook<T extends WebhookEventTypes>( + userId: MiUser['id'], + type: T, + content: UserWebhookPayload<T>, + ) { + const webhooks = await this.getActiveWebhooks() + .then(webhooks => webhooks.filter(webhook => webhook.userId === userId && webhook.on.includes(type))); + return Promise.all( + webhooks.map(webhook => { + return this.queueService.userWebhookDeliver(webhook, type, content); + }), + ); + } + @bindThis private async onMessage(_: string, data: string): Promise<void> { const obj = JSON.parse(data); diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index f905914022..81eaa5f95d 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { URL } from 'node:url'; -import punycode from 'punycode/punycode.js'; +import { URL, domainToASCII } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import RE2 from 're2'; import psl from 'psl'; @@ -107,13 +106,13 @@ export class UtilityService { @bindThis public toPuny(host: string): string { - return punycode.toASCII(host.toLowerCase()); + return domainToASCII(host.toLowerCase()); } @bindThis public toPunyNullable(host: string | null | undefined): string | null { if (host == null) return null; - return punycode.toASCII(host.toLowerCase()); + return domainToASCII(host.toLowerCase()); } @bindThis diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index ad53192f18..ed75e4f467 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -189,14 +189,12 @@ export class WebAuthnService { */ @bindThis public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise<MiUser['id'] | null> { - const challenge = await this.redisClient.get(`webauthn:challenge:${context}`); + const challenge = await this.redisClient.getdel(`webauthn:challenge:${context}`); if (!challenge) { throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`); } - await this.redisClient.del(`webauthn:challenge:${context}`); - const key = await this.userSecurityKeysRepository.findOneBy({ id: response.id, }); diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index dfe7a259c4..2e50f4472f 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -99,6 +99,8 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser { signupReason: null, noindex: false, enableRss: true, + mandatoryCW: null, + rejectQuotes: false, ...override, }; } @@ -142,6 +144,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote { renoteUserId: null, renoteUserHost: null, updatedAt: null, + processErrors: [], ...override, }; } @@ -216,6 +219,7 @@ function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<' isSystem: false, isSilenced: user.isSilenced, enableRss: true, + mandatoryCW: null, ...override, }; } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 278c97f907..1eef85aeef 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -363,10 +363,12 @@ export class ApInboxService { this.logger.info(`Creating the (Re)Note: ${uri}`); const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver); - const createdAt = activity.published ? new Date(activity.published) : null; + let createdAt = activity.published ? new Date(activity.published) : null; - if (createdAt && createdAt < this.idService.parse(renote.id).date) { - return 'skip: malformed createdAt'; + const renoteDate = this.idService.parse(renote.id).date; + if (createdAt && createdAt < renoteDate) { + this.logger.warn(`Correcting invalid publish time for Announce "${uri}"`); + createdAt = renoteDate; } await this.noteCreateService.create(actor, { diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index fb706a775f..cb9b74f6d7 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -28,6 +28,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { IdService } from '@/core/IdService.js'; +import { appendContentWarning } from '@/misc/append-content-warning.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; @@ -192,6 +193,9 @@ export class ApRendererService { // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, }, + _misskey_license: { + freeText: emoji.license, + }, }; } @@ -336,7 +340,7 @@ export class ApRendererService { } @bindThis - public async renderNote(note: MiNote, dive = true): Promise<IPost> { + public async renderNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> { const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => { if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); @@ -350,14 +354,14 @@ export class ApRendererService { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); if (inReplyToNote != null) { - const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } }); + const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - if (inReplyToUserExist) { + if (inReplyToUser) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; } else { if (dive) { - inReplyTo = await this.renderNote(inReplyToNote, false); + inReplyTo = await this.renderNote(inReplyToNote, inReplyToUser, false); } else { inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; } @@ -420,7 +424,12 @@ export class ApRendererService { apAppend += `\n\nRE: ${quote}`; } - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + // Apply mandatory CW, if applicable + if (author.mandatoryCW) { + summary = appendContentWarning(summary, author.mandatoryCW); + } const { content } = this.apMfmService.getNoteHtml(note, apAppend); @@ -633,7 +642,7 @@ export class ApRendererService { } @bindThis - public async renderUpNote(note: MiNote, dive = true): Promise<IPost> { + public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> { const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => { if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); @@ -647,14 +656,14 @@ export class ApRendererService { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); if (inReplyToNote != null) { - const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } }); + const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - if (inReplyToUserExist) { + if (inReplyToUser) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; } else { if (dive) { - inReplyTo = await this.renderUpNote(inReplyToNote, false); + inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false); } else { inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; } @@ -717,7 +726,12 @@ export class ApRendererService { apAppend += `\n\nRE: ${quote}`; } - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + // Apply mandatory CW, if applicable + if (author.mandatoryCW) { + summary = appendContentWarning(summary, author.mandatoryCW); + } const { content } = this.apMfmService.getNoteHtml(note, apAppend); diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 8036c9638f..b63d4eb2ab 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -11,13 +11,12 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { UtilityService } from '@/core/UtilityService.js'; +import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js'; import type { IObject } from './type.js'; type Request = { @@ -148,7 +147,7 @@ export class ApRequestService { private userKeypairService: UserKeypairService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, - private utilityService: UtilityService, + private readonly apUtilityService: ApUtilityService, ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる @@ -183,9 +182,10 @@ export class ApRequestService { * Get AP object with http-signature * @param user http-signature user * @param url URL to fetch + * @param followAlternate */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> { + public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObject> { const _followAlternate = followAlternate ?? true; const keypair = await this.userKeypairService.getUserKeypair(user.id); @@ -239,13 +239,22 @@ export class ApRequestService { try { document.documentElement.innerHTML = html; - const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); + // Search for any matching value in priority order: + // 1. Type=AP > Type=none > Type=anything + // 2. Alternate > Canonical + // 3. Page order (fallback) + const alternate = + document.querySelector('head > link[href][rel="alternate"][type="application/activity+json"]') ?? + document.querySelector('head > link[href][rel="canonical"][type="application/activity+json"]') ?? + document.querySelector('head > link[href][rel="alternate"]:not([type])') ?? + document.querySelector('head > link[href][rel="canonical"]:not([type])') ?? + document.querySelector('head > link[href][rel="alternate"]') ?? + document.querySelector('head > link[href][rel="canonical"]'); + if (alternate) { const href = alternate.getAttribute('href'); - if (href) { - if (this.utilityService.punyHostPSLDomain(url) === this.utilityService.punyHostPSLDomain(href)) { - return await this.signedGet(href, user, false); - } + if (href && this.apUtilityService.haveSameAuthority(url, href)) { + return await this.signedGet(href, user, false); } } } catch (e) { @@ -258,10 +267,11 @@ export class ApRequestService { validateContentTypeSetAsActivityPub(res); - const finalUrl = res.url; // redirects may have been involved const activity = await res.json() as IObject; - assertActivityMatchesUrls(activity, [finalUrl]); + // Make sure the object ID matches the final URL (which is where it actually exists). + // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); return activity; } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index c82a9be3b1..f9ccf10fa7 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -5,10 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; -import { UnrecoverableError } from 'bullmq'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; +import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { DI } from '@/di-symbols.js'; @@ -17,7 +16,10 @@ import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { fromTuple } from '@/misc/from-tuple.js'; -import { isCollectionOrOrderedCollection } from './type.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { ApLogService, calculateDurationSince, extractObjectContext } from '@/core/ApLogService.js'; +import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; +import { getApId, getNullableApId, isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; @@ -43,6 +45,8 @@ export class Resolver { private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, + private readonly apLogService: ApLogService, + private readonly apUtilityService: ApUtilityService, private recursionLimit = 256, ) { this.history = new Set(); @@ -68,7 +72,7 @@ export class Resolver { if (isCollectionOrOrderedCollection(collection)) { return collection; } else { - throw new UnrecoverableError(`unrecognized collection type: ${collection.type}`); + throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`); } } @@ -81,30 +85,67 @@ export class Resolver { return value; } + const host = this.utilityService.extractDbHost(value); + if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) { + return await this._resolveLogged(value, host); + } else { + return await this._resolve(value, host); + } + } + + private async _resolveLogged(requestUri: string, host: string): Promise<IObject> { + const startTime = process.hrtime.bigint(); + + const log = await this.apLogService.createFetchLog({ + host: host, + requestUri, + }); + + try { + const result = await this._resolve(requestUri, host, log); + + log.accepted = true; + log.result = 'ok'; + + return result; + } catch (err) { + log.accepted = false; + log.result = String(err); + + throw err; + } finally { + log.duration = calculateDurationSince(startTime); + + // Save or finalize asynchronously + this.apLogService.saveFetchLog(log) + .catch(err => this.logger.error('Failed to record AP object fetch:', err)); + } + } + + private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise<IObject> { if (value.includes('#')) { // URLs with fragment parts cannot be resolved correctly because // the fragment part does not get transmitted over HTTP(S). // Avoid strange behaviour by not trying to resolve these at all. - throw new UnrecoverableError(`cannot resolve URL with fragment: ${value}`); + throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`); } if (this.history.has(value)) { - throw new Error(`cannot resolve already resolved URL: ${value}`); + throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `cannot resolve already resolved URL: ${value}`); } if (this.history.size > this.recursionLimit) { - throw new Error(`hit recursion limit: ${value}`); + throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${value}`); } this.history.add(value); - const host = this.utilityService.extractDbHost(value); if (this.utilityService.isSelfHost(host)) { return await this.resolveLocal(value); } if (!this.utilityService.isFederationAllowedHost(host)) { - throw new UnrecoverableError(`cannot fetch AP object ${value}: blocked instance ${host}`); + throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `cannot fetch AP object ${value}: blocked instance ${host}`); } if (this.config.signToActivityPubGet && !this.user) { @@ -115,32 +156,42 @@ export class Resolver { ? await this.apRequestService.signedGet(value, this.user) as IObject : await this.httpRequestService.getActivityJson(value)) as IObject; + if (log) { + const { object: objectOnly, context, contextHash } = extractObjectContext(object); + const objectUri = getNullableApId(object); + + if (objectUri) { + log.objectUri = objectUri; + log.host = this.utilityService.extractDbHost(objectUri); + } + + log.object = objectOnly; + log.context = context; + log.contextHash = contextHash; + } + if ( Array.isArray(object['@context']) ? !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : object['@context'] !== 'https://www.w3.org/ns/activitystreams' ) { - throw new UnrecoverableError(`invalid AP object ${value}: does not have ActivityStreams context`); + throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `invalid AP object ${value}: does not have ActivityStreams context`); } - // Since redirects are allowed, we cannot safely validate an anonymous object. - // Reject any responses without an ID, as all other checks depend on that value. - if (object.id == null) { - throw new UnrecoverableError(`invalid AP object ${value}: missing id`); - } + // The object ID is already validated to match the final URL's authority by signedGet / getActivityJson. + // We only need to validate that it also matches the original URL's authority, in case of redirects. + const objectId = getApId(object); // We allow some limited cross-domain redirects, which means the host may have changed during fetch. // Additional checks are needed to validate the scope of cross-domain redirects. - const finalHost = this.utilityService.extractDbHost(object.id); + const finalHost = this.utilityService.extractDbHost(objectId); if (finalHost !== host) { // Make sure the redirect stayed within the same authority. - if (this.utilityService.punyHostPSLDomain(object.id) !== this.utilityService.punyHostPSLDomain(value)) { - throw new UnrecoverableError(`invalid AP object ${value}: id ${object.id} has different host`); - } + this.apUtilityService.assertIdMatchesUrlAuthority(object, value); // Check if the redirect bounce from [allowed domain] to [blocked domain]. if (!this.utilityService.isFederationAllowedHost(finalHost)) { - throw new UnrecoverableError(`cannot fetch AP object ${value}: redirected to blocked instance ${finalHost}`); + throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `cannot fetch AP object ${value}: redirected to blocked instance ${finalHost}`); } } @@ -150,17 +201,18 @@ export class Resolver { @bindThis private resolveLocal(url: string): Promise<IObject> { const parsed = this.apDbResolverService.parseUri(url); - if (!parsed.local) throw new UnrecoverableError(`resolveLocal - not a local URL: ${url}`); + if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `resolveLocal - not a local URL: ${url}`); switch (parsed.type) { case 'notes': return this.notesRepository.findOneByOrFail({ id: parsed.id }) .then(async note => { + const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); if (parsed.rest === 'activity') { // this refers to the create activity and not the note itself - return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note), note)); + return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note)); } else { - return this.apRendererService.renderNote(note); + return this.apRendererService.renderNote(note, author); } }); case 'users': @@ -179,7 +231,7 @@ export class Resolver { case 'follows': return this.followRequestsRepository.findOneBy({ id: parsed.id }) .then(async followRequest => { - if (followRequest == null) throw new UnrecoverableError(`resolveLocal - invalid follow request ID ${parsed.id}: ${url}`); + if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `resolveLocal - invalid follow request ID ${parsed.id}: ${url}`); const [follower, followee] = await Promise.all([ this.usersRepository.findOneBy({ id: followRequest.followerId, @@ -191,12 +243,12 @@ export class Resolver { }), ]); if (follower == null || followee == null) { - throw new Error(`resolveLocal - follower or followee does not exist: ${url}`); + throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `resolveLocal - follower or followee does not exist: ${url}`); } return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url)); }); default: - throw new UnrecoverableError(`resolveLocal: type ${parsed.type} unhandled: ${url}`); + throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled: ${url}`); } } } @@ -232,6 +284,8 @@ export class ApResolverService { private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, + private readonly apLogService: ApLogService, + private readonly apUtilityService: ApUtilityService, ) { } @@ -252,6 +306,8 @@ export class ApResolverService { this.apRendererService, this.apDbResolverService, this.loggerService, + this.apLogService, + this.apUtilityService, ); } } diff --git a/packages/backend/src/core/activitypub/ApUtilityService.ts b/packages/backend/src/core/activitypub/ApUtilityService.ts new file mode 100644 index 0000000000..ae6e4997e4 --- /dev/null +++ b/packages/backend/src/core/activitypub/ApUtilityService.ts @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { UtilityService } from '@/core/UtilityService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { EnvService } from '@/core/EnvService.js'; +import { getApId, getOneApHrefNullable, IObject } from './type.js'; + +@Injectable() +export class ApUtilityService { + constructor( + private readonly utilityService: UtilityService, + private readonly envService: EnvService, + ) {} + + /** + * Verifies that the object's ID has the same authority as the provided URL. + * Returns on success, throws on any validation error. + */ + public assertIdMatchesUrlAuthority(object: IObject, url: string): void { + // This throws if the ID is missing or invalid, but that's ok. + // Anonymous objects are impossible to verify, so we don't allow fetching them. + const id = getApId(object); + + // Make sure the object ID matches the final URL (which is where it actually exists). + // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. + if (!this.haveSameAuthority(url, id)) { + throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${url}: id ${id} has different host authority`); + } + } + + /** + * Checks if two URLs have the same host authority + */ + public haveSameAuthority(url1: string, url2: string): boolean { + if (url1 === url2) return true; + + const authority1 = this.utilityService.punyHostPSLDomain(url1); + const authority2 = this.utilityService.punyHostPSLDomain(url2); + return authority1 === authority2; + } + + /** + * Finds the "best" URL for a given AP object. + * The list of URLs is first filtered via findSameAuthorityUrl, then further filtered based on mediaType, and finally sorted to select the best one. + * @throws {IdentifiableError} if object does not have an ID + * @returns the best URL, or null if none were found + */ + public findBestObjectUrl(object: IObject): string | null { + const targetUrl = getApId(object); + const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl); + + const rawUrls = toArray(object.url); + const acceptableUrls = rawUrls + .map(raw => ({ + url: getOneApHrefNullable(raw), + type: typeof(raw) === 'object' + ? raw.mediaType?.toLowerCase() + : undefined, + })) + .filter(({ url, type }) => { + if (!url) return false; + if (!this.checkHttps(url)) return false; + if (!isAcceptableUrlType(type)) return false; + + const urlAuthority = this.utilityService.punyHostPSLDomain(url); + return urlAuthority === targetAuthority; + }) + .sort((a, b) => { + return rankUrlType(a.type) - rankUrlType(b.type); + }); + + return acceptableUrls[0]?.url ?? null; + } + + /** + * Checks if the URL contains HTTPS. + * Additionally, allows HTTP in non-production environments. + * Based on check-https.ts. + */ + private checkHttps(url: string): boolean { + const isNonProd = this.envService.env.NODE_ENV !== 'production'; + + // noinspection HttpUrlsUsage + return url.startsWith('https://') || (url.startsWith('http://') && isNonProd); + } +} + +function isAcceptableUrlType(type: string | undefined): boolean { + if (!type) return true; + if (type.startsWith('text/')) return true; + if (type.startsWith('application/ld+json')) return true; + if (type.startsWith('application/activity+json')) return true; + return false; +} + +function rankUrlType(type: string | undefined): number { + if (!type) return 2; + if (type === 'text/html') return 0; + if (type.startsWith('text/')) return 1; + if (type.startsWith('application/ld+json')) return 3; + if (type.startsWith('application/activity+json')) return 4; + return 5; +} diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts deleted file mode 100644 index edfab5a216..0000000000 --- a/packages/backend/src/core/activitypub/misc/check-against-url.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: dakkar and sharkey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { UnrecoverableError } from 'bullmq'; -import type { IObject } from '../type.js'; - -function getHrefsFrom(one: IObject | string | undefined | (IObject | string | undefined)[]): (string | undefined)[] { - if (Array.isArray(one)) { - return one.flatMap(h => getHrefsFrom(h)); - } - return [ - typeof(one) === 'object' ? one.href : one, - ]; -} - -export function assertActivityMatchesUrls(activity: IObject, urls: string[]) { - const expectedUrls = new Set(urls - .filter(u => URL.canParse(u)) - .map(u => new URL(u).href), - ); - - const actualUrls = [activity.id, ...getHrefsFrom(activity.url)] - .filter(u => u && URL.canParse(u)) - .map(u => new URL(u as string).href); - - if (!actualUrls.some(u => expectedUrls.has(u))) { - throw new UnrecoverableError(`bad Activity: neither id(${activity.id}) nor url(${JSON.stringify(activity.url)}) match location(${urls})`); - } -} diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index d7b6fc6589..5c0b8ffcbb 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -561,6 +561,11 @@ const extension_context_definition = { '_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents', '_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore', '_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore', + '_misskey_license': 'misskey:_misskey_license', + 'freeText': { + '@id': 'misskey:freeText', + '@type': 'schema:text', + }, 'isCat': 'misskey:isCat', // Firefish firefish: 'https://joinfirefish.org/ns#', diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index e4c4fe54b5..63f9887a8d 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -25,12 +25,14 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; +import { isRetryableError } from '@/misc/is-retryable-error.js'; +import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; import { ApDbResolverService } from '../ApDbResolverService.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApAudienceService } from '../ApAudienceService.js'; +import { ApUtilityService } from '../ApUtilityService.js'; import { ApPersonService } from './ApPersonService.js'; import { extractApHashtags } from './tag.js'; import { ApMentionService } from './ApMentionService.js'; @@ -81,6 +83,7 @@ export class ApNoteService { private noteEditService: NoteEditService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, + private readonly apUtilityService: ApUtilityService, ) { this.logger = this.apLoggerService.logger; } @@ -91,7 +94,6 @@ export class ApNoteService { uri: string, actor?: MiRemoteUser, user?: MiRemoteUser, - note?: MiNote, ): Error | null { const expectHost = this.utilityService.extractDbHost(uri); const apType = getApType(object); @@ -123,13 +125,6 @@ export class ApNoteService { } } - if (note) { - const url = (object.url) ? getOneApId(object.url) : note.url; - if (url && url !== note.url) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated url does not match original url. updated url: ${url}, original url: ${note.url}`); - } - } - return null; } @@ -185,17 +180,7 @@ export class ApNoteService { throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`); } - const url = getOneApHrefNullable(note.url); - - if (url != null) { - if (!checkHttps(url)) { - throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${entryUri}`); - } - - if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) { - throw new UnrecoverableError(`note url <> uri host mismatch: ${url} <> ${note.id} in ${entryUri}`); - } - } + const url = this.apUtilityService.findBestObjectUrl(note); this.logger.info(`Creating the Note: ${note.id}`); @@ -270,6 +255,14 @@ export class ApNoteService { if (file) files.push(file); } + // Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment" + const icon = getBestIcon(note); + if (icon) { + icon.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, icon); + if (file) files.push(file); + } + // リプライ const reply: MiNote | null = note.inReplyTo ? await this.resolveNote(note.inReplyTo, { resolver }) @@ -288,44 +281,8 @@ export class ApNoteService { : null; // 引用 - let quote: MiNote | undefined | null = null; - - if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { - const tryResolveNote = async (uri: unknown): Promise< - | { status: 'ok'; res: MiNote } - | { status: 'permerror' | 'temperror' } - > => { - if (typeof uri !== 'string' || !/^https?:/.test(uri)) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`); - return { status: 'permerror' }; - } - try { - const res = await this.resolveNote(uri, { resolver }); - if (res == null) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`); - return { status: 'permerror' }; - } - return { status: 'ok', res }; - } catch (e) { - const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`); - - return { - status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror', - }; - } - }; - - const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null)); - const results = await Promise.all(uris.map(tryResolveNote)); - - quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw new Error(`temporary error resolving quote for ${entryUri}`); - } - } - } + const quote = await this.getQuote(note, entryUri, resolver); + const processErrors = quote === null ? ['quoteUnavailable'] : null; // vote if (reply && reply.hasPoll) { @@ -361,7 +318,8 @@ export class ApNoteService { createdAt: note.published ? new Date(note.published) : null, files, reply, - renote: quote, + renote: quote ?? null, + processErrors, name: note.name, cw, text, @@ -411,7 +369,7 @@ export class ApNoteService { const object = await resolver.resolve(value); const entryUri = getApId(value); - const err = this.validateNote(object, entryUri, actor, user, updatedNote); + const err = this.validateNote(object, entryUri, actor, user); if (err) { this.logger.error(err.message, { resolver: { history: resolver.getHistory() }, @@ -437,17 +395,7 @@ export class ApNoteService { throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`); } - const url = getOneApHrefNullable(note.url); - - if (url != null) { - if (!checkHttps(url)) { - throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${noteUri}`); - } - - if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) { - throw new UnrecoverableError(`note url <> id host mismatch: ${url} <> ${note.id} in ${noteUri}`); - } - } + const url = this.apUtilityService.findBestObjectUrl(note); this.logger.info(`Creating the Note: ${note.id}`); @@ -504,6 +452,14 @@ export class ApNoteService { if (file) files.push(file); } + // Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment" + const icon = getBestIcon(note); + if (icon) { + icon.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, icon); + if (file) files.push(file); + } + // リプライ const reply: MiNote | null = note.inReplyTo ? await this.resolveNote(note.inReplyTo, { resolver }) @@ -522,44 +478,8 @@ export class ApNoteService { : null; // 引用 - let quote: MiNote | undefined | null = null; - - if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { - const tryResolveNote = async (uri: unknown): Promise< - | { status: 'ok'; res: MiNote } - | { status: 'permerror' | 'temperror' } - > => { - if (typeof uri !== 'string' || !/^https?:/.test(uri)) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`); - return { status: 'permerror' }; - } - try { - const res = await this.resolveNote(uri, { resolver }); - if (res == null) { - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`); - return { status: 'permerror' }; - } - return { status: 'ok', res }; - } catch (e) { - const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`); - - return { - status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror', - }; - } - }; - - const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null)); - const results = await Promise.all(uris.map(tryResolveNote)); - - quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw new Error(`temporary error resolving quote for ${entryUri}`); - } - } - } + const quote = await this.getQuote(note, entryUri, resolver); + const processErrors = quote === null ? ['quoteUnavailable'] : null; // vote if (reply && reply.hasPoll) { @@ -595,7 +515,8 @@ export class ApNoteService { createdAt: note.published ? new Date(note.published) : null, files, reply, - renote: quote, + renote: quote ?? null, + processErrors, name: note.name, cw, text, @@ -690,6 +611,8 @@ export class ApNoteService { originalUrl: tag.icon.url, publicUrl: tag.icon.url, updatedAt: new Date(), + // _misskey_license が存在しなければ `null` + license: (tag._misskey_license?.freeText ?? null), }); const emoji = await this.emojisRepository.findOneBy({ host, name }); @@ -711,7 +634,87 @@ export class ApNoteService { publicUrl: tag.icon.url, updatedAt: new Date(), aliases: [], + // _misskey_license が存在しなければ `null` + license: (tag._misskey_license?.freeText ?? null) }); })); } + + /** + * Fetches the note's quoted post. + * On success - returns the note. + * On skip (no quote) - returns undefined. + * On permanent error - returns null. + * On temporary error - throws an exception. + */ + private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise<MiNote | null | undefined> { + const quoteUris = new Set<string>(); + if (note._misskey_quote) quoteUris.add(note._misskey_quote); + if (note.quoteUrl) quoteUris.add(note.quoteUrl); + if (note.quoteUri) quoteUris.add(note.quoteUri); + + // No quote, return undefined + if (quoteUris.size < 1) return undefined; + + /** + * Attempts to resolve a quote by URI. + * Returns the note if successful, true if there's a retryable error, and false if there's a permanent error. + */ + const resolveQuote = async (uri: unknown): Promise<MiNote | boolean> => { + if (typeof(uri) !== 'string' || !/^https?:/.test(uri)) { + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": URI is invalid`); + return false; + } + + try { + const quote = await this.resolveNote(uri, { resolver }); + + if (quote == null) { + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`); + return false; + } + + return quote; + } catch (e) { + if (e instanceof Error) { + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}":`, e); + } else { + this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${e}`); + } + + return isRetryableError(e); + } + }; + + const results = await Promise.all(Array.from(quoteUris).map(u => resolveQuote(u))); + + // Success - return the quote + const quote = results.find(r => typeof(r) === 'object'); + if (quote) return quote; + + // Temporary / retryable error - throw error + const tempError = results.find(r => r === true); + if (tempError) throw new Error(`temporary error resolving quote for "${entryUri}"`); + + // Permanent error - return null + return null; + } +} + +function getBestIcon(note: IObject): IObject | null { + const icons: IObject[] = toArray(note.icon); + if (icons.length < 2) { + return icons[0] ?? null; + } + + return icons.reduce((best, i) => { + if (!isApObject(i)) return best; + if (!isDocument(i)) return best; + if (!best) return i; + if (!best.width || !best.height) return i; + if (!i.width || !i.height) return best; + if (i.width > best.width) return i; + if (i.height > best.height) return i; + return best; + }, null as IApDocument | null) ?? null; } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 5c71dbc626..da29a3c527 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -39,8 +39,8 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; -import { checkHttps } from '@/misc/check-https.js'; -import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; +import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; +import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; import type { ApNoteService } from './ApNoteService.js'; @@ -106,6 +106,7 @@ export class ApPersonService implements OnModuleInit { private followingsRepository: FollowingsRepository, private roleService: RoleService, + private readonly apUtilityService: ApUtilityService, ) { } @@ -346,21 +347,11 @@ export class ApPersonService implements OnModuleInit { const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - const url = getOneApHrefNullable(person.url); - if (person.id == null) { throw new UnrecoverableError(`Refusing to create person without id: ${uri}`); } - if (url != null) { - if (!checkHttps(url)) { - throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`); - } - - if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) { - throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`); - } - } + const url = this.apUtilityService.findBestObjectUrl(person); // Create user let user: MiRemoteUser | null = null; @@ -398,7 +389,7 @@ export class ApPersonService implements OnModuleInit { alsoKnownAs: person.alsoKnownAs, // We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic. hideOnlineStatus: person.hideOnlineStatus !== false, - isExplorable: person.discoverable, + isExplorable: person.discoverable !== false, username: person.preferredUsername, approved: true, usernameLower: person.preferredUsername?.toLowerCase(), @@ -447,7 +438,7 @@ export class ApPersonService implements OnModuleInit { await transactionalEntityManager.save(new MiUserPublickey({ userId: user.id, keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, + keyPem: person.publicKey.publicKeyPem.trim(), })); } }); @@ -566,21 +557,11 @@ export class ApPersonService implements OnModuleInit { const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - const url = getOneApHrefNullable(person.url); - if (person.id == null) { throw new UnrecoverableError(`Refusing to update person without id: ${uri}`); } - if (url != null) { - if (!checkHttps(url)) { - throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`); - } - - if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) { - throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`); - } - } + const url = this.apUtilityService.findBestObjectUrl(person); const updates = { lastFetchedAt: new Date(), @@ -602,7 +583,7 @@ export class ApPersonService implements OnModuleInit { alsoKnownAs: person.alsoKnownAs ?? null, // We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic. hideOnlineStatus: person.hideOnlineStatus !== false, - isExplorable: person.discoverable, + isExplorable: person.discoverable !== false, ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))), } as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index d67f8cf62e..d8e7b3c9c3 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { UnrecoverableError } from 'bullmq'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { fromTuple } from '@/misc/from-tuple.js'; export type Obj = { [x: string]: any }; @@ -65,7 +65,7 @@ export function getApId(value: string | IObject | [string | IObject]): string { if (typeof value === 'string') return value; if (typeof value.id === 'string') return value.id; - throw new UnrecoverableError('cannot determine id'); + throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`); } /** @@ -202,7 +202,7 @@ export interface IActor extends IObject { manuallyApprovesFollowers?: boolean; movedTo?: string; alsoKnownAs?: string[]; - discoverable?: boolean; + discoverable?: boolean | null; inbox: string; sharedInbox?: string; // 後方互換性のため publicKey?: { @@ -270,6 +270,11 @@ export interface IApEmoji extends IObject { type: 'Emoji'; name: string; updated: string; + // Misskey拡張。後方互換性のためにoptional。 + // 将来の拡張性を考慮してobjectにしている + _misskey_license?: { + freeText: string | null; + }; } export const isEmoji = (object: IObject): object is IApEmoji => @@ -285,6 +290,8 @@ export const validDocumentTypes = ['Audio', 'Document', 'Image', 'Page', 'Video' export interface IApDocument extends IObject { type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video'; + width?: number; + height?: number; } export const isDocument = (object: IObject): object is IApDocument => { diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index ef65af2432..81495c8a6c 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -41,7 +41,7 @@ export class ChartManagementService implements OnApplicationShutdown { private perUserFollowingChart: PerUserFollowingChart, private perUserDriveChart: PerUserDriveChart, private apRequestChart: ApRequestChart, - private chartLoggerService: ChartLoggerService, + chartLoggerService: ChartLoggerService, ) { this.charts = [ this.federationChart, diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 841bd731c0..490d3f2511 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -4,10 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository } from '@/models/_.js'; +import type { EmojisRepository, MiRole, RolesRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; import type { MiEmoji } from '@/models/Emoji.js'; import { bindThis } from '@/decorators.js'; @@ -16,6 +16,8 @@ export class EmojiEntityService { constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, ) { } @@ -68,8 +70,90 @@ export class EmojiEntityService { @bindThis public packDetailedMany( emojis: any[], - ) { + ): Promise<Packed<'EmojiDetailed'>[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } + + @bindThis + public async packDetailedAdmin( + src: MiEmoji['id'] | MiEmoji, + hint?: { + roles?: Map<MiRole['id'], MiRole> + }, + ): Promise<Packed<'EmojiDetailedAdmin'>> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + const roles = Array.of<MiRole>(); + if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0) { + if (hint?.roles) { + const hintRoles = hint.roles; + roles.push( + ...emoji.roleIdsThatCanBeUsedThisEmojiAsReaction + .filter(x => hintRoles.has(x)) + .map(x => hintRoles.get(x)!), + ); + } else { + roles.push( + ...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }), + ); + } + + roles.sort((a, b) => { + if (a.displayOrder !== b.displayOrder) { + return b.displayOrder - a.displayOrder; + } + + return a.id.localeCompare(b.id); + }); + } + + return { + id: emoji.id, + updatedAt: emoji.updatedAt?.toISOString() ?? null, + name: emoji.name, + host: emoji.host, + uri: emoji.uri, + type: emoji.type, + aliases: emoji.aliases, + category: emoji.category, + publicUrl: emoji.publicUrl, + originalUrl: emoji.originalUrl, + license: emoji.license, + localOnly: emoji.localOnly, + isSensitive: emoji.isSensitive, + roleIdsThatCanBeUsedThisEmojiAsReaction: roles.map(it => ({ id: it.id, name: it.name })), + }; + } + + @bindThis + public async packDetailedAdminMany( + emojis: MiEmoji['id'][] | MiEmoji[], + hint?: { + roles?: Map<MiRole['id'], MiRole> + }, + ): Promise<Packed<'EmojiDetailedAdmin'>[]> { + // IDのみの要素をピックアップし、DBからレコードを取り出して他の値を補完する + const emojiEntities = emojis.filter(x => typeof x === 'object') as MiEmoji[]; + const emojiIdOnlyList = emojis.filter(x => typeof x === 'string') as string[]; + if (emojiIdOnlyList.length > 0) { + emojiEntities.push(...await this.emojisRepository.findBy({ id: In(emojiIdOnlyList) })); + } + + // 特定ロール専用の絵文字である場合、そのロール情報をあらかじめまとめて取得しておく(pack側で都度取得も出来るが負荷が高いので) + let hintRoles: Map<MiRole['id'], MiRole>; + if (hint?.roles) { + hintRoles = hint.roles; + } else { + const roles = Array.of<MiRole>(); + const roleIds = [...new Set(emojiEntities.flatMap(x => x.roleIdsThatCanBeUsedThisEmojiAsReaction))]; + if (roleIds.length > 0) { + roles.push(...await this.rolesRepository.findBy({ id: In(roleIds) })); + } + + hintRoles = new Map(roles.map(x => [x.id, x])); + } + + return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles }))); + } } diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 63e5923255..fcc9bed3bd 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -60,6 +60,7 @@ export class InstanceEntityService { latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, isNSFW: instance.isNSFW, rejectReports: instance.rejectReports, + rejectQuotes: instance.rejectQuotes, moderationNote: iAmModerator ? instance.moderationNote : null, }; } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 7d7b4cbd81..84d591ce7a 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -95,6 +95,7 @@ export class MetaEntityService { mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl, enableRecaptcha: instance.enableRecaptcha, enableAchievements: instance.enableAchievements, + robotsTxt: instance.robotsTxt, recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, @@ -144,6 +145,7 @@ export class MetaEntityService { enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', maxFileSize: this.config.maxFileSize, + federation: this.meta.federation, }; return packed; @@ -184,3 +186,4 @@ export class MetaEntityService { return packDetailed; } } + diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index eb6b353752..537677ed34 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -23,7 +23,6 @@ import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; -import type { Config } from '@/config.js'; // is-renote.tsとよしなにリンク function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } { @@ -42,8 +41,14 @@ function getAppearNoteIds(notes: MiNote[]): Set<string> { for (const note of notes) { if (isPureRenote(note)) { appearNoteIds.add(note.renoteId); + if (note.renote?.replyId) { + appearNoteIds.add(note.renote.replyId); + } } else { appearNoteIds.add(note.id); + if (note.replyId) { + appearNoteIds.add(note.replyId); + } } } return appearNoteIds; @@ -69,9 +74,6 @@ export class NoteEntityService implements OnModuleInit { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.config) - private config: Config, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -110,8 +112,7 @@ export class NoteEntityService implements OnModuleInit { } @bindThis - private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> { - // FIXME: このvisibility変更処理が当関数にあるのは若干不自然かもしれない(関数名を treatVisibility とかに変える手もある) + private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] { if (packedNote.visibility === 'public' || packedNote.visibility === 'home') { const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore; if ((followersOnlyBefore != null) @@ -123,7 +124,11 @@ export class NoteEntityService implements OnModuleInit { packedNote.visibility = 'followers'; } } + return packedNote.visibility; + } + @bindThis + public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> { if (meId === packedNote.userId) return; // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) @@ -272,7 +277,9 @@ export class NoteEntityService implements OnModuleInit { const reaction = _hint_.myReactions.get(note.id); if (reaction) { return this.reactionService.convertLegacyReaction(reaction); - } else { + } else if (reaction === null) { + // the hints explicitly say this note has no reactions from + // this user return undefined; } } @@ -483,6 +490,7 @@ export class NoteEntityService implements OnModuleInit { ...(opts.detail ? { clippedCount: note.clippedCount, + processErrors: note.processErrors, reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { detail: false, @@ -500,6 +508,8 @@ export class NoteEntityService implements OnModuleInit { } : {}), }); + this.treatVisibility(packed); + if (!opts.skipHide) { await this.hideNote(packed, meId); } @@ -525,44 +535,39 @@ export class NoteEntityService implements OnModuleInit { if (meId) { const idsNeedFetchMyReaction = new Set<MiNote['id']>(); - // パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない - const oldId = this.idService.gen(Date.now() - 2000); - + const targetNotes: MiNote[] = []; for (const note of notes) { if (isPureRenote(note)) { - const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); - if (reactionsCount === 0) { - myReactionsMap.set(note.renote.id, null); - } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferedReactions?.get(note.renote.id)?.pairs.length ?? 0)) { - const pairInBuffer = bufferedReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId); - if (pairInBuffer) { - myReactionsMap.set(note.renote.id, pairInBuffer[1]); - } else { - const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); - } - } else { - idsNeedFetchMyReaction.add(note.renote.id); + // 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.id < oldId) { - const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); - if (reactionsCount === 0) { - myReactionsMap.set(note.id, null); - } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { - const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); - if (pairInBuffer) { - myReactionsMap.set(note.id, pairInBuffer[1]); - } else { - const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); - } - } else { - idsNeedFetchMyReaction.add(note.id); - } + 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) { + myReactionsMap.set(note.id, null); + } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); + if (pairInBuffer) { + myReactionsMap.set(note.id, pairInBuffer[1]); } else { - myReactionsMap.set(note.id, null); + const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); + myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); } + } else { + idsNeedFetchMyReaction.add(note.id); } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 6bfe865038..96fef863a0 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -49,11 +49,13 @@ import { IdService } from '@/core/IdService.js'; import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { isSystemAccount } from '@/misc/is-system-account.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; -import { isSystemAccount } from '@/misc/is-system-account.js'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ const Ajv = _Ajv.default; const ajv = new Ajv(); @@ -81,6 +83,8 @@ export type UserRelation = { isBlocked: boolean isMuted: boolean isRenoteMuted: boolean + isInstanceMuted?: boolean + memo?: string | null } @Injectable() @@ -180,6 +184,9 @@ export class UserEntityService implements OnModuleInit { isBlocked, isMuted, isRenoteMuted, + host, + memo, + mutedInstances, ] = await Promise.all([ this.followingsRepository.findOneBy({ followerId: me, @@ -227,8 +234,25 @@ export class UserEntityService implements OnModuleInit { muteeId: target, }, }), + this.usersRepository.createQueryBuilder('u') + .select('u.host') + .where({ id: target }) + .getRawOne<{ u_host: string }>() + .then(it => it?.u_host ?? null), + this.userMemosRepository.createQueryBuilder('m') + .select('m.memo') + .where({ userId: me, targetUserId: target }) + .getRawOne<{ m_memo: string | null }>() + .then(it => it?.m_memo ?? null), + this.userProfilesRepository.createQueryBuilder('p') + .select('p.mutedInstances') + .where({ userId: me }) + .getRawOne<{ p_mutedInstances: string[] }>() + .then(it => it?.p_mutedInstances ?? []), ]); + const isInstanceMuted = !!host && mutedInstances.includes(host); + return { id: target, following, @@ -240,6 +264,8 @@ export class UserEntityService implements OnModuleInit { isBlocked, isMuted, isRenoteMuted, + isInstanceMuted, + memo, }; } @@ -254,6 +280,9 @@ export class UserEntityService implements OnModuleInit { blockees, muters, renoteMuters, + hosts, + memos, + mutedInstances, ] = await Promise.all([ this.followingsRepository.findBy({ followerId: me }) .then(f => new Map(f.map(it => [it.followeeId, it]))), @@ -292,6 +321,27 @@ export class UserEntityService implements OnModuleInit { .where('m.muterId = :me', { me }) .getRawMany<{ m_muteeId: string }>() .then(it => it.map(it => it.m_muteeId)), + this.usersRepository.createQueryBuilder('u') + .select(['u.id', 'u.host']) + .where({ id: In(targets) } ) + .getRawMany<{ m_id: string, m_host: string }>() + .then(it => it.reduce((map, it) => { + map[it.m_id] = it.m_host; + return map; + }, {} as Record<string, string>)), + this.userMemosRepository.createQueryBuilder('m') + .select(['m.targetUserId', 'm.memo']) + .where({ userId: me, targetUserId: In(targets) }) + .getRawMany<{ m_targetUserId: string, m_memo: string | null }>() + .then(it => it.reduce((map, it) => { + map[it.m_targetUserId] = it.m_memo; + return map; + }, {} as Record<string, string | null>)), + this.userProfilesRepository.createQueryBuilder('p') + .select('p.mutedInstances') + .where({ userId: me }) + .getRawOne<{ p_mutedInstances: string[] }>() + .then(it => it?.p_mutedInstances ?? []), ]); return new Map( @@ -311,6 +361,8 @@ export class UserEntityService implements OnModuleInit { isBlocked: blockees.includes(target), isMuted: muters.includes(target), isRenoteMuted: renoteMuters.includes(target), + isInstanceMuted: mutedInstances.includes(hosts[target]), + memo: memos[target] ?? null, }, ]; }), @@ -540,6 +592,8 @@ export class UserEntityService implements OnModuleInit { isCat: user.isCat, noindex: user.noindex, enableRss: user.enableRss, + mandatoryCW: user.mandatoryCW, + rejectQuotes: user.rejectQuotes, isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), speakAsCat: user.speakAsCat ?? false, approved: user.approved, @@ -669,6 +723,8 @@ export class UserEntityService implements OnModuleInit { achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, policies: this.roleService.getUserPolicies(user.id), + defaultCW: profile!.defaultCW, + defaultCWPriority: profile!.defaultCWPriority, } : {}), ...(opts.includeSecrets ? { diff --git a/packages/backend/src/daemons/ApLogCleanupService.ts b/packages/backend/src/daemons/ApLogCleanupService.ts new file mode 100644 index 0000000000..61f76b4e2c --- /dev/null +++ b/packages/backend/src/daemons/ApLogCleanupService.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable, type OnApplicationShutdown } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; +import { ApLogService } from '@/core/ApLogService.js'; + +// 10 minutes +export const scanInterval = 1000 * 60 * 10; + +@Injectable() +export class ApLogCleanupService implements OnApplicationShutdown { + private readonly logger: Logger; + private scanTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly apLogService: ApLogService, + loggerService: LoggerService, + ) { + this.logger = loggerService.getLogger('activity-log-cleanup'); + } + + @bindThis + public async start(): Promise<void> { + // Just in case start() gets called multiple times. + this.dispose(); + + // Prune at startup, in case the server was rebooted during the interval. + // noinspection ES6MissingAwait + this.tick(); + + // Prune on a regular interval for the lifetime of the server. + this.scanTimer = setInterval(this.tick, scanInterval); + } + + @bindThis + private async tick(): Promise<void> { + try { + const affected = await this.apLogService.deleteExpiredLogs(); + this.logger.info(`Activity Log cleanup complete; removed ${affected} expired logs.`); + } catch (err) { + this.logger.error('Activity Log cleanup failed:', err as Error); + } + } + + @bindThis + public onApplicationShutdown(): void { + this.dispose(); + } + + @bindThis + public dispose(): void { + if (this.scanTimer) { + clearInterval(this.scanTimer); + this.scanTimer = null; + } + } +} diff --git a/packages/backend/src/daemons/DaemonModule.ts b/packages/backend/src/daemons/DaemonModule.ts index a67907e6dd..ea71875f19 100644 --- a/packages/backend/src/daemons/DaemonModule.ts +++ b/packages/backend/src/daemons/DaemonModule.ts @@ -8,6 +8,7 @@ import { CoreModule } from '@/core/CoreModule.js'; import { GlobalModule } from '@/GlobalModule.js'; import { QueueStatsService } from './QueueStatsService.js'; import { ServerStatsService } from './ServerStatsService.js'; +import { ApLogCleanupService } from './ApLogCleanupService.js'; @Module({ imports: [ @@ -17,10 +18,12 @@ import { ServerStatsService } from './ServerStatsService.js'; providers: [ QueueStatsService, ServerStatsService, + ApLogCleanupService, ], exports: [ QueueStatsService, ServerStatsService, + ApLogCleanupService, ], }) export class DaemonModule {} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 296cc4815b..461fcf11c3 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -13,6 +13,7 @@ export const DI = { redisForSub: Symbol('redisForSub'), redisForTimelines: Symbol('redisForTimelines'), redisForReactions: Symbol('redisForReactions'), + redisForRateLimit: Symbol('redisForRateLimit'), //#region Repositories usersRepository: Symbol('usersRepository'), @@ -22,6 +23,9 @@ export const DI = { appsRepository: Symbol('appsRepository'), avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), latestNotesRepository: Symbol('latestNotesRepository'), + apContextsRepository: Symbol('apContextsRepository'), + apFetchLogsRepository: Symbol('apFetchLogsRepository'), + apInboxLogsRepository: Symbol('apInboxLogsRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index 3b20ae5df0..eb2b081220 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -18,8 +18,8 @@ type Context = { type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; -type Data = DataElement | DataElement[]; -type DataElement = Record<string, unknown> | Error | string | null; +export type Data = DataElement | DataElement[]; +export type DataElement = Record<string, unknown> | Error | string | null; // eslint-disable-next-line import/no-default-export export default class Logger { diff --git a/packages/backend/src/misc/append-content-warning.ts b/packages/backend/src/misc/append-content-warning.ts new file mode 100644 index 0000000000..152cd6760e --- /dev/null +++ b/packages/backend/src/misc/append-content-warning.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * Important Note: this file must be kept in sync with packages/frontend-shared/js/append-content-warning.ts + */ + +/** + * Appends an additional content warning onto an existing one. + * The additional value will not be added if it already exists within the original input. + * @param original Existing content warning + * @param additional Content warning to append + * @param reverse If true, then the additional CW will be prepended instead of appended. + */ +export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string { + // Easy case - if original is empty, then additional replaces it. + if (!original) { + return additional; + } + + // Easy case - if the additional CW is empty, then don't append it. + if (!additional) { + return original; + } + + // If the additional CW already exists in the input, then we *don't* append another copy! + if (includesWholeWord(original, additional)) { + return original; + } + + return reverse + ? `${additional}, ${original}` + : `${original}, ${additional}`; +} + +/** + * Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern. + * We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side. + * @param input Input string to search + * @param target Target word / phrase to search for + */ +function includesWholeWord(input: string, target: string): boolean { + const parts = input.split(target); + + // The additional string could appear multiple times within the original input. + // We need to check each occurrence, since any of them could potentially match. + for (let i = 0; i + 1 < parts.length; i++) { + const before = parts[i]; + const after = parts[i + 1]; + + // If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word. + // Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input. + if (!/\w$/.test(before) && !/^\w/.test(after)) { + return true; + } + } + + // If we don't match, then there is no existing CW. + return false; +} diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index 342e0f8602..f3c08cc76e 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -8,7 +8,7 @@ * https://en.wikipedia.org/wiki/Identicon */ -import { createCanvas } from '@napi-rs/canvas'; +import { createCanvas } from 'canvas'; import gen from 'random-seed'; const size = 128; // px @@ -100,5 +100,5 @@ export async function genIdenticon(seed: string): Promise<Buffer> { } } - return await canvas.encode('png'); + return await canvas.toBuffer('image/png'); } diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 60dddee9a2..be2d3ea98d 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { appendContentWarning } from './append-content-warning.js'; import type { Packed } from './json-schema.js'; /** @@ -20,9 +21,15 @@ export const getNoteSummary = (note: Packed<'Note'>): string => { let summary = ''; + // Append mandatory CW, if applicable + let cw = note.cw; + if (note.user.mandatoryCW) { + cw = appendContentWarning(cw, note.user.mandatoryCW); + } + // 本文 - if (note.cw != null) { - summary += `CW: ${note.cw}`; + if (cw != null) { + summary += `CW: ${cw}`; } else if (note.text) { summary += note.text; } diff --git a/packages/backend/src/misc/identifiable-error.ts b/packages/backend/src/misc/identifiable-error.ts index 13c41f1e3b..f5c3fcd6cb 100644 --- a/packages/backend/src/misc/identifiable-error.ts +++ b/packages/backend/src/misc/identifiable-error.ts @@ -10,9 +10,15 @@ export class IdentifiableError extends Error { public message: string; public id: string; - constructor(id: string, message?: string) { + /** + * Indicates that this is a temporary error that may be cleared by retrying + */ + public readonly isRetryable: boolean; + + constructor(id: string, message?: string, isRetryable = false) { super(message); this.message = message ?? ''; this.id = id; + this.isRetryable = isRetryable; } } diff --git a/packages/backend/src/misc/is-retryable-error.ts b/packages/backend/src/misc/is-retryable-error.ts new file mode 100644 index 0000000000..9bb8700c7a --- /dev/null +++ b/packages/backend/src/misc/is-retryable-error.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AbortError } from 'node-fetch'; +import { UnrecoverableError } from 'bullmq'; +import { StatusError } from '@/misc/status-error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +/** + * Returns false if the provided value represents a "permanent" error that cannot be retried. + * Returns true if the error is retryable, unknown (as all errors are retryable by default), or not an error object. + */ +export function isRetryableError(e: unknown): boolean { + if (e instanceof StatusError) return e.isRetryable; + if (e instanceof IdentifiableError) return e.isRetryable; + if (e instanceof UnrecoverableError) return false; + if (e instanceof AbortError) return true; + if (e instanceof Error) return e.name === 'AbortError'; + return true; +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 040e36228c..f612591eda 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -33,7 +33,11 @@ import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { + packedEmojiDetailedAdminSchema, + packedEmojiDetailedSchema, + packedEmojiSimpleSchema, +} from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -95,6 +99,7 @@ export const refs = { GalleryPost: packedGalleryPostSchema, EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, + EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index ba93190c57..c64ebb1b3b 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -164,6 +164,15 @@ export class MiInstance { }) public rejectReports: boolean; + /** + * If true, quote posts from this instance will be downgraded to normal posts. + * The quote will be stripped and a process error will be generated. + */ + @Column('boolean', { + default: false, + }) + public rejectQuotes: boolean; + @Column('varchar', { length: 16384, default: '', }) diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 3fc3f273dd..a224117676 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -599,6 +599,11 @@ export class MiMeta { }) public enableAchievements: boolean; + @Column('varchar', { + length: 2048, nullable: true, + }) + public robotsTxt: string | null; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 408e023ff7..2dabb75d83 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -143,6 +143,7 @@ export class MiNote { }) public fileIds: MiDriveFile['id'][]; + @Index('IDX_NOTE_ATTACHED_FILE_TYPES', { synchronize: false }) @Column('varchar', { length: 256, array: true, default: '{}', }) @@ -202,6 +203,17 @@ export class MiNote { @JoinColumn() public channel: MiChannel | null; + /** + * List of non-fatal errors encountered while processing (creating or updating) this note. + * Entries can be a translation key (which will be queried from the "_processErrors" section) or a raw string. + * Errors will be displayed to the user when viewing the note. + */ + @Column('text', { + array: true, + nullable: true, + }) + public processErrors: string[] | null; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index 1695bf570e..0b59e7a92c 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -118,3 +118,5 @@ export class MiPage { } } } + +export const pageNameSchema = { type: 'string', pattern: /^[^\s:\/?#\[\]@!$&'()*+,;=\\%\x00-\x20]{1,256}$/.source } as const; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 3a1158a42a..78510ba588 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -80,7 +80,10 @@ import { MiUserPublickey, MiUserSecurityKey, MiWebhook, - NoteEdit + NoteEdit, + SkApContext, + SkApFetchLog, + SkApInboxLog, } from './_.js'; import type { DataSource } from 'typeorm'; @@ -126,6 +129,24 @@ const $latestNotesRepository: Provider = { inject: [DI.db], }; +const $apContextRepository: Provider = { + provide: DI.apContextsRepository, + useFactory: (db: DataSource) => db.getRepository(SkApContext).extend(miRepository as MiRepository<SkApContext>), + inject: [DI.db], +}; + +const $apFetchLogsRepository: Provider = { + provide: DI.apFetchLogsRepository, + useFactory: (db: DataSource) => db.getRepository(SkApFetchLog).extend(miRepository as MiRepository<SkApFetchLog>), + inject: [DI.db], +}; + +const $apInboxLogsRepository: Provider = { + provide: DI.apInboxLogsRepository, + useFactory: (db: DataSource) => db.getRepository(SkApInboxLog).extend(miRepository as MiRepository<SkApInboxLog>), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite).extend(miRepository as MiRepository<MiNoteFavorite>), @@ -526,6 +547,9 @@ const $noteScheduleRepository: Provider = { $appsRepository, $avatarDecorationsRepository, $latestNotesRepository, + $apContextRepository, + $apFetchLogsRepository, + $apInboxLogsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -600,6 +624,9 @@ const $noteScheduleRepository: Provider = { $appsRepository, $avatarDecorationsRepository, $latestNotesRepository, + $apContextRepository, + $apFetchLogsRepository, + $apInboxLogsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/SkApContext.ts b/packages/backend/src/models/SkApContext.ts new file mode 100644 index 0000000000..ff4c6d6fbf --- /dev/null +++ b/packages/backend/src/models/SkApContext.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, PrimaryColumn, Entity } from 'typeorm'; + +@Entity('ap_context') +export class SkApContext { + @PrimaryColumn('text', { + primaryKeyConstraintName: 'PK_ap_context', + }) + public md5: string; + + @Column('jsonb') + // https://github.com/typeorm/typeorm/issues/8559 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public json: any; + + constructor(data?: Partial<SkApContext>) { + if (data) { + Object.assign(this, data); + } + } +} diff --git a/packages/backend/src/models/SkApFetchLog.ts b/packages/backend/src/models/SkApFetchLog.ts new file mode 100644 index 0000000000..1e7d861b6c --- /dev/null +++ b/packages/backend/src/models/SkApFetchLog.ts @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Index, JoinColumn, ManyToOne, PrimaryColumn, Entity } from 'typeorm'; +import { SkApContext } from '@/models/SkApContext.js'; +import { id } from './util/id.js'; + +/** + * Records objects fetched via AP + */ +@Entity('ap_fetch_log') +export class SkApFetchLog { + @PrimaryColumn({ + ...id(), + primaryKeyConstraintName: 'PK_ap_fetch_log', + }) + public id: string; + + @Index('IDX_ap_fetch_log_at') + @Column('timestamptz') + public at: Date; + + /** + * Processing duration in milliseconds + */ + @Column('double precision', { nullable: true }) + public duration: number | null = null; + + /** + * DB hostname extracted from responseUri, or requestUri if fetch is incomplete + */ + @Index('IDX_ap_fetch_log_host') + @Column('text') + public host: string; + + /** + * Original requested URI + */ + @Column('text', { + name: 'request_uri', + }) + public requestUri: string; + + /** + * Canonical URI / object ID, taken from the final payload + */ + @Column('text', { + name: 'object_uri', + nullable: true, + }) + @Index('IDX_ap_fetch_log_object_uri') + public objectUri: string | null = null; + + @Column('boolean', { nullable: true }) + public accepted: boolean | null = null; + + @Column('text', { nullable: true }) + public result: string | null = null; + + @Column('jsonb', { nullable: true }) + // https://github.com/typeorm/typeorm/issues/8559 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public object: any | null = null; + + @Column({ + type: 'text', + name: 'context_hash', + nullable: true, + }) + public contextHash: string | null; + + @ManyToOne(() => SkApContext, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ + name: 'context_hash', + foreignKeyConstraintName: 'FK_ap_fetch_log_context_hash', + }) + public context: SkApContext | null; + + constructor(data?: Partial<SkApFetchLog>) { + if (data) { + Object.assign(this, data); + } + } +} diff --git a/packages/backend/src/models/SkApInboxLog.ts b/packages/backend/src/models/SkApInboxLog.ts new file mode 100644 index 0000000000..867094405c --- /dev/null +++ b/packages/backend/src/models/SkApInboxLog.ts @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { SkApContext } from '@/models/SkApContext.js'; +import { MiUser } from '@/models/_.js'; +import { id } from './util/id.js'; + +/** + * Records activities received in the inbox + */ +@Entity('ap_inbox_log') +export class SkApInboxLog { + @PrimaryColumn({ + ...id(), + primaryKeyConstraintName: 'PK_ap_inbox_log', + }) + public id: string; + + @Index('IDX_ap_inbox_log_at') + @Column('timestamptz') + public at: Date; + + /** + * Processing duration in milliseconds + */ + @Column('double precision', { nullable: true }) + public duration: number | null = null; + + /** + * Key ID that was used to sign this request. + * Untrusted unless verified is true. + */ + @Column({ + type: 'text', + name: 'key_id', + }) + public keyId: string; + + /** + * Instance that the activity was sent from. + * Untrusted unless verified is true. + */ + @Index('IDX_ap_inbox_log_host') + @Column('text') + public host: string; + + @Column('boolean') + public verified: boolean; + + @Column('boolean') + public accepted: boolean; + + @Column('text', { nullable: true }) + public result: string | null = null; + + @Column('jsonb') + // https://github.com/typeorm/typeorm/issues/8559 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public activity: any; + + @Column({ + type: 'text', + name: 'context_hash', + nullable: true, + }) + public contextHash: string | null; + + @ManyToOne(() => SkApContext, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ + name: 'context_hash', + foreignKeyConstraintName: 'FK_ap_inbox_log_context_hash', + }) + public context: SkApContext | null; + + /** + * ID of the user who signed this request. + */ + @Column({ + ...id(), + name: 'auth_user_id', + nullable: true, + }) + public authUserId: string | null; + + /** + * User who signed this request. + */ + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ + name: 'auth_user_id', + foreignKeyConstraintName: 'FK_ap_inbox_log_auth_user_id', + }) + public authUser: MiUser | null; + + constructor(data?: Partial<SkApInboxLog>) { + if (data) { + Object.assign(this, data); + } + } +} diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 3a825d36a7..5d87c7fa12 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -339,6 +339,24 @@ export class MiUser { }) public enableRss: boolean; + /** + * Specifies a Content Warning that should be forcibly applied to all notes by this user. + * If null (default), then no Content Warning is applied. + */ + @Column('text', { + nullable: true, + }) + public mandatoryCW: string | null; + + /** + * If true, quote posts from this user will be downgraded to normal posts. + * The quote will be stripped and a process error will be generated. + */ + @Column('boolean', { + default: false, + }) + public rejectQuotes: boolean; + constructor(data: Partial<MiUser>) { if (data == null) return; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 751b1aff08..449c2f370b 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -4,7 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, noteVisibilities, defaultCWPriorities } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; @@ -36,10 +36,10 @@ export class MiUserProfile { }) public birthday: string | null; - @Column("varchar", { + @Column('varchar', { length: 128, nullable: true, - comment: "The ListenBrainz username of the User.", + comment: 'The ListenBrainz username of the User.', }) public listenbrainz: string | null; @@ -290,6 +290,19 @@ export class MiUserProfile { unlockedAt: number; }[]; + @Column('text', { + name: 'default_cw', + nullable: true, + }) + public defaultCW: string | null; + + @Column('enum', { + name: 'default_cw_priority', + enum: defaultCWPriorities, + default: 'parent', + }) + public defaultCWPriority: typeof defaultCWPriorities[number]; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 9a4ebfc90f..4bd6e78ef4 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -82,6 +82,9 @@ import { NoteEdit } from '@/models/NoteEdit.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; import { MiNoteSchedule } from '@/models/NoteSchedule.js'; +import { SkApInboxLog } from '@/models/SkApInboxLog.js'; +import { SkApFetchLog } from '@/models/SkApFetchLog.js'; +import { SkApContext } from '@/models/SkApContext.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; export interface MiRepository<T extends ObjectLiteral> { @@ -129,6 +132,9 @@ export const miRepository = { export { SkLatestNote, + SkApContext, + SkApFetchLog, + SkApInboxLog, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiAccessToken, @@ -229,6 +235,9 @@ export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag> export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>; export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>; export type LatestNotesRepository = Repository<SkLatestNote> & MiRepository<SkLatestNote>; +export type ApContextsRepository = Repository<SkApContext> & MiRepository<SkApContext>; +export type ApFetchLogsRepository = Repository<SkApFetchLog> & MiRepository<SkApFetchLog>; +export type ApInboxLogsRepository = Repository<SkApInboxLog> & MiRepository<SkApInboxLog>; export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>; export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>; export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>; diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index 62686ad5ae..3cd263fa37 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -104,3 +104,86 @@ export const packedEmojiDetailedSchema = { }, }, } as const; + +export const packedEmojiDetailedAdminSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', + }, + publicUrl: { + type: 'string', + optional: false, nullable: false, + }, + originalUrl: { + type: 'string', + optional: false, nullable: false, + }, + uri: { + type: 'string', + optional: false, nullable: true, + }, + type: { + type: 'string', + optional: false, nullable: true, + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 7960e748e9..57d4466ffa 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -126,6 +126,11 @@ export const packedFederationInstanceSchema = { optional: false, nullable: false, }, + rejectQuotes: { + type: 'boolean', + optional: false, + nullable: false, + }, moderationNote: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 5179e5d51c..bf68208c37 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -139,6 +139,10 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: true, }, + robotsTxt: { + type: 'string', + optional: false, nullable: true, + }, enableTestcaptcha: { type: 'boolean', optional: false, nullable: false, @@ -317,6 +321,11 @@ export const packedMetaLiteSchema = { type: 'number', optional: false, nullable: false, }, + federation: { + type: 'string', + enum: ['all', 'specified', 'none'], + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 432c096e48..16e240ab11 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -17,6 +17,11 @@ export const packedNoteSchema = { optional: false, nullable: false, format: 'date-time', }, + updatedAt: { + type: 'string', + optional: true, nullable: false, + format: 'date-time', + }, deletedAt: { type: 'string', optional: true, nullable: true, @@ -256,6 +261,14 @@ export const packedNoteSchema = { type: 'number', optional: true, nullable: false, }, + processErrors: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, myReaction: { type: 'string', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f953008b3f..0f1601f138 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -134,6 +134,14 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: false, }, + mandatoryCW: { + type: 'string', + nullable: true, optional: false, + }, + rejectQuotes: { + type: 'boolean', + nullable: false, optional: true, + }, isBot: { type: 'boolean', nullable: false, optional: true, @@ -752,6 +760,15 @@ export const packedMeDetailedOnlySchema = { }, }, //#endregion + defaultCW: { + type: 'string', + nullable: true, optional: false, + }, + defaultCWPriority: { + type: 'string', + enum: ['default', 'parent', 'defaultParent', 'parentDefault'], + nullable: false, optional: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index c964c3ffee..1a5fdc8412 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -85,6 +85,9 @@ import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { SkLatestNote } from '@/models/LatestNote.js'; +import { SkApContext } from '@/models/SkApContext.js'; +import { SkApFetchLog } from '@/models/SkApFetchLog.js'; +import { SkApInboxLog } from '@/models/SkApInboxLog.js'; pg.types.setTypeParser(20, Number); @@ -92,27 +95,65 @@ export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); +export type LoggerProps = { + disableQueryTruncation?: boolean; + enableQueryParamLogging?: boolean; +} + +function highlightSql(sql: string) { + return highlight.highlight(sql, { + language: 'sql', ignoreIllegals: true, + }); +} + +function truncateSql(sql: string) { + return sql.length > 100 ? `${sql.substring(0, 100)}...` : sql; +} + +function stringifyParameter(param: any) { + if (param instanceof Date) { + return param.toISOString(); + } else { + return param; + } +} + class MyCustomLogger implements Logger { + constructor(private props: LoggerProps = {}) { + } + + @bindThis + private transformQueryLog(sql: string) { + let modded = sql; + if (!this.props.disableQueryTruncation) { + modded = truncateSql(modded); + } + + return highlightSql(modded); + } + @bindThis - private highlight(sql: string) { - return highlight.highlight(sql, { - language: 'sql', ignoreIllegals: true, - }); + private transformParameters(parameters?: any[]) { + if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) { + return parameters.map(stringifyParameter); + } + + return undefined; } @bindThis public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); + sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters)); } @bindThis public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.highlight(query)); + sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters)); } @bindThis public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.highlight(query)); + sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters)); } @bindThis @@ -133,6 +174,9 @@ class MyCustomLogger implements Logger { export const entities = [ SkLatestNote, + SkApContext, + SkApFetchLog, + SkApInboxLog, MiAnnouncement, MiAnnouncementRead, MiMeta, @@ -249,7 +293,12 @@ export function createPostgresDataSource(config: Config) { }, } : false, logging: log, - logger: log ? new MyCustomLogger() : undefined, + logger: log + ? new MyCustomLogger({ + disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, + enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, + }) + : undefined, maxQueryExecutionTime: 300, entities: entities, migrations: ['../../migration/*.js'], diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts index b81987cc15..ef21b6142e 100644 --- a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -215,15 +215,10 @@ export class CheckModeratorsActivityProcessorService { // -- SystemWebhook - const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks() - .then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning'))); - for (const systemWebhook of systemWebhooks) { - this.systemWebhookService.enqueueSystemWebhook( - systemWebhook, - 'inactiveModeratorsWarning', - { remainingTime: remainingTime }, - ); - } + return this.systemWebhookService.enqueueSystemWebhook( + 'inactiveModeratorsWarning', + { remainingTime: remainingTime }, + ); } @bindThis @@ -253,15 +248,10 @@ export class CheckModeratorsActivityProcessorService { // -- SystemWebhook - const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks() - .then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged'))); - for (const systemWebhook of systemWebhooks) { - this.systemWebhookService.enqueueSystemWebhook( - systemWebhook, - 'inactiveModeratorsInvitationOnlyChanged', - {}, - ); - } + return this.systemWebhookService.enqueueSystemWebhook( + 'inactiveModeratorsInvitationOnlyChanged', + {}, + ); } @bindThis diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts index 19f98c0d51..8c5faa8d07 100644 --- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts @@ -48,6 +48,7 @@ export class CleanChartsProcessorService { public async process(): Promise<void> { this.logger.info('Clean charts...'); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する await this.federationChart.clean(); await this.notesChart.clean(); await this.usersChart.clean(); diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 0e604a0501..0c70829132 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -15,10 +15,12 @@ import type { MiNoteReaction } from '@/models/NoteReaction.js'; import { EmailService } from '@/core/EmailService.js'; import { bindThis } from '@/decorators.js'; import { SearchService } from '@/core/SearchService.js'; +import { ApLogService } from '@/core/ApLogService.js'; +import { ReactionService } from '@/core/ReactionService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; -import { ReactionService } from '@/core/ReactionService.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class DeleteAccountProcessorService { @@ -40,11 +42,16 @@ export class DeleteAccountProcessorService { @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + private queueService: QueueService, private driveService: DriveService, private emailService: EmailService, private queueLoggerService: QueueLoggerService, private searchService: SearchService, private reactionService: ReactionService, + private readonly apLogService: ApLogService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); } @@ -58,6 +65,22 @@ export class DeleteAccountProcessorService { return; } + { // Delete scheduled notes + const scheduledNotes = await this.noteScheduleRepository.findBy({ + userId: user.id, + }) as MiNoteSchedule[]; + + for (const note of scheduledNotes) { + await this.queueService.ScheduleNotePostQueue.remove(`schedNote:${note.id}`); + } + + await this.noteScheduleRepository.delete({ + userId: user.id, + }); + + this.logger.succ('All scheduled notes deleted'); + } + { // Delete notes let cursor: MiNote['id'] | null = null; @@ -84,6 +107,13 @@ export class DeleteAccountProcessorService { for (const note of notes) { await this.searchService.unindexNote(note); } + + // Delete note AP logs + const noteUris = notes.map(n => n.uri).filter(u => !!u) as string[]; + if (noteUris.length > 0) { + await this.apLogService.deleteObjectLogs(noteUris) + .catch(err => this.logger.error(err, `Failed to delete AP logs for notes of user '${user.uri ?? user.id}'`)); + } } this.logger.succ('All of notes deleted'); @@ -149,6 +179,13 @@ export class DeleteAccountProcessorService { this.logger.succ('All of files deleted'); } + { // Delete actor logs + if (user.uri) { + await this.apLogService.deleteObjectLogs(user.uri) + .catch(err => this.logger.error(err, `Failed to delete AP logs for user '${user.uri}'`)); + } + } + { // Send email notification const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.email && profile.emailVerified) { diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 17ba71df3d..383fa0c26a 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -14,6 +14,7 @@ import { createTempDir } from '@/misc/create-temp.js'; import { DriveService } from '@/core/DriveService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { bindThis } from '@/decorators.js'; +import type { Config } from '@/config.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; @@ -24,6 +25,9 @@ export class ImportCustomEmojisProcessorService { private logger: Logger; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -57,7 +61,7 @@ export class ImportCustomEmojisProcessorService { try { fs.writeFileSync(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadService.downloadUrl(file.url, destPath, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -88,6 +92,7 @@ export class ImportCustomEmojisProcessorService { await this.emojisRepository.delete({ name: nameNfc, }); + try { const driveFile = await this.driveService.addFile({ user: null, @@ -96,11 +101,13 @@ export class ImportCustomEmojisProcessorService { force: true, }); await this.customEmojiService.add({ + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: nameNfc, category: emojiInfo.category?.normalize('NFC'), host: null, aliases: emojiInfo.aliases?.map((a: string) => a.normalize('NFC')), - driveFile, license: emojiInfo.license, isSensitive: emojiInfo.isSensitive, localOnly: emojiInfo.localOnly, diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts index f89dc46722..ee9819b29f 100644 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts @@ -626,7 +626,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(videos[0].url, filePath); + await this.downloadUrl(videos[0].url, filePath); } catch (e) { // TODO: 何度か再試行 this.logger.error(e instanceof Error ? e : new Error(e as string)); } @@ -651,7 +651,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(file.media_url_https, filePath); + await this.downloadUrl(file.media_url_https, filePath); } catch (e) { // TODO: 何度か再試行 this.logger.error(e instanceof Error ? e : new Error(e as string)); } diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 7727a3e985..35a0bf095d 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -29,6 +29,9 @@ import { CollapsedQueue } from '@/misc/collapsed-queue.js'; import { MiNote } from '@/models/Note.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; +import { SkApInboxLog } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { ApLogService, calculateDurationSince } from '@/core/ApLogService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { InboxJobData } from '../types.js'; @@ -46,6 +49,9 @@ export class InboxProcessorService implements OnApplicationShutdown { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.config) + private config: Config, + private utilityService: UtilityService, private apInboxService: ApInboxService, private federatedInstanceService: FederatedInstanceService, @@ -57,6 +63,7 @@ export class InboxProcessorService implements OnApplicationShutdown { private apRequestChart: ApRequestChart, private federationChart: FederationChart, private queueLoggerService: QueueLoggerService, + private readonly apLogService: ApLogService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance); @@ -64,6 +71,41 @@ export class InboxProcessorService implements OnApplicationShutdown { @bindThis public async process(job: Bull.Job<InboxJobData>): Promise<string> { + if (this.config.activityLogging.enabled) { + return await this._processLogged(job); + } else { + return await this._process(job); + } + } + + private async _processLogged(job: Bull.Job<InboxJobData>): Promise<string> { + const startTime = process.hrtime.bigint(); + const activity = job.data.activity; + const keyId = job.data.signature.keyId; + const log = await this.apLogService.createInboxLog({ activity, keyId }); + + try { + const result = await this._process(job, log); + + log.accepted = result.startsWith('ok'); + log.result = result; + + return result; + } catch (err) { + log.accepted = false; + log.result = String(err); + + throw err; + } finally { + log.duration = calculateDurationSince(startTime); + + // Save or finalize asynchronously + this.apLogService.saveInboxLog(log) + .catch(err => this.logger.error('Failed to record AP activity:', err)); + } + } + + private async _process(job: Bull.Job<InboxJobData>, log?: SkApInboxLog): Promise<string> { const signature = job.data.signature; // HTTP-signature let activity = job.data.activity; @@ -197,6 +239,13 @@ export class InboxProcessorService implements OnApplicationShutdown { delete activity.id; } + // Record verified user in log + if (log) { + log.verified = true; + log.authUser = authUser.user; + log.authUserId = authUser.user.id; + } + this.apRequestChart.inbox(); this.federationChart.inbox(authUser.user.host); @@ -248,6 +297,14 @@ export class InboxProcessorService implements OnApplicationShutdown { return `skip: permanent error ${e.statusCode}`; } + if (e instanceof IdentifiableError && !e.isRetryable) { + if (e.message) { + return `skip: permanent error ${e.id}: ${e.message}`; + } else { + return `skip: permanent error ${e.id}`; + } + } + throw e; } return 'ok'; diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts index 46e1adf173..0c47fdedb3 100644 --- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts @@ -29,6 +29,7 @@ export class ResyncChartsProcessorService { public async process(): Promise<void> { this.logger.info('Resync charts...'); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する // TODO: ユーザーごとのチャートも更新する // TODO: インスタンスごとのチャートも更新する await this.driveChart.resync(); diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts index c09cbccc57..fc8856a271 100644 --- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts @@ -48,6 +48,7 @@ export class TickChartsProcessorService { public async process(): Promise<void> { this.logger.info('Tick charts...'); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する await this.federationChart.tick(false); await this.notesChart.tick(false); await this.usersChart.tick(false); diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 9433392df5..a900675a86 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -6,9 +6,12 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; +import type { SystemWebhookEventType } from '@/models/SystemWebhook.js'; import type { MiUser } from '@/models/User.js'; -import type { MiWebhook } from '@/models/Webhook.js'; +import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; import type { IActivity } from '@/core/activitypub/type.js'; +import type { SystemWebhookPayload } from '@/core/SystemWebhookService.js'; +import type { UserWebhookPayload } from '@/core/UserWebhookService.js'; import type httpSignature from '@peertube/http-signature'; export type DeliverJobData = { @@ -131,9 +134,9 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; -export type SystemWebhookDeliverJobData = { - type: string; - content: unknown; +export type SystemWebhookDeliverJobData<T extends SystemWebhookEventType = SystemWebhookEventType> = { + type: T; + content: SystemWebhookPayload<T>; webhookId: MiWebhook['id']; to: string; secret: string; @@ -141,9 +144,9 @@ export type SystemWebhookDeliverJobData = { eventId: string; }; -export type UserWebhookDeliverJobData = { - type: string; - content: unknown; +export type UserWebhookDeliverJobData<T extends WebhookEventTypes = WebhookEventTypes> = { + type: T; + content: UserWebhookPayload<T>; webhookId: MiWebhook['id']; userId: MiUser['id']; to: string; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 815bf278c7..10dba1660f 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -30,14 +30,14 @@ import type { MiNote } from '@/models/Note.js'; import { QueryService } from '@/core/QueryService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import * as Acct from '@/misc/acct.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; @@ -103,15 +103,16 @@ export class ActivityPubServerService { /** * Pack Create<Note> or Announce Activity * @param note Note + * @param author Author of the note */ @bindThis - private async packActivity(note: MiNote): Promise<any> { + private async packActivity(note: MiNote, author: MiUser): Promise<any> { if (isRenote(note) && !isQuote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); } - return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note); } @bindThis @@ -506,7 +507,7 @@ export class ActivityPubServerService { this.notesRepository.findOneByOrFail({ id: pining.noteId })))) .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); - const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); + const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user))); const rendered = this.apRendererService.renderOrderedCollection( `${this.config.url}/users/${userId}/collections/featured`, @@ -579,7 +580,7 @@ export class ActivityPubServerService { if (sinceId) notes.reverse(); - const activities = await Promise.all(notes.map(note => this.packActivity(note))); + const activities = await Promise.all(notes.map(note => this.packActivity(note, user))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ page: 'true', @@ -653,8 +654,8 @@ export class ActivityPubServerService { }, deriveConstraint(request: IncomingMessage) { const accepted = accepts(request).type(['html', ACTIVITY_JSON, LD_JSON]); - const isAp = typeof accepted === 'string' && !accepted.match(/html/); - return isAp ? 'ap' : 'html'; + if (accepted === false) return null; + return accepted !== 'html' ? 'ap' : 'html'; }, }); @@ -723,7 +724,9 @@ export class ActivityPubServerService { if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); - return this.apRendererService.addContext(await this.apRendererService.renderNote(note, false)); + + const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); + return this.apRendererService.addContext(await this.apRendererService.renderNote(note, author, false)); }); // note activity @@ -746,7 +749,9 @@ export class ActivityPubServerService { if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.packActivity(note))); + + const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); + return (this.apRendererService.addContext(await this.packActivity(note, author))); }); // outbox diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 5293d529ad..a7e13a1b78 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -11,7 +11,7 @@ import rename from 'rename'; import sharp from 'sharp'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import type { Config } from '@/config.js'; -import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; +import type { MiDriveFile, DriveFilesRepository, MiUser } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { createTemp } from '@/misc/create-temp.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; @@ -30,8 +30,7 @@ import { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; @@ -59,7 +58,6 @@ export class FileServerService { private loggerService: LoggerService, private authenticateService: AuthenticateService, private rateLimiterService: SkRateLimiterService, - private roleService: RoleService, ) { this.logger = this.loggerService.getLogger('server', 'gray'); @@ -625,14 +623,13 @@ export class FileServerService { // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. const [user] = await this.authenticateService.authenticate(token); - const actor = user?.id ?? getIpHash(request.ip); - const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; + const actor = user ?? getIpHash(request.ip); // Call both limits: the per-resource limit and the shared cross-resource limit - return await this.checkResourceLimit(reply, actor, group, resource, factor) && await this.checkSharedLimit(reply, actor, group, factor); + return await this.checkResourceLimit(reply, actor, group, resource) && await this.checkSharedLimit(reply, actor, group); } - private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string, factor = 1): Promise<boolean> { + private async checkResourceLimit(reply: FastifyReply, actor: string | MiUser, group: string, resource: string): Promise<boolean> { const limit: Keyed<RateLimit> = { // Group by resource key: `${group}${resource}`, @@ -643,10 +640,10 @@ export class FileServerService { dripRate: 1000 * 60, }; - return await this.checkLimit(reply, actor, limit, factor); + return await this.checkLimit(reply, actor, limit); } - private async checkSharedLimit(reply: FastifyReply, actor: string, group: string, factor = 1): Promise<boolean> { + private async checkSharedLimit(reply: FastifyReply, actor: string | MiUser, group: string): Promise<boolean> { const limit: Keyed<RateLimit> = { key: group, type: 'bucket', @@ -655,11 +652,11 @@ export class FileServerService { size: 3600, }; - return await this.checkLimit(reply, actor, limit, factor); + return await this.checkLimit(reply, actor, limit); } - private async checkLimit(reply: FastifyReply, actor: string, limit: Keyed<RateLimit>, factor = 1): Promise<boolean> { - const info = await this.rateLimiterService.limit(limit, actor, factor); + private async checkLimit(reply: FastifyReply, actor: string | MiUser, limit: Keyed<RateLimit>): Promise<boolean> { + const info = await this.rateLimiterService.limit(limit, actor); sendRateLimitHeaders(reply, info); diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index c1d7c088f1..2c067afe88 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -6,7 +6,7 @@ import { Module } from '@nestjs/common'; import { EndpointsModule } from '@/server/api/EndpointsModule.js'; import { CoreModule } from '@/core/CoreModule.js'; -import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; import { HealthServerService } from './HealthServerService.js'; @@ -27,6 +27,8 @@ import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { MastoConverters } from './api/mastodon/converters.js'; +import { MastodonLogger } from './api/mastodon/MastodonLogger.js'; +import { MastodonDataService } from './api/mastodon/MastodonDataService.js'; import { FeedService } from './web/FeedService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; @@ -103,6 +105,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j MastodonApiServerService, OAuth2ProviderService, MastoConverters, + MastodonLogger, + MastodonDataService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 43a2a3a2b0..690fdcfe29 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -262,7 +262,7 @@ export class ServerService implements OnApplicationShutdown { } }); } else { - fastify.listen({ port: this.config.port, host: '0.0.0.0' }); + fastify.listen({ port: this.config.port, host: this.config.address }); } await fastify.ready(); diff --git a/packages/backend/src/server/SkRateLimiterService.md b/packages/backend/src/server/SkRateLimiterService.md index 762f8dfe14..c8a2b4e85c 100644 --- a/packages/backend/src/server/SkRateLimiterService.md +++ b/packages/backend/src/server/SkRateLimiterService.md @@ -12,6 +12,11 @@ SkRateLimiterService is not quite plug-and-play compatible with existing call si Instead, the returned LimitInfo object will have `blocked` set to true. Callers are responsible for checking this property and taking any desired action, such as rejecting a request or returning limit details. +Rate limit factors are also handled differently. +Instead of providing an optional parameter for callers, SkRateLimiterServer accepts an `MiUser` parameter that is used to compute the factor directly. +If a user is not available (such as for unauthenticated callers), then the Role Template factor is used instead. +To avoid confusion, the `factor` parameter has been removed entirely and is now an implementation detail. + ## Headers LimitInfo objects (returned by `SkRateLimitService.limit()`) can be passed to `rate-limit-utils.sendRateLimitHeaders()` to send standard rate limit headers with an HTTP response. @@ -34,6 +39,7 @@ The first call is read-only, while the others perform at least one write operati Two integer keys are stored per client/subject, and both expire together after the maximum duration of the limit. While performance has not been formally tested, it's expected that SkRateLimiterService has an impact roughly on par with the legacy RateLimiterService. Redis memory usage should be notably lower due to the reduced number of keys and avoidance of set / array constructions. +If redis load does become a concern, then a dedicated node can be assigned via the `redisForRateLimit` config setting. ## Concurrency and Multi-Node Correctness diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/SkRateLimiterService.ts index 38c97b63df..30bf092e4f 100644 --- a/packages/backend/src/server/api/SkRateLimiterService.ts +++ b/packages/backend/src/server/SkRateLimiterService.ts @@ -5,36 +5,67 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import { TimeService } from '@/core/TimeService.js'; -import { EnvService } from '@/core/EnvService.js'; +import type { TimeService } from '@/core/TimeService.js'; +import type { EnvService } from '@/core/EnvService.js'; import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed, hasMaxLimit, disabledLimitInfo, MaxLegacyLimit, MinLegacyLimit } from '@/misc/rate-limit-utils.js'; import { DI } from '@/di-symbols.js'; +import { MemoryKVCache } from '@/misc/cache.js'; +import type { MiUser } from '@/models/_.js'; +import type { RoleService } from '@/core/RoleService.js'; + +// Sentinel value used for caching the default role template. +// Required because MemoryKVCache doesn't support null keys. +const defaultUserKey = ''; @Injectable() export class SkRateLimiterService { + // 1-minute cache interval + private readonly factorCache = new MemoryKVCache<number>(1000 * 60); private readonly disabled: boolean; constructor( - @Inject(TimeService) + @Inject('TimeService') private readonly timeService: TimeService, - @Inject(DI.redis) + @Inject(DI.redisForRateLimit) private readonly redisClient: Redis.Redis, - @Inject(EnvService) + @Inject('RoleService') + private readonly roleService: RoleService, + + @Inject('EnvService') envService: EnvService, ) { this.disabled = envService.env.NODE_ENV === 'test'; } /** - * Check & increment a rate limit + * Check & increment a rate limit for a client. + * + * If the client (actorOrUser) is passed as a string, then it uses the default rate limit factor from the role template. + * If the client (actorOrUser) is passed as an MiUser, then it queries the user's actual rate limit factor from their assigned roles. + * + * A factor of zero (0) will disable the limit, while any negative number will produce an error. + * A factor between zero (0) and one (1) will increase the limit from its default values (allowing more actions per time interval). + * A factor greater than one (1) will decrease the limit from its default values (allowing fewer actions per time interval). + * * @param limit The limit definition - * @param actor Client who is calling this limit - * @param factor Scaling factor - smaller = larger limit (less restrictive) + * @param actorOrUser authenticated client user or IP hash */ - public async limit(limit: Keyed<RateLimit>, actor: string, factor = 1): Promise<LimitInfo> { - if (this.disabled || factor === 0) { + public async limit(limit: Keyed<RateLimit>, actorOrUser: string | MiUser): Promise<LimitInfo> { + if (this.disabled) { + return disabledLimitInfo; + } + + const actor = typeof(actorOrUser) === 'object' ? actorOrUser.id : actorOrUser; + const userCacheKey = typeof(actorOrUser) === 'object' ? actorOrUser.id : defaultUserKey; + const userRoleKey = typeof(actorOrUser) === 'object' ? actorOrUser.id : null; + const factor = this.factorCache.get(userCacheKey) ?? await this.factorCache.fetch(userCacheKey, async () => { + const role = await this.roleService.getUserPolicies(userRoleKey); + return role.rateLimitFactor; + }); + + if (factor === 0) { return disabledLimitInfo; } @@ -42,10 +73,6 @@ export class SkRateLimiterService { throw new Error(`Rate limit factor is zero or negative: ${factor}`); } - return await this.tryLimit(limit, actor, factor); - } - - private async tryLimit(limit: Keyed<RateLimit>, actor: string, factor: number): Promise<LimitInfo> { if (isLegacyRateLimit(limit)) { return await this.limitLegacy(limit, actor, factor); } else { diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 03f25a51fe..5ce358d68f 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.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/api/SkRateLimiterService.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { ApiError } from './error.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; @@ -313,35 +313,30 @@ export class ApiCallService implements OnApplicationShutdown { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (endpointLimit) { // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. - let limitActor: string; + let limitActor: string | MiLocalUser; if (user) { - limitActor = user.id; + limitActor = user; } else { limitActor = getIpHash(request.ip); } - // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい - const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; + const limit = { + key: ep.name, + ...endpointLimit, + }; - if (factor > 0) { - const limit = { - key: ep.name, - ...endpointLimit, - }; + // Rate limit + const info = await this.rateLimiterService.limit(limit, limitActor); - // Rate limit - const info = await this.rateLimiterService.limit(limit, limitActor, factor); + sendRateLimitHeaders(reply, info); - sendRateLimitHeaders(reply, info); - - if (info.blocked) { - throw new ApiError({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - httpStatusCode: 429, - }, info); - } + if (info.blocked) { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }, info); } } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index e319d6e0a4..9cfb2f0ac0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -6,816 +6,13 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; -import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; -import * as ep___admin_abuseReport_notificationRecipient_show from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; -import * as ep___admin_abuseReport_notificationRecipient_create from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; -import * as ep___admin_abuseReport_notificationRecipient_update from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; -import * as ep___admin_abuseReport_notificationRecipient_delete from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; -import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; -import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; -import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; -import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; -import * as ep___admin_ad_create from './endpoints/admin/ad/create.js'; -import * as ep___admin_ad_delete from './endpoints/admin/ad/delete.js'; -import * as ep___admin_ad_list from './endpoints/admin/ad/list.js'; -import * as ep___admin_ad_update from './endpoints/admin/ad/update.js'; -import * as ep___admin_announcements_create from './endpoints/admin/announcements/create.js'; -import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; -import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; -import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; -import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; -import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; -import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; -import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; -import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; -import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; -import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; -import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; -import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; -import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; -import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; -import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; -import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; -import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; -import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; -import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js'; -import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js'; -import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; -import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; -import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; -import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; -import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; -import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; -import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; -import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; -import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; -import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; -import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; -import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; -import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; -import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; -import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; -import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; -import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; -import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; -import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; -import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; -import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; -import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; -import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; -import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; -import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; -import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js'; -import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js'; -import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; -import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; -import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; -import * as ep___admin_showUser from './endpoints/admin/show-user.js'; -import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; -import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; -import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; -import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; -import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_approveUser from './endpoints/admin/approve-user.js'; -import * as ep___admin_declineUser from './endpoints/admin/decline-user.js'; -import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; -import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; -import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; -import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; -import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; -import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; -import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; -import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; -import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; -import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; -import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; -import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; -import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; -import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; -import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; -import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; -import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; -import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; -import * as ep___announcements from './endpoints/announcements.js'; -import * as ep___announcements_show from './endpoints/announcements/show.js'; -import * as ep___antennas_create from './endpoints/antennas/create.js'; -import * as ep___antennas_delete from './endpoints/antennas/delete.js'; -import * as ep___antennas_list from './endpoints/antennas/list.js'; -import * as ep___antennas_notes from './endpoints/antennas/notes.js'; -import * as ep___antennas_show from './endpoints/antennas/show.js'; -import * as ep___antennas_update from './endpoints/antennas/update.js'; -import * as ep___ap_get from './endpoints/ap/get.js'; -import * as ep___ap_show from './endpoints/ap/show.js'; -import * as ep___app_create from './endpoints/app/create.js'; -import * as ep___app_show from './endpoints/app/show.js'; -import * as ep___auth_accept from './endpoints/auth/accept.js'; -import * as ep___auth_session_generate from './endpoints/auth/session/generate.js'; -import * as ep___auth_session_show from './endpoints/auth/session/show.js'; -import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js'; -import * as ep___blocking_create from './endpoints/blocking/create.js'; -import * as ep___blocking_delete from './endpoints/blocking/delete.js'; -import * as ep___blocking_list from './endpoints/blocking/list.js'; -import * as ep___channels_create from './endpoints/channels/create.js'; -import * as ep___channels_featured from './endpoints/channels/featured.js'; -import * as ep___channels_follow from './endpoints/channels/follow.js'; -import * as ep___channels_followed from './endpoints/channels/followed.js'; -import * as ep___channels_owned from './endpoints/channels/owned.js'; -import * as ep___channels_show from './endpoints/channels/show.js'; -import * as ep___channels_timeline from './endpoints/channels/timeline.js'; -import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; -import * as ep___channels_update from './endpoints/channels/update.js'; -import * as ep___channels_favorite from './endpoints/channels/favorite.js'; -import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; -import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; -import * as ep___channels_search from './endpoints/channels/search.js'; -import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; -import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; -import * as ep___charts_drive from './endpoints/charts/drive.js'; -import * as ep___charts_federation from './endpoints/charts/federation.js'; -import * as ep___charts_instance from './endpoints/charts/instance.js'; -import * as ep___charts_notes from './endpoints/charts/notes.js'; -import * as ep___charts_user_drive from './endpoints/charts/user/drive.js'; -import * as ep___charts_user_following from './endpoints/charts/user/following.js'; -import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; -import * as ep___charts_user_pv from './endpoints/charts/user/pv.js'; -import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; -import * as ep___charts_users from './endpoints/charts/users.js'; -import * as ep___clips_addNote from './endpoints/clips/add-note.js'; -import * as ep___clips_removeNote from './endpoints/clips/remove-note.js'; -import * as ep___clips_create from './endpoints/clips/create.js'; -import * as ep___clips_delete from './endpoints/clips/delete.js'; -import * as ep___clips_list from './endpoints/clips/list.js'; -import * as ep___clips_notes from './endpoints/clips/notes.js'; -import * as ep___clips_show from './endpoints/clips/show.js'; -import * as ep___clips_update from './endpoints/clips/update.js'; -import * as ep___clips_favorite from './endpoints/clips/favorite.js'; -import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; -import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; -import * as ep___drive from './endpoints/drive.js'; -import * as ep___drive_files from './endpoints/drive/files.js'; -import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; -import * as ep___drive_files_checkExistence from './endpoints/drive/files/check-existence.js'; -import * as ep___drive_files_create from './endpoints/drive/files/create.js'; -import * as ep___drive_files_delete from './endpoints/drive/files/delete.js'; -import * as ep___drive_files_findByHash from './endpoints/drive/files/find-by-hash.js'; -import * as ep___drive_files_find from './endpoints/drive/files/find.js'; -import * as ep___drive_files_show from './endpoints/drive/files/show.js'; -import * as ep___drive_files_update from './endpoints/drive/files/update.js'; -import * as ep___drive_files_uploadFromUrl from './endpoints/drive/files/upload-from-url.js'; -import * as ep___drive_folders from './endpoints/drive/folders.js'; -import * as ep___drive_folders_create from './endpoints/drive/folders/create.js'; -import * as ep___drive_folders_delete from './endpoints/drive/folders/delete.js'; -import * as ep___drive_folders_find from './endpoints/drive/folders/find.js'; -import * as ep___drive_folders_show from './endpoints/drive/folders/show.js'; -import * as ep___drive_folders_update from './endpoints/drive/folders/update.js'; -import * as ep___drive_stream from './endpoints/drive/stream.js'; -import * as ep___emailAddress_available from './endpoints/email-address/available.js'; -import * as ep___endpoint from './endpoints/endpoint.js'; -import * as ep___endpoints from './endpoints/endpoints.js'; -import * as ep___exportCustomEmojis from './endpoints/export-custom-emojis.js'; -import * as ep___federation_followers from './endpoints/federation/followers.js'; -import * as ep___federation_following from './endpoints/federation/following.js'; -import * as ep___federation_instances from './endpoints/federation/instances.js'; -import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; -import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; -import * as ep___federation_users from './endpoints/federation/users.js'; -import * as ep___federation_stats from './endpoints/federation/stats.js'; -import * as ep___following_create from './endpoints/following/create.js'; -import * as ep___following_delete from './endpoints/following/delete.js'; -import * as ep___following_update from './endpoints/following/update.js'; -import * as ep___following_update_all from './endpoints/following/update-all.js'; -import * as ep___following_invalidate from './endpoints/following/invalidate.js'; -import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; -import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; -import * as ep___following_requests_list from './endpoints/following/requests/list.js'; -import * as ep___following_requests_sent from './endpoints/following/requests/sent.js'; -import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; -import * as ep___gallery_featured from './endpoints/gallery/featured.js'; -import * as ep___gallery_popular from './endpoints/gallery/popular.js'; -import * as ep___gallery_posts from './endpoints/gallery/posts.js'; -import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js'; -import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js'; -import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js'; -import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; -import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; -import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; -import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; -import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; -import * as ep___hashtags_list from './endpoints/hashtags/list.js'; -import * as ep___hashtags_search from './endpoints/hashtags/search.js'; -import * as ep___hashtags_show from './endpoints/hashtags/show.js'; -import * as ep___hashtags_trend from './endpoints/hashtags/trend.js'; -import * as ep___hashtags_users from './endpoints/hashtags/users.js'; -import * as ep___i from './endpoints/i.js'; -import * as ep___i_2fa_done from './endpoints/i/2fa/done.js'; -import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; -import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; -import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; -import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; -import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; -import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; -import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; -import * as ep___i_apps from './endpoints/i/apps.js'; -import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; -import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; -import * as ep___i_changePassword from './endpoints/i/change-password.js'; -import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; -import * as ep___i_exportData from './endpoints/i/export-data.js'; -import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; -import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; -import * as ep___i_exportMute from './endpoints/i/export-mute.js'; -import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; -import * as ep___i_exportClips from './endpoints/i/export-clips.js'; -import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; -import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; -import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; -import * as ep___i_favorites from './endpoints/i/favorites.js'; -import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; -import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; -import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; -import * as ep___i_importFollowing from './endpoints/i/import-following.js'; -import * as ep___i_importNotes from './endpoints/i/import-notes.js'; -import * as ep___i_importMuting from './endpoints/i/import-muting.js'; -import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; -import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; -import * as ep___i_notifications from './endpoints/i/notifications.js'; -import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; -import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; -import * as ep___i_pages from './endpoints/i/pages.js'; -import * as ep___i_pin from './endpoints/i/pin.js'; -import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; -import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; -import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; -import * as ep___i_registry_getAll from './endpoints/i/registry/get-all.js'; -import * as ep___i_registry_getUnsecure from './endpoints/i/registry/get-unsecure.js'; -import * as ep___i_registry_getDetail from './endpoints/i/registry/get-detail.js'; -import * as ep___i_registry_get from './endpoints/i/registry/get.js'; -import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; -import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; -import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; -import * as ep___i_registry_set from './endpoints/i/registry/set.js'; -import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; -import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; -import * as ep___i_unpin from './endpoints/i/unpin.js'; -import * as ep___i_updateEmail from './endpoints/i/update-email.js'; -import * as ep___i_update from './endpoints/i/update.js'; -import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; -import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; -import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; -import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; -import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; -import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; -import * as ep___invite_create from './endpoints/invite/create.js'; -import * as ep___invite_delete from './endpoints/invite/delete.js'; -import * as ep___invite_list from './endpoints/invite/list.js'; -import * as ep___invite_limit from './endpoints/invite/limit.js'; -import * as ep___meta from './endpoints/meta.js'; -import * as ep___emojis from './endpoints/emojis.js'; -import * as ep___emoji from './endpoints/emoji.js'; -import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; -import * as ep___mute_create from './endpoints/mute/create.js'; -import * as ep___mute_delete from './endpoints/mute/delete.js'; -import * as ep___mute_list from './endpoints/mute/list.js'; -import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; -import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; -import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; -import * as ep___my_apps from './endpoints/my/apps.js'; -import * as ep___notes from './endpoints/notes.js'; -import * as ep___notes_children from './endpoints/notes/children.js'; -import * as ep___notes_clips from './endpoints/notes/clips.js'; -import * as ep___notes_conversation from './endpoints/notes/conversation.js'; -import * as ep___notes_create from './endpoints/notes/create.js'; -import * as ep___notes_delete from './endpoints/notes/delete.js'; -import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; -import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; -import * as ep___notes_featured from './endpoints/notes/featured.js'; -import * as ep___notes_following from './endpoints/notes/following.js'; -import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; -import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-timeline.js'; -import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; -import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; -import * as ep___notes_mentions from './endpoints/notes/mentions.js'; -import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; -import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; -import * as ep___notes_polls_refresh from './endpoints/notes/polls/refresh.js'; -import * as ep___notes_reactions from './endpoints/notes/reactions.js'; -import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; -import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; -import * as ep___notes_like from './endpoints/notes/like.js'; -import * as ep___notes_renotes from './endpoints/notes/renotes.js'; -import * as ep___notes_replies from './endpoints/notes/replies.js'; -import * as ep___notes_edit from './endpoints/notes/edit.js'; -import * as ep___notes_versions from './endpoints/notes/versions.js'; -import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; -import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; -import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; -import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; -import * as ep___notes_search from './endpoints/notes/search.js'; -import * as ep___notes_show from './endpoints/notes/show.js'; -import * as ep___notes_state from './endpoints/notes/state.js'; -import * as ep___notes_threadMuting_create from './endpoints/notes/thread-muting/create.js'; -import * as ep___notes_threadMuting_delete from './endpoints/notes/thread-muting/delete.js'; -import * as ep___notes_timeline from './endpoints/notes/timeline.js'; -import * as ep___notes_translate from './endpoints/notes/translate.js'; -import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; -import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; -import * as ep___notifications_create from './endpoints/notifications/create.js'; -import * as ep___notifications_flush from './endpoints/notifications/flush.js'; -import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; -import * as ep___pagePush from './endpoints/page-push.js'; -import * as ep___pages_create from './endpoints/pages/create.js'; -import * as ep___pages_delete from './endpoints/pages/delete.js'; -import * as ep___pages_featured from './endpoints/pages/featured.js'; -import * as ep___pages_like from './endpoints/pages/like.js'; -import * as ep___pages_show from './endpoints/pages/show.js'; -import * as ep___pages_unlike from './endpoints/pages/unlike.js'; -import * as ep___pages_update from './endpoints/pages/update.js'; -import * as ep___flash_create from './endpoints/flash/create.js'; -import * as ep___flash_delete from './endpoints/flash/delete.js'; -import * as ep___flash_featured from './endpoints/flash/featured.js'; -import * as ep___flash_like from './endpoints/flash/like.js'; -import * as ep___flash_show from './endpoints/flash/show.js'; -import * as ep___flash_unlike from './endpoints/flash/unlike.js'; -import * as ep___flash_update from './endpoints/flash/update.js'; -import * as ep___flash_my from './endpoints/flash/my.js'; -import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; -import * as ep___ping from './endpoints/ping.js'; -import * as ep___pinnedUsers from './endpoints/pinned-users.js'; -import * as ep___promo_read from './endpoints/promo/read.js'; -import * as ep___roles_list from './endpoints/roles/list.js'; -import * as ep___roles_show from './endpoints/roles/show.js'; -import * as ep___roles_users from './endpoints/roles/users.js'; -import * as ep___roles_notes from './endpoints/roles/notes.js'; -import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; -import * as ep___resetDb from './endpoints/reset-db.js'; -import * as ep___resetPassword from './endpoints/reset-password.js'; -import * as ep___serverInfo from './endpoints/server-info.js'; -import * as ep___stats from './endpoints/stats.js'; -import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; -import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; -import * as ep___sw_register from './endpoints/sw/register.js'; -import * as ep___sw_unregister from './endpoints/sw/unregister.js'; -import * as ep___test from './endpoints/test.js'; -import * as ep___username_available from './endpoints/username/available.js'; -import * as ep___users from './endpoints/users.js'; -import * as ep___users_clips from './endpoints/users/clips.js'; -import * as ep___users_followers from './endpoints/users/followers.js'; -import * as ep___users_following from './endpoints/users/following.js'; -import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; -import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; -import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; -import * as ep___users_lists_create from './endpoints/users/lists/create.js'; -import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; -import * as ep___users_lists_list from './endpoints/users/lists/list.js'; -import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; -import * as ep___users_lists_push from './endpoints/users/lists/push.js'; -import * as ep___users_lists_show from './endpoints/users/lists/show.js'; -import * as ep___users_lists_update from './endpoints/users/lists/update.js'; -import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; -import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; -import * as ep___users_notes from './endpoints/users/notes.js'; -import * as ep___users_pages from './endpoints/users/pages.js'; -import * as ep___users_flashs from './endpoints/users/flashs.js'; -import * as ep___users_reactions from './endpoints/users/reactions.js'; -import * as ep___users_recommendation from './endpoints/users/recommendation.js'; -import * as ep___users_relation from './endpoints/users/relation.js'; -import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; -import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; -import * as ep___users_search from './endpoints/users/search.js'; -import * as ep___users_show from './endpoints/users/show.js'; -import * as ep___users_achievements from './endpoints/users/achievements.js'; -import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; -import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; -import * as ep___retention from './endpoints/retention.js'; -import * as ep___sponsors from './endpoints/sponsors.js'; -import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js'; -import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js'; -import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js'; -import * as ep___reversi_games from './endpoints/reversi/games.js'; -import * as ep___reversi_match from './endpoints/reversi/match.js'; -import * as ep___reversi_invitations from './endpoints/reversi/invitations.js'; -import * as ep___reversi_showGame from './endpoints/reversi/show-game.js'; -import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; -import * as ep___reversi_verify from './endpoints/reversi/verify.js'; +import * as endpointsObject from './endpoint-list.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import type { Provider } from '@nestjs/common'; -const $admin_meta: Provider = { provide: 'ep:admin/meta', useClass: ep___admin_meta.default }; -const $admin_abuseUserReports: Provider = { provide: 'ep:admin/abuse-user-reports', useClass: ep___admin_abuseUserReports.default }; -const $admin_abuseReport_notificationRecipient_list: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/list', useClass: ep___admin_abuseReport_notificationRecipient_list.default }; -const $admin_abuseReport_notificationRecipient_show: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/show', useClass: ep___admin_abuseReport_notificationRecipient_show.default }; -const $admin_abuseReport_notificationRecipient_create: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/create', useClass: ep___admin_abuseReport_notificationRecipient_create.default }; -const $admin_abuseReport_notificationRecipient_update: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/update', useClass: ep___admin_abuseReport_notificationRecipient_update.default }; -const $admin_abuseReport_notificationRecipient_delete: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/delete', useClass: ep___admin_abuseReport_notificationRecipient_delete.default }; -const $admin_accounts_create: Provider = { provide: 'ep:admin/accounts/create', useClass: ep___admin_accounts_create.default }; -const $admin_accounts_delete: Provider = { provide: 'ep:admin/accounts/delete', useClass: ep___admin_accounts_delete.default }; -const $admin_accounts_findByEmail: Provider = { provide: 'ep:admin/accounts/find-by-email', useClass: ep___admin_accounts_findByEmail.default }; -const $admin_ad_create: Provider = { provide: 'ep:admin/ad/create', useClass: ep___admin_ad_create.default }; -const $admin_ad_delete: Provider = { provide: 'ep:admin/ad/delete', useClass: ep___admin_ad_delete.default }; -const $admin_ad_list: Provider = { provide: 'ep:admin/ad/list', useClass: ep___admin_ad_list.default }; -const $admin_ad_update: Provider = { provide: 'ep:admin/ad/update', useClass: ep___admin_ad_update.default }; -const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements/create', useClass: ep___admin_announcements_create.default }; -const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default }; -const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default }; -const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default }; -const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-decorations/create', useClass: ep___admin_avatarDecorations_create.default }; -const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; -const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; -const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; -const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; -const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; -const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; -const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; -const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; -const $admin_drive_files: Provider = { provide: 'ep:admin/drive/files', useClass: ep___admin_drive_files.default }; -const $admin_drive_showFile: Provider = { provide: 'ep:admin/drive/show-file', useClass: ep___admin_drive_showFile.default }; -const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-aliases-bulk', useClass: ep___admin_emoji_addAliasesBulk.default }; -const $admin_emoji_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.default }; -const $admin_emoji_copy: Provider = { provide: 'ep:admin/emoji/copy', useClass: ep___admin_emoji_copy.default }; -const $admin_emoji_deleteBulk: Provider = { provide: 'ep:admin/emoji/delete-bulk', useClass: ep___admin_emoji_deleteBulk.default }; -const $admin_emoji_delete: Provider = { provide: 'ep:admin/emoji/delete', useClass: ep___admin_emoji_delete.default }; -const $admin_emoji_importZip: Provider = { provide: 'ep:admin/emoji/import-zip', useClass: ep___admin_emoji_importZip.default }; -const $admin_emoji_listRemote: Provider = { provide: 'ep:admin/emoji/list-remote', useClass: ep___admin_emoji_listRemote.default }; -const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: ep___admin_emoji_list.default }; -const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default }; -const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default }; -const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; -const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; -const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; -const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; -const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; -const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; -const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federation/update-instance', useClass: ep___admin_federation_updateInstance.default }; -const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default }; -const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default }; -const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default }; -const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default }; -const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default }; -const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default }; -const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; -const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; -const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; -const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default }; -const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; -const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; -const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; -const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default }; -const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default }; -const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default }; -const $admin_forwardAbuseUserReport: Provider = { provide: 'ep:admin/forward-abuse-user-report', useClass: ep___admin_forwardAbuseUserReport.default }; -const $admin_updateAbuseUserReport: Provider = { provide: 'ep:admin/update-abuse-user-report', useClass: ep___admin_updateAbuseUserReport.default }; -const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; -const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; -const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; -const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default }; -const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; -const $admin_nsfwUser: Provider = { provide: 'ep:admin/nsfw-user', useClass: ep___admin_nsfwUser.default }; -const $admin_unnsfwUser: Provider = { provide: 'ep:admin/unnsfw-user', useClass: ep___admin_unnsfwUser.default }; -const $admin_silenceUser: Provider = { provide: 'ep:admin/silence-user', useClass: ep___admin_silenceUser.default }; -const $admin_unsilenceUser: Provider = { provide: 'ep:admin/unsilence-user', useClass: ep___admin_unsilenceUser.default }; -const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; -const $admin_approveUser: Provider = { provide: 'ep:admin/approve-user', useClass: ep___admin_approveUser.default }; -const $admin_declineUser: Provider = { provide: 'ep:admin/decline-user', useClass: ep___admin_declineUser.default }; -const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; -const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; -const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default }; -const $admin_updateUserNote: Provider = { provide: 'ep:admin/update-user-note', useClass: ep___admin_updateUserNote.default }; -const $admin_roles_create: Provider = { provide: 'ep:admin/roles/create', useClass: ep___admin_roles_create.default }; -const $admin_roles_delete: Provider = { provide: 'ep:admin/roles/delete', useClass: ep___admin_roles_delete.default }; -const $admin_roles_list: Provider = { provide: 'ep:admin/roles/list', useClass: ep___admin_roles_list.default }; -const $admin_roles_show: Provider = { provide: 'ep:admin/roles/show', useClass: ep___admin_roles_show.default }; -const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useClass: ep___admin_roles_update.default }; -const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default }; -const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; -const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; -const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default }; -const $admin_systemWebhook_create: Provider = { provide: 'ep:admin/system-webhook/create', useClass: ep___admin_systemWebhook_create.default }; -const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhook/delete', useClass: ep___admin_systemWebhook_delete.default }; -const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default }; -const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; -const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; -const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default }; -const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; -const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; -const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; -const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; -const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default }; -const $antennas_notes: Provider = { provide: 'ep:antennas/notes', useClass: ep___antennas_notes.default }; -const $antennas_show: Provider = { provide: 'ep:antennas/show', useClass: ep___antennas_show.default }; -const $antennas_update: Provider = { provide: 'ep:antennas/update', useClass: ep___antennas_update.default }; -const $ap_get: Provider = { provide: 'ep:ap/get', useClass: ep___ap_get.default }; -const $ap_show: Provider = { provide: 'ep:ap/show', useClass: ep___ap_show.default }; -const $app_create: Provider = { provide: 'ep:app/create', useClass: ep___app_create.default }; -const $app_show: Provider = { provide: 'ep:app/show', useClass: ep___app_show.default }; -const $auth_accept: Provider = { provide: 'ep:auth/accept', useClass: ep___auth_accept.default }; -const $auth_session_generate: Provider = { provide: 'ep:auth/session/generate', useClass: ep___auth_session_generate.default }; -const $auth_session_show: Provider = { provide: 'ep:auth/session/show', useClass: ep___auth_session_show.default }; -const $auth_session_userkey: Provider = { provide: 'ep:auth/session/userkey', useClass: ep___auth_session_userkey.default }; -const $blocking_create: Provider = { provide: 'ep:blocking/create', useClass: ep___blocking_create.default }; -const $blocking_delete: Provider = { provide: 'ep:blocking/delete', useClass: ep___blocking_delete.default }; -const $blocking_list: Provider = { provide: 'ep:blocking/list', useClass: ep___blocking_list.default }; -const $channels_create: Provider = { provide: 'ep:channels/create', useClass: ep___channels_create.default }; -const $channels_featured: Provider = { provide: 'ep:channels/featured', useClass: ep___channels_featured.default }; -const $channels_follow: Provider = { provide: 'ep:channels/follow', useClass: ep___channels_follow.default }; -const $channels_followed: Provider = { provide: 'ep:channels/followed', useClass: ep___channels_followed.default }; -const $channels_owned: Provider = { provide: 'ep:channels/owned', useClass: ep___channels_owned.default }; -const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___channels_show.default }; -const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; -const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; -const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; -const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; -const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; -const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; -const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default }; -const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; -const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; -const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; -const $charts_federation: Provider = { provide: 'ep:charts/federation', useClass: ep___charts_federation.default }; -const $charts_instance: Provider = { provide: 'ep:charts/instance', useClass: ep___charts_instance.default }; -const $charts_notes: Provider = { provide: 'ep:charts/notes', useClass: ep___charts_notes.default }; -const $charts_user_drive: Provider = { provide: 'ep:charts/user/drive', useClass: ep___charts_user_drive.default }; -const $charts_user_following: Provider = { provide: 'ep:charts/user/following', useClass: ep___charts_user_following.default }; -const $charts_user_notes: Provider = { provide: 'ep:charts/user/notes', useClass: ep___charts_user_notes.default }; -const $charts_user_pv: Provider = { provide: 'ep:charts/user/pv', useClass: ep___charts_user_pv.default }; -const $charts_user_reactions: Provider = { provide: 'ep:charts/user/reactions', useClass: ep___charts_user_reactions.default }; -const $charts_users: Provider = { provide: 'ep:charts/users', useClass: ep___charts_users.default }; -const $clips_addNote: Provider = { provide: 'ep:clips/add-note', useClass: ep___clips_addNote.default }; -const $clips_removeNote: Provider = { provide: 'ep:clips/remove-note', useClass: ep___clips_removeNote.default }; -const $clips_create: Provider = { provide: 'ep:clips/create', useClass: ep___clips_create.default }; -const $clips_delete: Provider = { provide: 'ep:clips/delete', useClass: ep___clips_delete.default }; -const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_list.default }; -const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; -const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; -const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; -const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default }; -const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default }; -const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default }; -const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; -const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; -const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; -const $drive_files_checkExistence: Provider = { provide: 'ep:drive/files/check-existence', useClass: ep___drive_files_checkExistence.default }; -const $drive_files_create: Provider = { provide: 'ep:drive/files/create', useClass: ep___drive_files_create.default }; -const $drive_files_delete: Provider = { provide: 'ep:drive/files/delete', useClass: ep___drive_files_delete.default }; -const $drive_files_findByHash: Provider = { provide: 'ep:drive/files/find-by-hash', useClass: ep___drive_files_findByHash.default }; -const $drive_files_find: Provider = { provide: 'ep:drive/files/find', useClass: ep___drive_files_find.default }; -const $drive_files_show: Provider = { provide: 'ep:drive/files/show', useClass: ep___drive_files_show.default }; -const $drive_files_update: Provider = { provide: 'ep:drive/files/update', useClass: ep___drive_files_update.default }; -const $drive_files_uploadFromUrl: Provider = { provide: 'ep:drive/files/upload-from-url', useClass: ep___drive_files_uploadFromUrl.default }; -const $drive_folders: Provider = { provide: 'ep:drive/folders', useClass: ep___drive_folders.default }; -const $drive_folders_create: Provider = { provide: 'ep:drive/folders/create', useClass: ep___drive_folders_create.default }; -const $drive_folders_delete: Provider = { provide: 'ep:drive/folders/delete', useClass: ep___drive_folders_delete.default }; -const $drive_folders_find: Provider = { provide: 'ep:drive/folders/find', useClass: ep___drive_folders_find.default }; -const $drive_folders_show: Provider = { provide: 'ep:drive/folders/show', useClass: ep___drive_folders_show.default }; -const $drive_folders_update: Provider = { provide: 'ep:drive/folders/update', useClass: ep___drive_folders_update.default }; -const $drive_stream: Provider = { provide: 'ep:drive/stream', useClass: ep___drive_stream.default }; -const $emailAddress_available: Provider = { provide: 'ep:email-address/available', useClass: ep___emailAddress_available.default }; -const $endpoint: Provider = { provide: 'ep:endpoint', useClass: ep___endpoint.default }; -const $endpoints: Provider = { provide: 'ep:endpoints', useClass: ep___endpoints.default }; -const $exportCustomEmojis: Provider = { provide: 'ep:export-custom-emojis', useClass: ep___exportCustomEmojis.default }; -const $federation_followers: Provider = { provide: 'ep:federation/followers', useClass: ep___federation_followers.default }; -const $federation_following: Provider = { provide: 'ep:federation/following', useClass: ep___federation_following.default }; -const $federation_instances: Provider = { provide: 'ep:federation/instances', useClass: ep___federation_instances.default }; -const $federation_showInstance: Provider = { provide: 'ep:federation/show-instance', useClass: ep___federation_showInstance.default }; -const $federation_updateRemoteUser: Provider = { provide: 'ep:federation/update-remote-user', useClass: ep___federation_updateRemoteUser.default }; -const $federation_users: Provider = { provide: 'ep:federation/users', useClass: ep___federation_users.default }; -const $federation_stats: Provider = { provide: 'ep:federation/stats', useClass: ep___federation_stats.default }; -const $following_create: Provider = { provide: 'ep:following/create', useClass: ep___following_create.default }; -const $following_delete: Provider = { provide: 'ep:following/delete', useClass: ep___following_delete.default }; -const $following_update: Provider = { provide: 'ep:following/update', useClass: ep___following_update.default }; -const $following_update_all: Provider = { provide: 'ep:following/update-all', useClass: ep___following_update_all.default }; -const $following_invalidate: Provider = { provide: 'ep:following/invalidate', useClass: ep___following_invalidate.default }; -const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default }; -const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default }; -const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default }; -const $following_requests_sent: Provider = { provide: 'ep:following/requests/sent', useClass: ep___following_requests_sent.default }; -const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default }; -const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default }; -const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default }; -const $gallery_posts: Provider = { provide: 'ep:gallery/posts', useClass: ep___gallery_posts.default }; -const $gallery_posts_create: Provider = { provide: 'ep:gallery/posts/create', useClass: ep___gallery_posts_create.default }; -const $gallery_posts_delete: Provider = { provide: 'ep:gallery/posts/delete', useClass: ep___gallery_posts_delete.default }; -const $gallery_posts_like: Provider = { provide: 'ep:gallery/posts/like', useClass: ep___gallery_posts_like.default }; -const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useClass: ep___gallery_posts_show.default }; -const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default }; -const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default }; -const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default }; -const $getAvatarDecorations: Provider = { provide: 'ep:get-avatar-decorations', useClass: ep___getAvatarDecorations.default }; -const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default }; -const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default }; -const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default }; -const $hashtags_trend: Provider = { provide: 'ep:hashtags/trend', useClass: ep___hashtags_trend.default }; -const $hashtags_users: Provider = { provide: 'ep:hashtags/users', useClass: ep___hashtags_users.default }; -const $i: Provider = { provide: 'ep:i', useClass: ep___i.default }; -const $i_2fa_done: Provider = { provide: 'ep:i/2fa/done', useClass: ep___i_2fa_done.default }; -const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___i_2fa_keyDone.default }; -const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default }; -const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default }; -const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default }; -const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default }; -const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default }; -const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; -const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; -const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default }; -const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default }; -const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default }; -const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default }; -const $i_exportData: Provider = { provide: 'ep:i/export-data', useClass: ep___i_exportData.default }; -const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default }; -const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default }; -const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default }; -const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default }; -const $i_exportClips: Provider = { provide: 'ep:i/export-clips', useClass: ep___i_exportClips.default }; -const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default }; -const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default }; -const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default }; -const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; -const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; -const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; -const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default }; -const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; -const $i_importNotes: Provider = { provide: 'ep:i/import-notes', useClass: ep___i_importNotes.default }; -const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; -const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; -const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; -const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; -const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default }; -const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; -const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; -const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; -const $i_readAllUnreadNotes: Provider = { provide: 'ep:i/read-all-unread-notes', useClass: ep___i_readAllUnreadNotes.default }; -const $i_readAnnouncement: Provider = { provide: 'ep:i/read-announcement', useClass: ep___i_readAnnouncement.default }; -const $i_regenerateToken: Provider = { provide: 'ep:i/regenerate-token', useClass: ep___i_regenerateToken.default }; -const $i_registry_getAll: Provider = { provide: 'ep:i/registry/get-all', useClass: ep___i_registry_getAll.default }; -const $i_registry_getUnsecure: Provider = { provide: 'ep:i/registry/get-unsecure', useClass: ep___i_registry_getUnsecure.default }; -const $i_registry_getDetail: Provider = { provide: 'ep:i/registry/get-detail', useClass: ep___i_registry_getDetail.default }; -const $i_registry_get: Provider = { provide: 'ep:i/registry/get', useClass: ep___i_registry_get.default }; -const $i_registry_keysWithType: Provider = { provide: 'ep:i/registry/keys-with-type', useClass: ep___i_registry_keysWithType.default }; -const $i_registry_keys: Provider = { provide: 'ep:i/registry/keys', useClass: ep___i_registry_keys.default }; -const $i_registry_remove: Provider = { provide: 'ep:i/registry/remove', useClass: ep___i_registry_remove.default }; -const $i_registry_scopesWithDomain: Provider = { provide: 'ep:i/registry/scopes-with-domain', useClass: ep___i_registry_scopesWithDomain.default }; -const $i_registry_set: Provider = { provide: 'ep:i/registry/set', useClass: ep___i_registry_set.default }; -const $i_revokeToken: Provider = { provide: 'ep:i/revoke-token', useClass: ep___i_revokeToken.default }; -const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: ep___i_signinHistory.default }; -const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; -const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; -const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; -const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; -const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; -const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; -const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; -const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; -const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; -const $i_webhooks_test: Provider = { provide: 'ep:i/webhooks/test', useClass: ep___i_webhooks_test.default }; -const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default }; -const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default }; -const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default }; -const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default }; -const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; -const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; -const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; -const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; -const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; -const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; -const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; -const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; -const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; -const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default }; -const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; -const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; -const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; -const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; -const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; -const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; -const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; -const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; -const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; -const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; -const $notes_following: Provider = { provide: 'ep:notes/following', useClass: ep___notes_following.default }; -const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default }; -const $notes_bubbleTimeline: Provider = { provide: 'ep:notes/bubble-timeline', useClass: ep___notes_bubbleTimeline.default }; -const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default }; -const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default }; -const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; -const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; -const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default }; -const $notes_polls_refresh: Provider = { provide: 'ep:notes/polls/refresh', useClass: ep___notes_polls_refresh.default }; -const $notes_reactions: Provider = { provide: 'ep:notes/reactions', useClass: ep___notes_reactions.default }; -const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create', useClass: ep___notes_reactions_create.default }; -const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default }; -const $notes_like: Provider = { provide: 'ep:notes/like', useClass: ep___notes_like.default }; -const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; -const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; -const $notes_schedule_create: Provider = { provide: 'ep:notes/schedule/create', useClass: ep___notes_schedule_create.default }; -const $notes_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default }; -const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default }; -const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; -const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; -const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; -const $notes_state: Provider = { provide: 'ep:notes/state', useClass: ep___notes_state.default }; -const $notes_threadMuting_create: Provider = { provide: 'ep:notes/thread-muting/create', useClass: ep___notes_threadMuting_create.default }; -const $notes_threadMuting_delete: Provider = { provide: 'ep:notes/thread-muting/delete', useClass: ep___notes_threadMuting_delete.default }; -const $notes_timeline: Provider = { provide: 'ep:notes/timeline', useClass: ep___notes_timeline.default }; -const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep___notes_translate.default }; -const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default }; -const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; -const $notes_edit: Provider = { provide: 'ep:notes/edit', useClass: ep___notes_edit.default }; -const $notes_versions: Provider = { provide: 'ep:notes/versions', useClass: ep___notes_versions.default }; -const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; -const $notifications_flush: Provider = { provide: 'ep:notifications/flush', useClass: ep___notifications_flush.default }; -const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; -const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default }; -const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; -const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; -const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; -const $pages_featured: Provider = { provide: 'ep:pages/featured', useClass: ep___pages_featured.default }; -const $pages_like: Provider = { provide: 'ep:pages/like', useClass: ep___pages_like.default }; -const $pages_show: Provider = { provide: 'ep:pages/show', useClass: ep___pages_show.default }; -const $pages_unlike: Provider = { provide: 'ep:pages/unlike', useClass: ep___pages_unlike.default }; -const $pages_update: Provider = { provide: 'ep:pages/update', useClass: ep___pages_update.default }; -const $flash_create: Provider = { provide: 'ep:flash/create', useClass: ep___flash_create.default }; -const $flash_delete: Provider = { provide: 'ep:flash/delete', useClass: ep___flash_delete.default }; -const $flash_featured: Provider = { provide: 'ep:flash/featured', useClass: ep___flash_featured.default }; -const $flash_like: Provider = { provide: 'ep:flash/like', useClass: ep___flash_like.default }; -const $flash_show: Provider = { provide: 'ep:flash/show', useClass: ep___flash_show.default }; -const $flash_unlike: Provider = { provide: 'ep:flash/unlike', useClass: ep___flash_unlike.default }; -const $flash_update: Provider = { provide: 'ep:flash/update', useClass: ep___flash_update.default }; -const $flash_my: Provider = { provide: 'ep:flash/my', useClass: ep___flash_my.default }; -const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___flash_myLikes.default }; -const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default }; -const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default }; -const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default }; -const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; -const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; -const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; -const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default }; -const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; -const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; -const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; -const $serverInfo: Provider = { provide: 'ep:server-info', useClass: ep___serverInfo.default }; -const $stats: Provider = { provide: 'ep:stats', useClass: ep___stats.default }; -const $sw_show_registration: Provider = { provide: 'ep:sw/show-registration', useClass: ep___sw_show_registration.default }; -const $sw_update_registration: Provider = { provide: 'ep:sw/update-registration', useClass: ep___sw_update_registration.default }; -const $sw_register: Provider = { provide: 'ep:sw/register', useClass: ep___sw_register.default }; -const $sw_unregister: Provider = { provide: 'ep:sw/unregister', useClass: ep___sw_unregister.default }; -const $test: Provider = { provide: 'ep:test', useClass: ep___test.default }; -const $username_available: Provider = { provide: 'ep:username/available', useClass: ep___username_available.default }; -const $users: Provider = { provide: 'ep:users', useClass: ep___users.default }; -const $users_clips: Provider = { provide: 'ep:users/clips', useClass: ep___users_clips.default }; -const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep___users_followers.default }; -const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default }; -const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; -const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; -const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default }; -const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; -const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default }; -const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default }; -const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass: ep___users_lists_pull.default }; -const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default }; -const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default }; -const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; -const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; -const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; -const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default }; -const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default }; -const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default }; -const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; -const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; -const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; -const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default }; -const $users_recommendation: Provider = { provide: 'ep:users/recommendation', useClass: ep___users_recommendation.default }; -const $users_relation: Provider = { provide: 'ep:users/relation', useClass: ep___users_relation.default }; -const $users_reportAbuse: Provider = { provide: 'ep:users/report-abuse', useClass: ep___users_reportAbuse.default }; -const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default }; -const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; -const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; -const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; -const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; -const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; -const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resources', useClass: ep___fetchExternalResources.default }; -const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; -const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.default }; -const $bubbleGame_register: Provider = { provide: 'ep:bubble-game/register', useClass: ep___bubbleGame_register.default }; -const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useClass: ep___bubbleGame_ranking.default }; -const $reversi_cancelMatch: Provider = { provide: 'ep:reversi/cancel-match', useClass: ep___reversi_cancelMatch.default }; -const $reversi_games: Provider = { provide: 'ep:reversi/games', useClass: ep___reversi_games.default }; -const $reversi_match: Provider = { provide: 'ep:reversi/match', useClass: ep___reversi_match.default }; -const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useClass: ep___reversi_invitations.default }; -const $reversi_showGame: Provider = { provide: 'ep:reversi/show-game', useClass: ep___reversi_showGame.default }; -const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass: ep___reversi_surrender.default }; -const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep___reversi_verify.default }; +const endpoints = Object.entries(endpointsObject); +const endpointProviders = endpoints.map(([path, endpoint]): Provider => ({ provide: `ep:${path}`, useClass: endpoint.default })); @Module({ imports: [ @@ -824,811 +21,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ providers: [ GetterService, ApiLoggerService, - $admin_meta, - $admin_abuseUserReports, - $admin_abuseReport_notificationRecipient_list, - $admin_abuseReport_notificationRecipient_show, - $admin_abuseReport_notificationRecipient_create, - $admin_abuseReport_notificationRecipient_update, - $admin_abuseReport_notificationRecipient_delete, - $admin_accounts_create, - $admin_accounts_delete, - $admin_accounts_findByEmail, - $admin_ad_create, - $admin_ad_delete, - $admin_ad_list, - $admin_ad_update, - $admin_announcements_create, - $admin_announcements_delete, - $admin_announcements_list, - $admin_announcements_update, - $admin_avatarDecorations_create, - $admin_avatarDecorations_delete, - $admin_avatarDecorations_list, - $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, - $admin_unsetUserAvatar, - $admin_unsetUserBanner, - $admin_drive_cleanRemoteFiles, - $admin_drive_cleanup, - $admin_drive_files, - $admin_drive_showFile, - $admin_emoji_addAliasesBulk, - $admin_emoji_add, - $admin_emoji_copy, - $admin_emoji_deleteBulk, - $admin_emoji_delete, - $admin_emoji_importZip, - $admin_emoji_listRemote, - $admin_emoji_list, - $admin_emoji_removeAliasesBulk, - $admin_emoji_setAliasesBulk, - $admin_emoji_setCategoryBulk, - $admin_emoji_setLicenseBulk, - $admin_emoji_update, - $admin_federation_deleteAllFiles, - $admin_federation_refreshRemoteInstanceMetadata, - $admin_federation_removeAllFollowing, - $admin_federation_updateInstance, - $admin_getIndexStats, - $admin_getTableStats, - $admin_getUserIps, - $admin_invite_create, - $admin_invite_list, - $admin_promo_create, - $admin_queue_clear, - $admin_queue_deliverDelayed, - $admin_queue_inboxDelayed, - $admin_queue_promote, - $admin_queue_stats, - $admin_relays_add, - $admin_relays_list, - $admin_relays_remove, - $admin_resetPassword, - $admin_resolveAbuseUserReport, - $admin_forwardAbuseUserReport, - $admin_updateAbuseUserReport, - $admin_sendEmail, - $admin_serverInfo, - $admin_showModerationLogs, - $admin_showUser, - $admin_showUsers, - $admin_nsfwUser, - $admin_unnsfwUser, - $admin_silenceUser, - $admin_unsilenceUser, - $admin_suspendUser, - $admin_approveUser, - $admin_declineUser, - $admin_unsuspendUser, - $admin_updateMeta, - $admin_deleteAccount, - $admin_updateUserNote, - $admin_roles_create, - $admin_roles_delete, - $admin_roles_list, - $admin_roles_show, - $admin_roles_update, - $admin_roles_assign, - $admin_roles_unassign, - $admin_roles_updateDefaultPolicies, - $admin_roles_users, - $admin_systemWebhook_create, - $admin_systemWebhook_delete, - $admin_systemWebhook_list, - $admin_systemWebhook_show, - $admin_systemWebhook_update, - $admin_systemWebhook_test, - $announcements, - $announcements_show, - $antennas_create, - $antennas_delete, - $antennas_list, - $antennas_notes, - $antennas_show, - $antennas_update, - $ap_get, - $ap_show, - $app_create, - $app_show, - $auth_accept, - $auth_session_generate, - $auth_session_show, - $auth_session_userkey, - $blocking_create, - $blocking_delete, - $blocking_list, - $channels_create, - $channels_featured, - $channels_follow, - $channels_followed, - $channels_owned, - $channels_show, - $channels_timeline, - $channels_unfollow, - $channels_update, - $channels_favorite, - $channels_unfavorite, - $channels_myFavorites, - $channels_search, - $charts_activeUsers, - $charts_apRequest, - $charts_drive, - $charts_federation, - $charts_instance, - $charts_notes, - $charts_user_drive, - $charts_user_following, - $charts_user_notes, - $charts_user_pv, - $charts_user_reactions, - $charts_users, - $clips_addNote, - $clips_removeNote, - $clips_create, - $clips_delete, - $clips_list, - $clips_notes, - $clips_show, - $clips_update, - $clips_favorite, - $clips_unfavorite, - $clips_myFavorites, - $drive, - $drive_files, - $drive_files_attachedNotes, - $drive_files_checkExistence, - $drive_files_create, - $drive_files_delete, - $drive_files_findByHash, - $drive_files_find, - $drive_files_show, - $drive_files_update, - $drive_files_uploadFromUrl, - $drive_folders, - $drive_folders_create, - $drive_folders_delete, - $drive_folders_find, - $drive_folders_show, - $drive_folders_update, - $drive_stream, - $emailAddress_available, - $endpoint, - $endpoints, - $exportCustomEmojis, - $federation_followers, - $federation_following, - $federation_instances, - $federation_showInstance, - $federation_updateRemoteUser, - $federation_users, - $federation_stats, - $following_create, - $following_delete, - $following_update, - $following_update_all, - $following_invalidate, - $following_requests_accept, - $following_requests_cancel, - $following_requests_list, - $following_requests_sent, - $following_requests_reject, - $gallery_featured, - $gallery_popular, - $gallery_posts, - $gallery_posts_create, - $gallery_posts_delete, - $gallery_posts_like, - $gallery_posts_show, - $gallery_posts_unlike, - $gallery_posts_update, - $getOnlineUsersCount, - $getAvatarDecorations, - $hashtags_list, - $hashtags_search, - $hashtags_show, - $hashtags_trend, - $hashtags_users, - $i, - $i_2fa_done, - $i_2fa_keyDone, - $i_2fa_passwordLess, - $i_2fa_registerKey, - $i_2fa_register, - $i_2fa_updateKey, - $i_2fa_removeKey, - $i_2fa_unregister, - $i_apps, - $i_authorizedApps, - $i_claimAchievement, - $i_changePassword, - $i_deleteAccount, - $i_exportData, - $i_exportBlocking, - $i_exportFollowing, - $i_exportMute, - $i_exportNotes, - $i_exportClips, - $i_exportFavorites, - $i_exportUserLists, - $i_exportAntennas, - $i_favorites, - $i_gallery_likes, - $i_gallery_posts, - $i_importBlocking, - $i_importFollowing, - $i_importNotes, - $i_importMuting, - $i_importUserLists, - $i_importAntennas, - $i_notifications, - $i_notificationsGrouped, - $i_pageLikes, - $i_pages, - $i_pin, - $i_readAllUnreadNotes, - $i_readAnnouncement, - $i_regenerateToken, - $i_registry_getAll, - $i_registry_getUnsecure, - $i_registry_getDetail, - $i_registry_get, - $i_registry_keysWithType, - $i_registry_keys, - $i_registry_remove, - $i_registry_scopesWithDomain, - $i_registry_set, - $i_revokeToken, - $i_signinHistory, - $i_unpin, - $i_updateEmail, - $i_update, - $i_move, - $i_webhooks_create, - $i_webhooks_list, - $i_webhooks_show, - $i_webhooks_update, - $i_webhooks_delete, - $i_webhooks_test, - $invite_create, - $invite_delete, - $invite_list, - $invite_limit, - $meta, - $emojis, - $emoji, - $miauth_genToken, - $mute_create, - $mute_delete, - $mute_list, - $renoteMute_create, - $renoteMute_delete, - $renoteMute_list, - $my_apps, - $notes, - $notes_children, - $notes_clips, - $notes_conversation, - $notes_create, - $notes_delete, - $notes_favorites_create, - $notes_favorites_delete, - $notes_featured, - $notes_following, - $notes_globalTimeline, - $notes_bubbleTimeline, - $notes_hybridTimeline, - $notes_localTimeline, - $notes_mentions, - $notes_polls_recommendation, - $notes_polls_vote, - $notes_polls_refresh, - $notes_reactions, - $notes_reactions_create, - $notes_reactions_delete, - $notes_like, - $notes_renotes, - $notes_replies, - $notes_schedule_create, - $notes_schedule_delete, - $notes_schedule_list, - $notes_searchByTag, - $notes_search, - $notes_show, - $notes_state, - $notes_threadMuting_create, - $notes_threadMuting_delete, - $notes_timeline, - $notes_translate, - $notes_unrenote, - $notes_userListTimeline, - $notes_edit, - $notes_versions, - $notifications_create, - $notifications_flush, - $notifications_markAllAsRead, - $notifications_testNotification, - $pagePush, - $pages_create, - $pages_delete, - $pages_featured, - $pages_like, - $pages_show, - $pages_unlike, - $pages_update, - $flash_create, - $flash_delete, - $flash_featured, - $flash_like, - $flash_show, - $flash_unlike, - $flash_update, - $flash_my, - $flash_myLikes, - $ping, - $pinnedUsers, - $promo_read, - $roles_list, - $roles_show, - $roles_users, - $roles_notes, - $requestResetPassword, - $resetDb, - $resetPassword, - $serverInfo, - $stats, - $sw_show_registration, - $sw_update_registration, - $sw_register, - $sw_unregister, - $test, - $username_available, - $users, - $users_clips, - $users_followers, - $users_following, - $users_gallery_posts, - $users_getFrequentlyRepliedUsers, - $users_featuredNotes, - $users_lists_create, - $users_lists_delete, - $users_lists_list, - $users_lists_pull, - $users_lists_push, - $users_lists_show, - $users_lists_update, - $users_lists_favorite, - $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, - $users_notes, - $users_pages, - $users_flashs, - $users_reactions, - $users_recommendation, - $users_relation, - $users_reportAbuse, - $users_searchByUsernameAndHost, - $users_search, - $users_show, - $users_achievements, - $users_updateMemo, - $fetchRss, - $fetchExternalResources, - $retention, - $sponsors, - $bubbleGame_register, - $bubbleGame_ranking, - $reversi_cancelMatch, - $reversi_games, - $reversi_match, - $reversi_invitations, - $reversi_showGame, - $reversi_surrender, - $reversi_verify, + ...endpointProviders, ], exports: [ - $admin_meta, - $admin_abuseUserReports, - $admin_abuseReport_notificationRecipient_list, - $admin_abuseReport_notificationRecipient_show, - $admin_abuseReport_notificationRecipient_create, - $admin_abuseReport_notificationRecipient_update, - $admin_abuseReport_notificationRecipient_delete, - $admin_accounts_create, - $admin_accounts_delete, - $admin_accounts_findByEmail, - $admin_ad_create, - $admin_ad_delete, - $admin_ad_list, - $admin_ad_update, - $admin_announcements_create, - $admin_announcements_delete, - $admin_announcements_list, - $admin_announcements_update, - $admin_avatarDecorations_create, - $admin_avatarDecorations_delete, - $admin_avatarDecorations_list, - $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, - $admin_unsetUserAvatar, - $admin_unsetUserBanner, - $admin_drive_cleanRemoteFiles, - $admin_drive_cleanup, - $admin_drive_files, - $admin_drive_showFile, - $admin_emoji_addAliasesBulk, - $admin_emoji_add, - $admin_emoji_copy, - $admin_emoji_deleteBulk, - $admin_emoji_delete, - $admin_emoji_importZip, - $admin_emoji_listRemote, - $admin_emoji_list, - $admin_emoji_removeAliasesBulk, - $admin_emoji_setAliasesBulk, - $admin_emoji_setCategoryBulk, - $admin_emoji_setLicenseBulk, - $admin_emoji_update, - $admin_federation_deleteAllFiles, - $admin_federation_refreshRemoteInstanceMetadata, - $admin_federation_removeAllFollowing, - $admin_federation_updateInstance, - $admin_getIndexStats, - $admin_getTableStats, - $admin_getUserIps, - $admin_invite_create, - $admin_invite_list, - $admin_promo_create, - $admin_queue_clear, - $admin_queue_deliverDelayed, - $admin_queue_inboxDelayed, - $admin_queue_promote, - $admin_queue_stats, - $admin_relays_add, - $admin_relays_list, - $admin_relays_remove, - $admin_resetPassword, - $admin_resolveAbuseUserReport, - $admin_forwardAbuseUserReport, - $admin_updateAbuseUserReport, - $admin_sendEmail, - $admin_serverInfo, - $admin_showModerationLogs, - $admin_showUser, - $admin_showUsers, - $admin_nsfwUser, - $admin_unnsfwUser, - $admin_silenceUser, - $admin_unsilenceUser, - $admin_suspendUser, - $admin_approveUser, - $admin_declineUser, - $admin_unsuspendUser, - $admin_updateMeta, - $admin_deleteAccount, - $admin_updateUserNote, - $admin_roles_create, - $admin_roles_delete, - $admin_roles_list, - $admin_roles_show, - $admin_roles_update, - $admin_roles_assign, - $admin_roles_unassign, - $admin_roles_updateDefaultPolicies, - $admin_roles_users, - $admin_systemWebhook_create, - $admin_systemWebhook_delete, - $admin_systemWebhook_list, - $admin_systemWebhook_show, - $admin_systemWebhook_update, - $admin_systemWebhook_test, - $announcements, - $announcements_show, - $antennas_create, - $antennas_delete, - $antennas_list, - $antennas_notes, - $antennas_show, - $antennas_update, - $ap_get, - $ap_show, - $app_create, - $app_show, - $auth_accept, - $auth_session_generate, - $auth_session_show, - $auth_session_userkey, - $blocking_create, - $blocking_delete, - $blocking_list, - $channels_create, - $channels_featured, - $channels_follow, - $channels_followed, - $channels_owned, - $channels_show, - $channels_timeline, - $channels_unfollow, - $channels_update, - $channels_favorite, - $channels_unfavorite, - $channels_myFavorites, - $channels_search, - $charts_activeUsers, - $charts_apRequest, - $charts_drive, - $charts_federation, - $charts_instance, - $charts_notes, - $charts_user_drive, - $charts_user_following, - $charts_user_notes, - $charts_user_pv, - $charts_user_reactions, - $charts_users, - $clips_addNote, - $clips_removeNote, - $clips_create, - $clips_delete, - $clips_list, - $clips_notes, - $clips_show, - $clips_update, - $clips_favorite, - $clips_unfavorite, - $clips_myFavorites, - $drive, - $drive_files, - $drive_files_attachedNotes, - $drive_files_checkExistence, - $drive_files_create, - $drive_files_delete, - $drive_files_findByHash, - $drive_files_find, - $drive_files_show, - $drive_files_update, - $drive_files_uploadFromUrl, - $drive_folders, - $drive_folders_create, - $drive_folders_delete, - $drive_folders_find, - $drive_folders_show, - $drive_folders_update, - $drive_stream, - $emailAddress_available, - $endpoint, - $endpoints, - $exportCustomEmojis, - $federation_followers, - $federation_following, - $federation_instances, - $federation_showInstance, - $federation_updateRemoteUser, - $federation_users, - $federation_stats, - $following_create, - $following_delete, - $following_update, - $following_update_all, - $following_invalidate, - $following_requests_accept, - $following_requests_cancel, - $following_requests_list, - $following_requests_reject, - $gallery_featured, - $gallery_popular, - $gallery_posts, - $gallery_posts_create, - $gallery_posts_delete, - $gallery_posts_like, - $gallery_posts_show, - $gallery_posts_unlike, - $gallery_posts_update, - $getOnlineUsersCount, - $getAvatarDecorations, - $hashtags_list, - $hashtags_search, - $hashtags_show, - $hashtags_trend, - $hashtags_users, - $i, - $i_2fa_done, - $i_2fa_keyDone, - $i_2fa_passwordLess, - $i_2fa_registerKey, - $i_2fa_register, - $i_2fa_updateKey, - $i_2fa_removeKey, - $i_2fa_unregister, - $i_apps, - $i_authorizedApps, - $i_claimAchievement, - $i_changePassword, - $i_deleteAccount, - $i_exportData, - $i_exportBlocking, - $i_exportFollowing, - $i_exportMute, - $i_exportNotes, - $i_exportClips, - $i_exportFavorites, - $i_exportUserLists, - $i_exportAntennas, - $i_favorites, - $i_gallery_likes, - $i_gallery_posts, - $i_importBlocking, - $i_importFollowing, - $i_importNotes, - $i_importMuting, - $i_importUserLists, - $i_importAntennas, - $i_notifications, - $i_notificationsGrouped, - $i_pageLikes, - $i_pages, - $i_pin, - $i_readAllUnreadNotes, - $i_readAnnouncement, - $i_regenerateToken, - $i_registry_getAll, - $i_registry_getUnsecure, - $i_registry_getDetail, - $i_registry_get, - $i_registry_keysWithType, - $i_registry_keys, - $i_registry_remove, - $i_registry_scopesWithDomain, - $i_registry_set, - $i_revokeToken, - $i_signinHistory, - $i_unpin, - $i_updateEmail, - $i_update, - $i_move, - $i_webhooks_create, - $i_webhooks_list, - $i_webhooks_show, - $i_webhooks_update, - $i_webhooks_delete, - $i_webhooks_test, - $invite_create, - $invite_delete, - $invite_list, - $invite_limit, - $meta, - $emojis, - $emoji, - $miauth_genToken, - $mute_create, - $mute_delete, - $mute_list, - $renoteMute_create, - $renoteMute_delete, - $renoteMute_list, - $my_apps, - $notes, - $notes_children, - $notes_clips, - $notes_conversation, - $notes_create, - $notes_delete, - $notes_favorites_create, - $notes_favorites_delete, - $notes_featured, - $notes_following, - $notes_globalTimeline, - $notes_bubbleTimeline, - $notes_hybridTimeline, - $notes_localTimeline, - $notes_mentions, - $notes_polls_recommendation, - $notes_polls_vote, - $notes_polls_refresh, - $notes_reactions, - $notes_reactions_create, - $notes_reactions_delete, - $notes_like, - $notes_renotes, - $notes_replies, - $notes_schedule_create, - $notes_schedule_delete, - $notes_schedule_list, - $notes_searchByTag, - $notes_search, - $notes_show, - $notes_state, - $notes_threadMuting_create, - $notes_threadMuting_delete, - $notes_timeline, - $notes_translate, - $notes_unrenote, - $notes_userListTimeline, - $notes_edit, - $notes_versions, - $notifications_create, - $notifications_flush, - $notifications_markAllAsRead, - $notifications_testNotification, - $pagePush, - $pages_create, - $pages_delete, - $pages_featured, - $pages_like, - $pages_show, - $pages_unlike, - $pages_update, - $flash_create, - $flash_delete, - $flash_featured, - $flash_like, - $flash_show, - $flash_unlike, - $flash_update, - $flash_my, - $flash_myLikes, - $ping, - $pinnedUsers, - $promo_read, - $roles_list, - $roles_show, - $roles_users, - $roles_notes, - $requestResetPassword, - $resetDb, - $resetPassword, - $serverInfo, - $stats, - $sw_register, - $sw_unregister, - $test, - $username_available, - $users, - $users_clips, - $users_followers, - $users_following, - $users_gallery_posts, - $users_getFrequentlyRepliedUsers, - $users_featuredNotes, - $users_lists_create, - $users_lists_delete, - $users_lists_list, - $users_lists_pull, - $users_lists_push, - $users_lists_show, - $users_lists_update, - $users_lists_favorite, - $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, - $users_notes, - $users_pages, - $users_flashs, - $users_reactions, - $users_recommendation, - $users_relation, - $users_reportAbuse, - $users_searchByUsernameAndHost, - $users_search, - $users_show, - $users_achievements, - $users_updateMemo, - $fetchRss, - $fetchExternalResources, - $retention, - $sponsors, - $bubbleGame_register, - $bubbleGame_ranking, - $reversi_cancelMatch, - $reversi_games, - $reversi_match, - $reversi_invitations, - $reversi_showGame, - $reversi_surrender, - $reversi_verify, + ...endpointProviders, ], }) export class EndpointsModule {} diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index fa9155d82d..72712bce60 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -26,12 +26,19 @@ import { UserAuthService } from '@/core/UserAuthService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; -import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; -import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; +// Up to 10 attempts, then 1 per minute +const signinRateLimit: Keyed<RateLimit> = { + key: 'signin', + max: 10, + dripRate: 1000 * 60, +}; + @Injectable() export class SigninApiService { constructor( @@ -94,7 +101,7 @@ export class SigninApiService { } // not more than 1 attempt per second and not more than 10 attempts per hour - const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); + const rateLimit = await this.rateLimiterService.limit(signinRateLimit, getIpHash(request.ip)); sendRateLimitHeaders(reply, rateLimit); diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index e94d2b6b68..f84f50523b 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -21,7 +21,7 @@ import { WebAuthnService } from '@/core/WebAuthnService.js'; import Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IdentifiableError } from '@/misc/identifiable-error.js'; -import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 7aea6a0e56..42137d3298 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -19,10 +19,9 @@ import { MiLocalUser } from '@/models/User.js'; import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { bindThis } from '@/decorators.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; +import { RoleService } from '@/core/RoleService.js'; import { SigninService } from './SigninService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; -import instance from './endpoints/charts/instance.js'; -import { RoleService } from '@/core/RoleService.js'; @Injectable() export class SignupApiService { diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index e3fd1312ae..6e7abcfae6 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -18,10 +18,9 @@ import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; -import { RoleService } from '@/core/RoleService.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -49,7 +48,6 @@ export class StreamingApiServerService { private usersService: UserService, private channelFollowingService: ChannelFollowingService, private rateLimiterService: SkRateLimiterService, - private roleService: RoleService, private loggerService: LoggerService, ) { } @@ -57,22 +55,18 @@ export class StreamingApiServerService { @bindThis private async rateLimitThis( user: MiLocalUser | null | undefined, - requestIp: string | undefined, + requestIp: string, limit: IEndpointMeta['limit'] & { key: NonNullable<string> }, ) : Promise<boolean> { - let limitActor: string; + let limitActor: string | MiLocalUser; if (user) { - limitActor = user.id; + limitActor = user; } else { - limitActor = getIpHash(requestIp || 'wtf'); + limitActor = getIpHash(requestIp); } - const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; - - if (factor <= 0) return false; - // Rate limit - const rateLimit = await this.rateLimiterService.limit(limit, limitActor, factor); + const rateLimit = await this.rateLimiterService.limit(limit, limitActor); return rateLimit.blocked; } diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts new file mode 100644 index 0000000000..a641a14448 --- /dev/null +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -0,0 +1,421 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * This file contains list of all endpoints exported as pathname of API endpoint + * + * When you add new endpoint, you should add it to this file. + * This file is used to generate API documentation and EndpointsModule. + */ + +export * as 'admin/abuse-report/notification-recipient/create' from './endpoints/admin/abuse-report/notification-recipient/create.js'; +export * as 'admin/abuse-report/notification-recipient/delete' from './endpoints/admin/abuse-report/notification-recipient/delete.js'; +export * as 'admin/abuse-report/notification-recipient/list' from './endpoints/admin/abuse-report/notification-recipient/list.js'; +export * as 'admin/abuse-report/notification-recipient/show' from './endpoints/admin/abuse-report/notification-recipient/show.js'; +export * as 'admin/abuse-report/notification-recipient/update' from './endpoints/admin/abuse-report/notification-recipient/update.js'; +export * as 'admin/abuse-user-reports' from './endpoints/admin/abuse-user-reports.js'; +export * as 'admin/accounts/create' from './endpoints/admin/accounts/create.js'; +export * as 'admin/accounts/delete' from './endpoints/admin/accounts/delete.js'; +export * as 'admin/accounts/find-by-email' from './endpoints/admin/accounts/find-by-email.js'; +export * as 'admin/ad/create' from './endpoints/admin/ad/create.js'; +export * as 'admin/ad/delete' from './endpoints/admin/ad/delete.js'; +export * as 'admin/ad/list' from './endpoints/admin/ad/list.js'; +export * as 'admin/ad/update' from './endpoints/admin/ad/update.js'; +export * as 'admin/announcements/create' from './endpoints/admin/announcements/create.js'; +export * as 'admin/announcements/delete' from './endpoints/admin/announcements/delete.js'; +export * as 'admin/announcements/list' from './endpoints/admin/announcements/list.js'; +export * as 'admin/announcements/update' from './endpoints/admin/announcements/update.js'; +export * as 'admin/approve-user' from './endpoints/admin/approve-user.js'; +export * as 'admin/avatar-decorations/create' from './endpoints/admin/avatar-decorations/create.js'; +export * as 'admin/avatar-decorations/delete' from './endpoints/admin/avatar-decorations/delete.js'; +export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decorations/list.js'; +export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js'; +export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js'; +export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js'; +export * as 'admin/cw-user' from './endpoints/admin/cw-user.js'; +export * as 'admin/decline-user' from './endpoints/admin/decline-user.js'; +export * as 'admin/delete-account' from './endpoints/admin/delete-account.js'; +export * as 'admin/delete-all-files-of-a-user' from './endpoints/admin/delete-all-files-of-a-user.js'; +export * as 'admin/drive/clean-remote-files' from './endpoints/admin/drive/clean-remote-files.js'; +export * as 'admin/drive/cleanup' from './endpoints/admin/drive/cleanup.js'; +export * as 'admin/drive/files' from './endpoints/admin/drive/files.js'; +export * as 'admin/drive/show-file' from './endpoints/admin/drive/show-file.js'; +export * as 'admin/emoji/add' from './endpoints/admin/emoji/add.js'; +export * as 'admin/emoji/add-aliases-bulk' from './endpoints/admin/emoji/add-aliases-bulk.js'; +export * as 'admin/emoji/copy' from './endpoints/admin/emoji/copy.js'; +export * as 'admin/emoji/delete' from './endpoints/admin/emoji/delete.js'; +export * as 'admin/emoji/delete-bulk' from './endpoints/admin/emoji/delete-bulk.js'; +export * as 'admin/emoji/import-zip' from './endpoints/admin/emoji/import-zip.js'; +export * as 'admin/emoji/list' from './endpoints/admin/emoji/list.js'; +export * as 'admin/emoji/list-remote' from './endpoints/admin/emoji/list-remote.js'; +export * as 'admin/emoji/remove-aliases-bulk' from './endpoints/admin/emoji/remove-aliases-bulk.js'; +export * as 'admin/emoji/set-aliases-bulk' from './endpoints/admin/emoji/set-aliases-bulk.js'; +export * as 'admin/emoji/set-category-bulk' from './endpoints/admin/emoji/set-category-bulk.js'; +export * as 'admin/emoji/set-license-bulk' from './endpoints/admin/emoji/set-license-bulk.js'; +export * as 'admin/emoji/update' from './endpoints/admin/emoji/update.js'; +export * as 'admin/federation/delete-all-files' from './endpoints/admin/federation/delete-all-files.js'; +export * as 'admin/federation/refresh-remote-instance-metadata' from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; +export * as 'admin/federation/remove-all-following' from './endpoints/admin/federation/remove-all-following.js'; +export * as 'admin/federation/update-instance' from './endpoints/admin/federation/update-instance.js'; +export * as 'admin/forward-abuse-user-report' from './endpoints/admin/forward-abuse-user-report.js'; +export * as 'admin/gen-vapid-keys' from './endpoints/admin/gen-vapid-keys.js'; +export * as 'admin/get-index-stats' from './endpoints/admin/get-index-stats.js'; +export * as 'admin/get-table-stats' from './endpoints/admin/get-table-stats.js'; +export * as 'admin/get-user-ips' from './endpoints/admin/get-user-ips.js'; +export * as 'admin/invite/create' from './endpoints/admin/invite/create.js'; +export * as 'admin/invite/list' from './endpoints/admin/invite/list.js'; +export * as 'admin/meta' from './endpoints/admin/meta.js'; +export * as 'admin/nsfw-user' from './endpoints/admin/nsfw-user.js'; +export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; +export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; +export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js'; +export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js'; +export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js'; +export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js'; +export * as 'admin/reject-quotes' from './endpoints/admin/reject-quotes.js'; +export * as 'admin/relays/add' from './endpoints/admin/relays/add.js'; +export * as 'admin/relays/list' from './endpoints/admin/relays/list.js'; +export * as 'admin/relays/remove' from './endpoints/admin/relays/remove.js'; +export * as 'admin/reset-password' from './endpoints/admin/reset-password.js'; +export * as 'admin/resolve-abuse-user-report' from './endpoints/admin/resolve-abuse-user-report.js'; +export * as 'admin/roles/assign' from './endpoints/admin/roles/assign.js'; +export * as 'admin/roles/create' from './endpoints/admin/roles/create.js'; +export * as 'admin/roles/delete' from './endpoints/admin/roles/delete.js'; +export * as 'admin/roles/list' from './endpoints/admin/roles/list.js'; +export * as 'admin/roles/show' from './endpoints/admin/roles/show.js'; +export * as 'admin/roles/unassign' from './endpoints/admin/roles/unassign.js'; +export * as 'admin/roles/update' from './endpoints/admin/roles/update.js'; +export * as 'admin/roles/update-default-policies' from './endpoints/admin/roles/update-default-policies.js'; +export * as 'admin/roles/users' from './endpoints/admin/roles/users.js'; +export * as 'admin/send-email' from './endpoints/admin/send-email.js'; +export * as 'admin/server-info' from './endpoints/admin/server-info.js'; +export * as 'admin/show-moderation-logs' from './endpoints/admin/show-moderation-logs.js'; +export * as 'admin/show-user' from './endpoints/admin/show-user.js'; +export * as 'admin/show-users' from './endpoints/admin/show-users.js'; +export * as 'admin/silence-user' from './endpoints/admin/silence-user.js'; +export * as 'admin/suspend-user' from './endpoints/admin/suspend-user.js'; +export * as 'admin/system-webhook/create' from './endpoints/admin/system-webhook/create.js'; +export * as 'admin/system-webhook/delete' from './endpoints/admin/system-webhook/delete.js'; +export * as 'admin/system-webhook/list' from './endpoints/admin/system-webhook/list.js'; +export * as 'admin/system-webhook/show' from './endpoints/admin/system-webhook/show.js'; +export * as 'admin/system-webhook/test' from './endpoints/admin/system-webhook/test.js'; +export * as 'admin/system-webhook/update' from './endpoints/admin/system-webhook/update.js'; +export * as 'admin/unnsfw-user' from './endpoints/admin/unnsfw-user.js'; +export * as 'admin/unset-user-avatar' from './endpoints/admin/unset-user-avatar.js'; +export * as 'admin/unset-user-banner' from './endpoints/admin/unset-user-banner.js'; +export * as 'admin/unsilence-user' from './endpoints/admin/unsilence-user.js'; +export * as 'admin/unsuspend-user' from './endpoints/admin/unsuspend-user.js'; +export * as 'admin/update-abuse-user-report' from './endpoints/admin/update-abuse-user-report.js'; +export * as 'admin/update-meta' from './endpoints/admin/update-meta.js'; +export * as 'admin/update-user-note' from './endpoints/admin/update-user-note.js'; +export * as 'announcements' from './endpoints/announcements.js'; +export * as 'announcements/show' from './endpoints/announcements/show.js'; +export * as 'antennas/create' from './endpoints/antennas/create.js'; +export * as 'antennas/delete' from './endpoints/antennas/delete.js'; +export * as 'antennas/list' from './endpoints/antennas/list.js'; +export * as 'antennas/notes' from './endpoints/antennas/notes.js'; +export * as 'antennas/show' from './endpoints/antennas/show.js'; +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/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'; +export * as 'auth/session/show' from './endpoints/auth/session/show.js'; +export * as 'auth/session/userkey' from './endpoints/auth/session/userkey.js'; +export * as 'blocking/create' from './endpoints/blocking/create.js'; +export * as 'blocking/delete' from './endpoints/blocking/delete.js'; +export * as 'blocking/list' from './endpoints/blocking/list.js'; +export * as 'bubble-game/ranking' from './endpoints/bubble-game/ranking.js'; +export * as 'bubble-game/register' from './endpoints/bubble-game/register.js'; +export * as 'channels/create' from './endpoints/channels/create.js'; +export * as 'channels/favorite' from './endpoints/channels/favorite.js'; +export * as 'channels/featured' from './endpoints/channels/featured.js'; +export * as 'channels/follow' from './endpoints/channels/follow.js'; +export * as 'channels/followed' from './endpoints/channels/followed.js'; +export * as 'channels/my-favorites' from './endpoints/channels/my-favorites.js'; +export * as 'channels/owned' from './endpoints/channels/owned.js'; +export * as 'channels/search' from './endpoints/channels/search.js'; +export * as 'channels/show' from './endpoints/channels/show.js'; +export * as 'channels/timeline' from './endpoints/channels/timeline.js'; +export * as 'channels/unfavorite' from './endpoints/channels/unfavorite.js'; +export * as 'channels/unfollow' from './endpoints/channels/unfollow.js'; +export * as 'channels/update' from './endpoints/channels/update.js'; +export * as 'charts/active-users' from './endpoints/charts/active-users.js'; +export * as 'charts/ap-request' from './endpoints/charts/ap-request.js'; +export * as 'charts/drive' from './endpoints/charts/drive.js'; +export * as 'charts/federation' from './endpoints/charts/federation.js'; +export * as 'charts/instance' from './endpoints/charts/instance.js'; +export * as 'charts/notes' from './endpoints/charts/notes.js'; +export * as 'charts/user/drive' from './endpoints/charts/user/drive.js'; +export * as 'charts/user/following' from './endpoints/charts/user/following.js'; +export * as 'charts/user/notes' from './endpoints/charts/user/notes.js'; +export * as 'charts/user/pv' from './endpoints/charts/user/pv.js'; +export * as 'charts/user/reactions' from './endpoints/charts/user/reactions.js'; +export * as 'charts/users' from './endpoints/charts/users.js'; +export * as 'clips/add-note' from './endpoints/clips/add-note.js'; +export * as 'clips/create' from './endpoints/clips/create.js'; +export * as 'clips/delete' from './endpoints/clips/delete.js'; +export * as 'clips/favorite' from './endpoints/clips/favorite.js'; +export * as 'clips/list' from './endpoints/clips/list.js'; +export * as 'clips/my-favorites' from './endpoints/clips/my-favorites.js'; +export * as 'clips/notes' from './endpoints/clips/notes.js'; +export * as 'clips/remove-note' from './endpoints/clips/remove-note.js'; +export * as 'clips/show' from './endpoints/clips/show.js'; +export * as 'clips/unfavorite' from './endpoints/clips/unfavorite.js'; +export * as 'clips/update' from './endpoints/clips/update.js'; +export * as 'drive' from './endpoints/drive.js'; +export * as 'drive/files' from './endpoints/drive/files.js'; +export * as 'drive/files/attached-notes' from './endpoints/drive/files/attached-notes.js'; +export * as 'drive/files/check-existence' from './endpoints/drive/files/check-existence.js'; +export * as 'drive/files/create' from './endpoints/drive/files/create.js'; +export * as 'drive/files/delete' from './endpoints/drive/files/delete.js'; +export * as 'drive/files/find' from './endpoints/drive/files/find.js'; +export * as 'drive/files/find-by-hash' from './endpoints/drive/files/find-by-hash.js'; +export * as 'drive/files/show' from './endpoints/drive/files/show.js'; +export * as 'drive/files/update' from './endpoints/drive/files/update.js'; +export * as 'drive/files/upload-from-url' from './endpoints/drive/files/upload-from-url.js'; +export * as 'drive/folders' from './endpoints/drive/folders.js'; +export * as 'drive/folders/create' from './endpoints/drive/folders/create.js'; +export * as 'drive/folders/delete' from './endpoints/drive/folders/delete.js'; +export * as 'drive/folders/find' from './endpoints/drive/folders/find.js'; +export * as 'drive/folders/show' from './endpoints/drive/folders/show.js'; +export * as 'drive/folders/update' from './endpoints/drive/folders/update.js'; +export * as 'drive/stream' from './endpoints/drive/stream.js'; +export * as 'email-address/available' from './endpoints/email-address/available.js'; +export * as 'emoji' from './endpoints/emoji.js'; +export * as 'emojis' from './endpoints/emojis.js'; +export * as 'endpoint' from './endpoints/endpoint.js'; +export * as 'endpoints' from './endpoints/endpoints.js'; +export * as 'export-custom-emojis' from './endpoints/export-custom-emojis.js'; +export * as 'federation/followers' from './endpoints/federation/followers.js'; +export * as 'federation/following' from './endpoints/federation/following.js'; +export * as 'federation/instances' from './endpoints/federation/instances.js'; +export * as 'federation/show-instance' from './endpoints/federation/show-instance.js'; +export * as 'federation/stats' from './endpoints/federation/stats.js'; +export * as 'federation/update-remote-user' from './endpoints/federation/update-remote-user.js'; +export * as 'federation/users' from './endpoints/federation/users.js'; +export * as 'fetch-external-resources' from './endpoints/fetch-external-resources.js'; +export * as 'fetch-rss' from './endpoints/fetch-rss.js'; +export * as 'flash/create' from './endpoints/flash/create.js'; +export * as 'flash/delete' from './endpoints/flash/delete.js'; +export * as 'flash/featured' from './endpoints/flash/featured.js'; +export * as 'flash/like' from './endpoints/flash/like.js'; +export * as 'flash/my' from './endpoints/flash/my.js'; +export * as 'flash/my-likes' from './endpoints/flash/my-likes.js'; +export * as 'flash/show' from './endpoints/flash/show.js'; +export * as 'flash/unlike' from './endpoints/flash/unlike.js'; +export * as 'flash/update' from './endpoints/flash/update.js'; +export * as 'following/create' from './endpoints/following/create.js'; +export * as 'following/delete' from './endpoints/following/delete.js'; +export * as 'following/invalidate' from './endpoints/following/invalidate.js'; +export * as 'following/requests/accept' from './endpoints/following/requests/accept.js'; +export * as 'following/requests/cancel' from './endpoints/following/requests/cancel.js'; +export * as 'following/requests/list' from './endpoints/following/requests/list.js'; +export * as 'following/requests/reject' from './endpoints/following/requests/reject.js'; +export * as 'following/requests/sent' from './endpoints/following/requests/sent.js'; +export * as 'following/update' from './endpoints/following/update.js'; +export * as 'following/update-all' from './endpoints/following/update-all.js'; +export * as 'gallery/featured' from './endpoints/gallery/featured.js'; +export * as 'gallery/popular' from './endpoints/gallery/popular.js'; +export * as 'gallery/posts' from './endpoints/gallery/posts.js'; +export * as 'gallery/posts/create' from './endpoints/gallery/posts/create.js'; +export * as 'gallery/posts/delete' from './endpoints/gallery/posts/delete.js'; +export * as 'gallery/posts/like' from './endpoints/gallery/posts/like.js'; +export * as 'gallery/posts/show' from './endpoints/gallery/posts/show.js'; +export * as 'gallery/posts/unlike' from './endpoints/gallery/posts/unlike.js'; +export * as 'gallery/posts/update' from './endpoints/gallery/posts/update.js'; +export * as 'get-avatar-decorations' from './endpoints/get-avatar-decorations.js'; +export * as 'get-online-users-count' from './endpoints/get-online-users-count.js'; +export * as 'hashtags/list' from './endpoints/hashtags/list.js'; +export * as 'hashtags/search' from './endpoints/hashtags/search.js'; +export * as 'hashtags/show' from './endpoints/hashtags/show.js'; +export * as 'hashtags/trend' from './endpoints/hashtags/trend.js'; +export * as 'hashtags/users' from './endpoints/hashtags/users.js'; +export * as 'i' from './endpoints/i.js'; +export * as 'i/2fa/done' from './endpoints/i/2fa/done.js'; +export * as 'i/2fa/key-done' from './endpoints/i/2fa/key-done.js'; +export * as 'i/2fa/password-less' from './endpoints/i/2fa/password-less.js'; +export * as 'i/2fa/register' from './endpoints/i/2fa/register.js'; +export * as 'i/2fa/register-key' from './endpoints/i/2fa/register-key.js'; +export * as 'i/2fa/remove-key' from './endpoints/i/2fa/remove-key.js'; +export * as 'i/2fa/unregister' from './endpoints/i/2fa/unregister.js'; +export * as 'i/2fa/update-key' from './endpoints/i/2fa/update-key.js'; +export * as 'i/apps' from './endpoints/i/apps.js'; +export * as 'i/authorized-apps' from './endpoints/i/authorized-apps.js'; +export * as 'i/change-password' from './endpoints/i/change-password.js'; +export * as 'i/claim-achievement' from './endpoints/i/claim-achievement.js'; +export * as 'i/delete-account' from './endpoints/i/delete-account.js'; +export * as 'i/export-antennas' from './endpoints/i/export-antennas.js'; +export * as 'i/export-blocking' from './endpoints/i/export-blocking.js'; +export * as 'i/export-clips' from './endpoints/i/export-clips.js'; +export * as 'i/export-data' from './endpoints/i/export-data.js'; +export * as 'i/export-favorites' from './endpoints/i/export-favorites.js'; +export * as 'i/export-following' from './endpoints/i/export-following.js'; +export * as 'i/export-mute' from './endpoints/i/export-mute.js'; +export * as 'i/export-notes' from './endpoints/i/export-notes.js'; +export * as 'i/export-user-lists' from './endpoints/i/export-user-lists.js'; +export * as 'i/favorites' from './endpoints/i/favorites.js'; +export * as 'i/gallery/likes' from './endpoints/i/gallery/likes.js'; +export * as 'i/gallery/posts' from './endpoints/i/gallery/posts.js'; +export * as 'i/import-antennas' from './endpoints/i/import-antennas.js'; +export * as 'i/import-blocking' from './endpoints/i/import-blocking.js'; +export * as 'i/import-following' from './endpoints/i/import-following.js'; +export * as 'i/import-muting' from './endpoints/i/import-muting.js'; +export * as 'i/import-notes' from './endpoints/i/import-notes.js'; +export * as 'i/import-user-lists' from './endpoints/i/import-user-lists.js'; +export * as 'i/move' from './endpoints/i/move.js'; +export * as 'i/notifications' from './endpoints/i/notifications.js'; +export * as 'i/notifications-grouped' from './endpoints/i/notifications-grouped.js'; +export * as 'i/page-likes' from './endpoints/i/page-likes.js'; +export * as 'i/pages' from './endpoints/i/pages.js'; +export * as 'i/pin' from './endpoints/i/pin.js'; +export * as 'i/read-all-unread-notes' from './endpoints/i/read-all-unread-notes.js'; +export * as 'i/read-announcement' from './endpoints/i/read-announcement.js'; +export * as 'i/regenerate-token' from './endpoints/i/regenerate-token.js'; +export * as 'i/registry/get' from './endpoints/i/registry/get.js'; +export * as 'i/registry/get-all' from './endpoints/i/registry/get-all.js'; +export * as 'i/registry/get-detail' from './endpoints/i/registry/get-detail.js'; +export * as 'i/registry/get-unsecure' from './endpoints/i/registry/get-unsecure.js'; +export * as 'i/registry/keys' from './endpoints/i/registry/keys.js'; +export * as 'i/registry/keys-with-type' from './endpoints/i/registry/keys-with-type.js'; +export * as 'i/registry/remove' from './endpoints/i/registry/remove.js'; +export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes-with-domain.js'; +export * as 'i/registry/set' from './endpoints/i/registry/set.js'; +export * as 'i/revoke-token' from './endpoints/i/revoke-token.js'; +export * as 'i/signin-history' from './endpoints/i/signin-history.js'; +export * as 'i/unpin' from './endpoints/i/unpin.js'; +export * as 'i/update' from './endpoints/i/update.js'; +export * as 'i/update-email' from './endpoints/i/update-email.js'; +export * as 'i/webhooks/create' from './endpoints/i/webhooks/create.js'; +export * as 'i/webhooks/delete' from './endpoints/i/webhooks/delete.js'; +export * as 'i/webhooks/list' from './endpoints/i/webhooks/list.js'; +export * as 'i/webhooks/show' from './endpoints/i/webhooks/show.js'; +export * as 'i/webhooks/test' from './endpoints/i/webhooks/test.js'; +export * as 'i/webhooks/update' from './endpoints/i/webhooks/update.js'; +export * as 'invite/create' from './endpoints/invite/create.js'; +export * as 'invite/delete' from './endpoints/invite/delete.js'; +export * as 'invite/limit' from './endpoints/invite/limit.js'; +export * as 'invite/list' from './endpoints/invite/list.js'; +export * as 'meta' from './endpoints/meta.js'; +export * as 'miauth/gen-token' from './endpoints/miauth/gen-token.js'; +export * as 'mute/create' from './endpoints/mute/create.js'; +export * as 'mute/delete' from './endpoints/mute/delete.js'; +export * as 'mute/list' from './endpoints/mute/list.js'; +export * as 'my/apps' from './endpoints/my/apps.js'; +export * as 'notes' from './endpoints/notes.js'; +export * as 'notes/bubble-timeline' from './endpoints/notes/bubble-timeline.js'; +export * as 'notes/children' from './endpoints/notes/children.js'; +export * as 'notes/clips' from './endpoints/notes/clips.js'; +export * as 'notes/conversation' from './endpoints/notes/conversation.js'; +export * as 'notes/create' from './endpoints/notes/create.js'; +export * as 'notes/delete' from './endpoints/notes/delete.js'; +export * as 'notes/edit' from './endpoints/notes/edit.js'; +export * as 'notes/favorites/create' from './endpoints/notes/favorites/create.js'; +export * as 'notes/favorites/delete' from './endpoints/notes/favorites/delete.js'; +export * as 'notes/featured' from './endpoints/notes/featured.js'; +export * as 'notes/following' from './endpoints/notes/following.js'; +export * as 'notes/global-timeline' from './endpoints/notes/global-timeline.js'; +export * as 'notes/hybrid-timeline' from './endpoints/notes/hybrid-timeline.js'; +export * as 'notes/like' from './endpoints/notes/like.js'; +export * as 'notes/local-timeline' from './endpoints/notes/local-timeline.js'; +export * as 'notes/mentions' from './endpoints/notes/mentions.js'; +export * as 'notes/polls/recommendation' from './endpoints/notes/polls/recommendation.js'; +export * as 'notes/polls/refresh' from './endpoints/notes/polls/refresh.js'; +export * as 'notes/polls/vote' from './endpoints/notes/polls/vote.js'; +export * as 'notes/reactions' from './endpoints/notes/reactions.js'; +export * as 'notes/reactions/create' from './endpoints/notes/reactions/create.js'; +export * as 'notes/reactions/delete' from './endpoints/notes/reactions/delete.js'; +export * as 'notes/renotes' from './endpoints/notes/renotes.js'; +export * as 'notes/replies' from './endpoints/notes/replies.js'; +export * as 'notes/search' from './endpoints/notes/search.js'; +export * as 'notes/schedule/create' from './endpoints/notes/schedule/create.js'; +export * as 'notes/schedule/delete' from './endpoints/notes/schedule/delete.js'; +export * as 'notes/schedule/list' from './endpoints/notes/schedule/list.js'; +export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js'; +export * as 'notes/show' from './endpoints/notes/show.js'; +export * as 'notes/state' from './endpoints/notes/state.js'; +export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js'; +export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js'; +export * as 'notes/timeline' from './endpoints/notes/timeline.js'; +export * as 'notes/translate' from './endpoints/notes/translate.js'; +export * as 'notes/unrenote' from './endpoints/notes/unrenote.js'; +export * as 'notes/user-list-timeline' from './endpoints/notes/user-list-timeline.js'; +export * as 'notes/versions' from './endpoints/notes/versions.js'; +export * as 'notifications/create' from './endpoints/notifications/create.js'; +export * as 'notifications/flush' from './endpoints/notifications/flush.js'; +export * as 'notifications/mark-all-as-read' from './endpoints/notifications/mark-all-as-read.js'; +export * as 'notifications/test-notification' from './endpoints/notifications/test-notification.js'; +export * as 'page-push' from './endpoints/page-push.js'; +export * as 'pages/create' from './endpoints/pages/create.js'; +export * as 'pages/delete' from './endpoints/pages/delete.js'; +export * as 'pages/featured' from './endpoints/pages/featured.js'; +export * as 'pages/like' from './endpoints/pages/like.js'; +export * as 'pages/show' from './endpoints/pages/show.js'; +export * as 'pages/unlike' from './endpoints/pages/unlike.js'; +export * as 'pages/update' from './endpoints/pages/update.js'; +export * as 'ping' from './endpoints/ping.js'; +export * as 'pinned-users' from './endpoints/pinned-users.js'; +export * as 'promo/read' from './endpoints/promo/read.js'; +export * as 'renote-mute/create' from './endpoints/renote-mute/create.js'; +export * as 'renote-mute/delete' from './endpoints/renote-mute/delete.js'; +export * as 'renote-mute/list' from './endpoints/renote-mute/list.js'; +export * as 'request-reset-password' from './endpoints/request-reset-password.js'; +export * as 'reset-db' from './endpoints/reset-db.js'; +export * as 'reset-password' from './endpoints/reset-password.js'; +export * as 'retention' from './endpoints/retention.js'; +export * as 'reversi/cancel-match' from './endpoints/reversi/cancel-match.js'; +export * as 'reversi/games' from './endpoints/reversi/games.js'; +export * as 'reversi/invitations' from './endpoints/reversi/invitations.js'; +export * as 'reversi/match' from './endpoints/reversi/match.js'; +export * as 'reversi/show-game' from './endpoints/reversi/show-game.js'; +export * as 'reversi/surrender' from './endpoints/reversi/surrender.js'; +export * as 'reversi/verify' from './endpoints/reversi/verify.js'; +export * as 'roles/list' from './endpoints/roles/list.js'; +export * as 'roles/notes' from './endpoints/roles/notes.js'; +export * as 'roles/show' from './endpoints/roles/show.js'; +export * as 'roles/users' from './endpoints/roles/users.js'; +export * as 'server-info' from './endpoints/server-info.js'; +export * as 'sponsors' from './endpoints/sponsors.js'; +export * as 'stats' from './endpoints/stats.js'; +export * as 'sw/register' from './endpoints/sw/register.js'; +export * as 'sw/show-registration' from './endpoints/sw/show-registration.js'; +export * as 'sw/unregister' from './endpoints/sw/unregister.js'; +export * as 'sw/update-registration' from './endpoints/sw/update-registration.js'; +export * as 'test' from './endpoints/test.js'; +export * as 'username/available' from './endpoints/username/available.js'; +export * as 'users' from './endpoints/users.js'; +export * as 'users/achievements' from './endpoints/users/achievements.js'; +export * as 'users/clips' from './endpoints/users/clips.js'; +export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; +export * as 'users/flashs' from './endpoints/users/flashs.js'; +export * as 'users/followers' from './endpoints/users/followers.js'; +export * as 'users/following' from './endpoints/users/following.js'; +export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js'; +export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js'; +export * as 'users/lists/create' from './endpoints/users/lists/create.js'; +export * as 'users/lists/create-from-public' from './endpoints/users/lists/create-from-public.js'; +export * as 'users/lists/delete' from './endpoints/users/lists/delete.js'; +export * as 'users/lists/favorite' from './endpoints/users/lists/favorite.js'; +export * as 'users/lists/get-memberships' from './endpoints/users/lists/get-memberships.js'; +export * as 'users/lists/list' from './endpoints/users/lists/list.js'; +export * as 'users/lists/pull' from './endpoints/users/lists/pull.js'; +export * as 'users/lists/push' from './endpoints/users/lists/push.js'; +export * as 'users/lists/show' from './endpoints/users/lists/show.js'; +export * as 'users/lists/unfavorite' from './endpoints/users/lists/unfavorite.js'; +export * as 'users/lists/update' from './endpoints/users/lists/update.js'; +export * as 'users/lists/update-membership' from './endpoints/users/lists/update-membership.js'; +export * as 'users/notes' from './endpoints/users/notes.js'; +export * as 'users/pages' from './endpoints/users/pages.js'; +export * as 'users/reactions' from './endpoints/users/reactions.js'; +export * as 'users/recommendation' from './endpoints/users/recommendation.js'; +export * as 'users/relation' from './endpoints/users/relation.js'; +export * as 'users/report-abuse' from './endpoints/users/report-abuse.js'; +export * as 'users/search' from './endpoints/users/search.js'; +export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js'; +export * as 'users/show' from './endpoints/users/show.js'; +export * as 'users/update-memo' from './endpoints/users/update-memo.js'; +export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b4f36234f0..fd6b9bb14b 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -7,821 +7,7 @@ import { permissions } from 'misskey-js'; import type { KeyOf, Schema } from '@/misc/json-schema.js'; import type { RateLimit } from '@/misc/rate-limit-utils.js'; -import * as ep___admin_abuseReport_notificationRecipient_list - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; -import * as ep___admin_abuseReport_notificationRecipient_show - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; -import * as ep___admin_abuseReport_notificationRecipient_create - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; -import * as ep___admin_abuseReport_notificationRecipient_update - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; -import * as ep___admin_abuseReport_notificationRecipient_delete - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; -import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; -import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; -import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; -import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; -import * as ep___admin_ad_create from './endpoints/admin/ad/create.js'; -import * as ep___admin_ad_delete from './endpoints/admin/ad/delete.js'; -import * as ep___admin_ad_list from './endpoints/admin/ad/list.js'; -import * as ep___admin_ad_update from './endpoints/admin/ad/update.js'; -import * as ep___admin_announcements_create from './endpoints/admin/announcements/create.js'; -import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; -import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; -import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; -import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; -import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; -import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; -import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; -import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; -import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; -import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; -import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; -import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; -import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; -import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; -import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; -import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; -import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; -import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; -import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js'; -import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js'; -import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; -import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; -import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; -import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; -import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; -import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; -import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; -import * as ep___admin_federation_refreshRemoteInstanceMetadata - from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; -import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; -import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; -import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; -import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; -import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; -import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; -import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; -import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; -import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; -import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; -import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; -import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; -import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; -import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; -import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; -import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; -import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; -import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js'; -import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js'; -import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; -import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; -import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; -import * as ep___admin_showUser from './endpoints/admin/show-user.js'; -import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_nsfwUser from './endpoints/admin/nsfw-user.js'; -import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js'; -import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; -import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; -import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_approveUser from './endpoints/admin/approve-user.js'; -import * as ep___admin_declineUser from './endpoints/admin/decline-user.js'; -import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; -import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; -import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; -import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; -import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; -import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; -import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; -import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; -import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; -import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; -import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; -import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; -import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; -import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; -import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; -import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; -import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; -import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; -import * as ep___announcements from './endpoints/announcements.js'; -import * as ep___announcements_show from './endpoints/announcements/show.js'; -import * as ep___antennas_create from './endpoints/antennas/create.js'; -import * as ep___antennas_delete from './endpoints/antennas/delete.js'; -import * as ep___antennas_list from './endpoints/antennas/list.js'; -import * as ep___antennas_notes from './endpoints/antennas/notes.js'; -import * as ep___antennas_show from './endpoints/antennas/show.js'; -import * as ep___antennas_update from './endpoints/antennas/update.js'; -import * as ep___ap_get from './endpoints/ap/get.js'; -import * as ep___ap_show from './endpoints/ap/show.js'; -import * as ep___app_create from './endpoints/app/create.js'; -import * as ep___app_show from './endpoints/app/show.js'; -import * as ep___auth_accept from './endpoints/auth/accept.js'; -import * as ep___auth_session_generate from './endpoints/auth/session/generate.js'; -import * as ep___auth_session_show from './endpoints/auth/session/show.js'; -import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js'; -import * as ep___blocking_create from './endpoints/blocking/create.js'; -import * as ep___blocking_delete from './endpoints/blocking/delete.js'; -import * as ep___blocking_list from './endpoints/blocking/list.js'; -import * as ep___channels_create from './endpoints/channels/create.js'; -import * as ep___channels_featured from './endpoints/channels/featured.js'; -import * as ep___channels_follow from './endpoints/channels/follow.js'; -import * as ep___channels_followed from './endpoints/channels/followed.js'; -import * as ep___channels_owned from './endpoints/channels/owned.js'; -import * as ep___channels_show from './endpoints/channels/show.js'; -import * as ep___channels_timeline from './endpoints/channels/timeline.js'; -import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; -import * as ep___channels_update from './endpoints/channels/update.js'; -import * as ep___channels_favorite from './endpoints/channels/favorite.js'; -import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; -import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; -import * as ep___channels_search from './endpoints/channels/search.js'; -import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; -import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; -import * as ep___charts_drive from './endpoints/charts/drive.js'; -import * as ep___charts_federation from './endpoints/charts/federation.js'; -import * as ep___charts_instance from './endpoints/charts/instance.js'; -import * as ep___charts_notes from './endpoints/charts/notes.js'; -import * as ep___charts_user_drive from './endpoints/charts/user/drive.js'; -import * as ep___charts_user_following from './endpoints/charts/user/following.js'; -import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; -import * as ep___charts_user_pv from './endpoints/charts/user/pv.js'; -import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; -import * as ep___charts_users from './endpoints/charts/users.js'; -import * as ep___clips_addNote from './endpoints/clips/add-note.js'; -import * as ep___clips_removeNote from './endpoints/clips/remove-note.js'; -import * as ep___clips_create from './endpoints/clips/create.js'; -import * as ep___clips_delete from './endpoints/clips/delete.js'; -import * as ep___clips_list from './endpoints/clips/list.js'; -import * as ep___clips_notes from './endpoints/clips/notes.js'; -import * as ep___clips_show from './endpoints/clips/show.js'; -import * as ep___clips_update from './endpoints/clips/update.js'; -import * as ep___clips_favorite from './endpoints/clips/favorite.js'; -import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; -import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; -import * as ep___drive from './endpoints/drive.js'; -import * as ep___drive_files from './endpoints/drive/files.js'; -import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; -import * as ep___drive_files_checkExistence from './endpoints/drive/files/check-existence.js'; -import * as ep___drive_files_create from './endpoints/drive/files/create.js'; -import * as ep___drive_files_delete from './endpoints/drive/files/delete.js'; -import * as ep___drive_files_findByHash from './endpoints/drive/files/find-by-hash.js'; -import * as ep___drive_files_find from './endpoints/drive/files/find.js'; -import * as ep___drive_files_show from './endpoints/drive/files/show.js'; -import * as ep___drive_files_update from './endpoints/drive/files/update.js'; -import * as ep___drive_files_uploadFromUrl from './endpoints/drive/files/upload-from-url.js'; -import * as ep___drive_folders from './endpoints/drive/folders.js'; -import * as ep___drive_folders_create from './endpoints/drive/folders/create.js'; -import * as ep___drive_folders_delete from './endpoints/drive/folders/delete.js'; -import * as ep___drive_folders_find from './endpoints/drive/folders/find.js'; -import * as ep___drive_folders_show from './endpoints/drive/folders/show.js'; -import * as ep___drive_folders_update from './endpoints/drive/folders/update.js'; -import * as ep___drive_stream from './endpoints/drive/stream.js'; -import * as ep___emailAddress_available from './endpoints/email-address/available.js'; -import * as ep___endpoint from './endpoints/endpoint.js'; -import * as ep___endpoints from './endpoints/endpoints.js'; -import * as ep___exportCustomEmojis from './endpoints/export-custom-emojis.js'; -import * as ep___federation_followers from './endpoints/federation/followers.js'; -import * as ep___federation_following from './endpoints/federation/following.js'; -import * as ep___federation_instances from './endpoints/federation/instances.js'; -import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; -import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; -import * as ep___federation_users from './endpoints/federation/users.js'; -import * as ep___federation_stats from './endpoints/federation/stats.js'; -import * as ep___following_create from './endpoints/following/create.js'; -import * as ep___following_delete from './endpoints/following/delete.js'; -import * as ep___following_update from './endpoints/following/update.js'; -import * as ep___following_update_all from './endpoints/following/update-all.js'; -import * as ep___following_invalidate from './endpoints/following/invalidate.js'; -import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; -import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; -import * as ep___following_requests_list from './endpoints/following/requests/list.js'; -import * as ep___following_requests_sent from './endpoints/following/requests/sent.js'; -import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; -import * as ep___gallery_featured from './endpoints/gallery/featured.js'; -import * as ep___gallery_popular from './endpoints/gallery/popular.js'; -import * as ep___gallery_posts from './endpoints/gallery/posts.js'; -import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js'; -import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js'; -import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js'; -import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; -import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; -import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; -import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; -import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; -import * as ep___hashtags_list from './endpoints/hashtags/list.js'; -import * as ep___hashtags_search from './endpoints/hashtags/search.js'; -import * as ep___hashtags_show from './endpoints/hashtags/show.js'; -import * as ep___hashtags_trend from './endpoints/hashtags/trend.js'; -import * as ep___hashtags_users from './endpoints/hashtags/users.js'; -import * as ep___i from './endpoints/i.js'; -import * as ep___i_2fa_done from './endpoints/i/2fa/done.js'; -import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; -import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; -import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; -import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; -import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; -import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; -import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; -import * as ep___i_apps from './endpoints/i/apps.js'; -import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; -import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; -import * as ep___i_changePassword from './endpoints/i/change-password.js'; -import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; -import * as ep___i_exportData from './endpoints/i/export-data.js'; -import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; -import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; -import * as ep___i_exportMute from './endpoints/i/export-mute.js'; -import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; -import * as ep___i_exportClips from './endpoints/i/export-clips.js'; -import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; -import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; -import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; -import * as ep___i_favorites from './endpoints/i/favorites.js'; -import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; -import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; -import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; -import * as ep___i_importFollowing from './endpoints/i/import-following.js'; -import * as ep___i_importNotes from './endpoints/i/import-notes.js'; -import * as ep___i_importMuting from './endpoints/i/import-muting.js'; -import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; -import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; -import * as ep___i_notifications from './endpoints/i/notifications.js'; -import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; -import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; -import * as ep___i_pages from './endpoints/i/pages.js'; -import * as ep___i_pin from './endpoints/i/pin.js'; -import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; -import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; -import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; -import * as ep___i_registry_getAll from './endpoints/i/registry/get-all.js'; -import * as ep___i_registry_getUnsecure from './endpoints/i/registry/get-unsecure.js'; -import * as ep___i_registry_getDetail from './endpoints/i/registry/get-detail.js'; -import * as ep___i_registry_get from './endpoints/i/registry/get.js'; -import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; -import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; -import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; -import * as ep___i_registry_set from './endpoints/i/registry/set.js'; -import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; -import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; -import * as ep___i_unpin from './endpoints/i/unpin.js'; -import * as ep___i_updateEmail from './endpoints/i/update-email.js'; -import * as ep___i_update from './endpoints/i/update.js'; -import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; -import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; -import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; -import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; -import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; -import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; -import * as ep___invite_create from './endpoints/invite/create.js'; -import * as ep___invite_delete from './endpoints/invite/delete.js'; -import * as ep___invite_list from './endpoints/invite/list.js'; -import * as ep___invite_limit from './endpoints/invite/limit.js'; -import * as ep___meta from './endpoints/meta.js'; -import * as ep___emojis from './endpoints/emojis.js'; -import * as ep___emoji from './endpoints/emoji.js'; -import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; -import * as ep___mute_create from './endpoints/mute/create.js'; -import * as ep___mute_delete from './endpoints/mute/delete.js'; -import * as ep___mute_list from './endpoints/mute/list.js'; -import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; -import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; -import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; -import * as ep___my_apps from './endpoints/my/apps.js'; -import * as ep___notes from './endpoints/notes.js'; -import * as ep___notes_children from './endpoints/notes/children.js'; -import * as ep___notes_clips from './endpoints/notes/clips.js'; -import * as ep___notes_conversation from './endpoints/notes/conversation.js'; -import * as ep___notes_create from './endpoints/notes/create.js'; -import * as ep___notes_delete from './endpoints/notes/delete.js'; -import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; -import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; -import * as ep___notes_featured from './endpoints/notes/featured.js'; -import * as ep___notes_following from './endpoints/notes/following.js'; -import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; -import * as ep___notes_bubbleTimeline from './endpoints/notes/bubble-timeline.js'; -import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; -import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; -import * as ep___notes_mentions from './endpoints/notes/mentions.js'; -import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; -import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; -import * as ep___notes_polls_refresh from './endpoints/notes/polls/refresh.js'; -import * as ep___notes_reactions from './endpoints/notes/reactions.js'; -import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; -import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; -import * as ep___notes_like from './endpoints/notes/like.js'; -import * as ep___notes_renotes from './endpoints/notes/renotes.js'; -import * as ep___notes_replies from './endpoints/notes/replies.js'; -import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; -import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; -import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; -import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; -import * as ep___notes_search from './endpoints/notes/search.js'; -import * as ep___notes_show from './endpoints/notes/show.js'; -import * as ep___notes_state from './endpoints/notes/state.js'; -import * as ep___notes_threadMuting_create from './endpoints/notes/thread-muting/create.js'; -import * as ep___notes_threadMuting_delete from './endpoints/notes/thread-muting/delete.js'; -import * as ep___notes_timeline from './endpoints/notes/timeline.js'; -import * as ep___notes_translate from './endpoints/notes/translate.js'; -import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; -import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; -import * as ep___notes_edit from './endpoints/notes/edit.js'; -import * as ep___notes_versions from './endpoints/notes/versions.js'; -import * as ep___notifications_create from './endpoints/notifications/create.js'; -import * as ep___notifications_flush from './endpoints/notifications/flush.js'; -import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; -import * as ep___pagePush from './endpoints/page-push.js'; -import * as ep___pages_create from './endpoints/pages/create.js'; -import * as ep___pages_delete from './endpoints/pages/delete.js'; -import * as ep___pages_featured from './endpoints/pages/featured.js'; -import * as ep___pages_like from './endpoints/pages/like.js'; -import * as ep___pages_show from './endpoints/pages/show.js'; -import * as ep___pages_unlike from './endpoints/pages/unlike.js'; -import * as ep___pages_update from './endpoints/pages/update.js'; -import * as ep___flash_create from './endpoints/flash/create.js'; -import * as ep___flash_delete from './endpoints/flash/delete.js'; -import * as ep___flash_featured from './endpoints/flash/featured.js'; -import * as ep___flash_like from './endpoints/flash/like.js'; -import * as ep___flash_show from './endpoints/flash/show.js'; -import * as ep___flash_unlike from './endpoints/flash/unlike.js'; -import * as ep___flash_update from './endpoints/flash/update.js'; -import * as ep___flash_my from './endpoints/flash/my.js'; -import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; -import * as ep___ping from './endpoints/ping.js'; -import * as ep___pinnedUsers from './endpoints/pinned-users.js'; -import * as ep___promo_read from './endpoints/promo/read.js'; -import * as ep___roles_list from './endpoints/roles/list.js'; -import * as ep___roles_show from './endpoints/roles/show.js'; -import * as ep___roles_users from './endpoints/roles/users.js'; -import * as ep___roles_notes from './endpoints/roles/notes.js'; -import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; -import * as ep___resetDb from './endpoints/reset-db.js'; -import * as ep___resetPassword from './endpoints/reset-password.js'; -import * as ep___serverInfo from './endpoints/server-info.js'; -import * as ep___stats from './endpoints/stats.js'; -import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; -import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; -import * as ep___sw_register from './endpoints/sw/register.js'; -import * as ep___sw_unregister from './endpoints/sw/unregister.js'; -import * as ep___test from './endpoints/test.js'; -import * as ep___username_available from './endpoints/username/available.js'; -import * as ep___users from './endpoints/users.js'; -import * as ep___users_clips from './endpoints/users/clips.js'; -import * as ep___users_followers from './endpoints/users/followers.js'; -import * as ep___users_following from './endpoints/users/following.js'; -import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; -import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; -import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; -import * as ep___users_lists_create from './endpoints/users/lists/create.js'; -import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; -import * as ep___users_lists_list from './endpoints/users/lists/list.js'; -import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; -import * as ep___users_lists_push from './endpoints/users/lists/push.js'; -import * as ep___users_lists_show from './endpoints/users/lists/show.js'; -import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; -import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; -import * as ep___users_lists_update from './endpoints/users/lists/update.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; -import * as ep___users_notes from './endpoints/users/notes.js'; -import * as ep___users_pages from './endpoints/users/pages.js'; -import * as ep___users_flashs from './endpoints/users/flashs.js'; -import * as ep___users_reactions from './endpoints/users/reactions.js'; -import * as ep___users_recommendation from './endpoints/users/recommendation.js'; -import * as ep___users_relation from './endpoints/users/relation.js'; -import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; -import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; -import * as ep___users_search from './endpoints/users/search.js'; -import * as ep___users_show from './endpoints/users/show.js'; -import * as ep___users_achievements from './endpoints/users/achievements.js'; -import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; -import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; -import * as ep___retention from './endpoints/retention.js'; -import * as ep___sponsors from './endpoints/sponsors.js'; -import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js'; -import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js'; -import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js'; -import * as ep___reversi_games from './endpoints/reversi/games.js'; -import * as ep___reversi_match from './endpoints/reversi/match.js'; -import * as ep___reversi_invitations from './endpoints/reversi/invitations.js'; -import * as ep___reversi_showGame from './endpoints/reversi/show-game.js'; -import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; -import * as ep___reversi_verify from './endpoints/reversi/verify.js'; - -const eps = [ - ['admin/meta', ep___admin_meta], - ['admin/abuse-user-reports', ep___admin_abuseUserReports], - ['admin/abuse-report/notification-recipient/list', ep___admin_abuseReport_notificationRecipient_list], - ['admin/abuse-report/notification-recipient/show', ep___admin_abuseReport_notificationRecipient_show], - ['admin/abuse-report/notification-recipient/create', ep___admin_abuseReport_notificationRecipient_create], - ['admin/abuse-report/notification-recipient/update', ep___admin_abuseReport_notificationRecipient_update], - ['admin/abuse-report/notification-recipient/delete', ep___admin_abuseReport_notificationRecipient_delete], - ['admin/accounts/create', ep___admin_accounts_create], - ['admin/accounts/delete', ep___admin_accounts_delete], - ['admin/accounts/find-by-email', ep___admin_accounts_findByEmail], - ['admin/ad/create', ep___admin_ad_create], - ['admin/ad/delete', ep___admin_ad_delete], - ['admin/ad/list', ep___admin_ad_list], - ['admin/ad/update', ep___admin_ad_update], - ['admin/announcements/create', ep___admin_announcements_create], - ['admin/announcements/delete', ep___admin_announcements_delete], - ['admin/announcements/list', ep___admin_announcements_list], - ['admin/announcements/update', ep___admin_announcements_update], - ['admin/avatar-decorations/create', ep___admin_avatarDecorations_create], - ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], - ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], - ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], - ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], - ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], - ['admin/unset-user-banner', ep___admin_unsetUserBanner], - ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], - ['admin/drive/cleanup', ep___admin_drive_cleanup], - ['admin/drive/files', ep___admin_drive_files], - ['admin/drive/show-file', ep___admin_drive_showFile], - ['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], - ['admin/emoji/add', ep___admin_emoji_add], - ['admin/emoji/copy', ep___admin_emoji_copy], - ['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk], - ['admin/emoji/delete', ep___admin_emoji_delete], - ['admin/emoji/import-zip', ep___admin_emoji_importZip], - ['admin/emoji/list-remote', ep___admin_emoji_listRemote], - ['admin/emoji/list', ep___admin_emoji_list], - ['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk], - ['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk], - ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], - ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], - ['admin/emoji/update', ep___admin_emoji_update], - ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], - ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], - ['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing], - ['admin/federation/update-instance', ep___admin_federation_updateInstance], - ['admin/get-index-stats', ep___admin_getIndexStats], - ['admin/get-table-stats', ep___admin_getTableStats], - ['admin/get-user-ips', ep___admin_getUserIps], - ['admin/invite/create', ep___admin_invite_create], - ['admin/invite/list', ep___admin_invite_list], - ['admin/promo/create', ep___admin_promo_create], - ['admin/queue/clear', ep___admin_queue_clear], - ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], - ['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed], - ['admin/queue/promote', ep___admin_queue_promote], - ['admin/queue/stats', ep___admin_queue_stats], - ['admin/relays/add', ep___admin_relays_add], - ['admin/relays/list', ep___admin_relays_list], - ['admin/relays/remove', ep___admin_relays_remove], - ['admin/reset-password', ep___admin_resetPassword], - ['admin/resolve-abuse-user-report', ep___admin_resolveAbuseUserReport], - ['admin/forward-abuse-user-report', ep___admin_forwardAbuseUserReport], - ['admin/update-abuse-user-report', ep___admin_updateAbuseUserReport], - ['admin/send-email', ep___admin_sendEmail], - ['admin/server-info', ep___admin_serverInfo], - ['admin/show-moderation-logs', ep___admin_showModerationLogs], - ['admin/show-user', ep___admin_showUser], - ['admin/show-users', ep___admin_showUsers], - ['admin/nsfw-user', ep___admin_nsfwUser], - ['admin/unnsfw-user', ep___admin_unnsfwUser], - ['admin/silence-user', ep___admin_silenceUser], - ['admin/unsilence-user', ep___admin_unsilenceUser], - ['admin/suspend-user', ep___admin_suspendUser], - ['admin/approve-user', ep___admin_approveUser], - ['admin/decline-user', ep___admin_declineUser], - ['admin/unsuspend-user', ep___admin_unsuspendUser], - ['admin/update-meta', ep___admin_updateMeta], - ['admin/delete-account', ep___admin_deleteAccount], - ['admin/update-user-note', ep___admin_updateUserNote], - ['admin/roles/create', ep___admin_roles_create], - ['admin/roles/delete', ep___admin_roles_delete], - ['admin/roles/list', ep___admin_roles_list], - ['admin/roles/show', ep___admin_roles_show], - ['admin/roles/update', ep___admin_roles_update], - ['admin/roles/assign', ep___admin_roles_assign], - ['admin/roles/unassign', ep___admin_roles_unassign], - ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], - ['admin/roles/users', ep___admin_roles_users], - ['admin/system-webhook/create', ep___admin_systemWebhook_create], - ['admin/system-webhook/delete', ep___admin_systemWebhook_delete], - ['admin/system-webhook/list', ep___admin_systemWebhook_list], - ['admin/system-webhook/show', ep___admin_systemWebhook_show], - ['admin/system-webhook/update', ep___admin_systemWebhook_update], - ['admin/system-webhook/test', ep___admin_systemWebhook_test], - ['announcements', ep___announcements], - ['announcements/show', ep___announcements_show], - ['antennas/create', ep___antennas_create], - ['antennas/delete', ep___antennas_delete], - ['antennas/list', ep___antennas_list], - ['antennas/notes', ep___antennas_notes], - ['antennas/show', ep___antennas_show], - ['antennas/update', ep___antennas_update], - ['ap/get', ep___ap_get], - ['ap/show', ep___ap_show], - ['app/create', ep___app_create], - ['app/show', ep___app_show], - ['auth/accept', ep___auth_accept], - ['auth/session/generate', ep___auth_session_generate], - ['auth/session/show', ep___auth_session_show], - ['auth/session/userkey', ep___auth_session_userkey], - ['blocking/create', ep___blocking_create], - ['blocking/delete', ep___blocking_delete], - ['blocking/list', ep___blocking_list], - ['channels/create', ep___channels_create], - ['channels/featured', ep___channels_featured], - ['channels/follow', ep___channels_follow], - ['channels/followed', ep___channels_followed], - ['channels/owned', ep___channels_owned], - ['channels/show', ep___channels_show], - ['channels/timeline', ep___channels_timeline], - ['channels/unfollow', ep___channels_unfollow], - ['channels/update', ep___channels_update], - ['channels/favorite', ep___channels_favorite], - ['channels/unfavorite', ep___channels_unfavorite], - ['channels/my-favorites', ep___channels_myFavorites], - ['channels/search', ep___channels_search], - ['charts/active-users', ep___charts_activeUsers], - ['charts/ap-request', ep___charts_apRequest], - ['charts/drive', ep___charts_drive], - ['charts/federation', ep___charts_federation], - ['charts/instance', ep___charts_instance], - ['charts/notes', ep___charts_notes], - ['charts/user/drive', ep___charts_user_drive], - ['charts/user/following', ep___charts_user_following], - ['charts/user/notes', ep___charts_user_notes], - ['charts/user/pv', ep___charts_user_pv], - ['charts/user/reactions', ep___charts_user_reactions], - ['charts/users', ep___charts_users], - ['clips/add-note', ep___clips_addNote], - ['clips/remove-note', ep___clips_removeNote], - ['clips/create', ep___clips_create], - ['clips/delete', ep___clips_delete], - ['clips/list', ep___clips_list], - ['clips/notes', ep___clips_notes], - ['clips/show', ep___clips_show], - ['clips/update', ep___clips_update], - ['clips/favorite', ep___clips_favorite], - ['clips/unfavorite', ep___clips_unfavorite], - ['clips/my-favorites', ep___clips_myFavorites], - ['drive', ep___drive], - ['drive/files', ep___drive_files], - ['drive/files/attached-notes', ep___drive_files_attachedNotes], - ['drive/files/check-existence', ep___drive_files_checkExistence], - ['drive/files/create', ep___drive_files_create], - ['drive/files/delete', ep___drive_files_delete], - ['drive/files/find-by-hash', ep___drive_files_findByHash], - ['drive/files/find', ep___drive_files_find], - ['drive/files/show', ep___drive_files_show], - ['drive/files/update', ep___drive_files_update], - ['drive/files/upload-from-url', ep___drive_files_uploadFromUrl], - ['drive/folders', ep___drive_folders], - ['drive/folders/create', ep___drive_folders_create], - ['drive/folders/delete', ep___drive_folders_delete], - ['drive/folders/find', ep___drive_folders_find], - ['drive/folders/show', ep___drive_folders_show], - ['drive/folders/update', ep___drive_folders_update], - ['drive/stream', ep___drive_stream], - ['email-address/available', ep___emailAddress_available], - ['endpoint', ep___endpoint], - ['endpoints', ep___endpoints], - ['export-custom-emojis', ep___exportCustomEmojis], - ['federation/followers', ep___federation_followers], - ['federation/following', ep___federation_following], - ['federation/instances', ep___federation_instances], - ['federation/show-instance', ep___federation_showInstance], - ['federation/update-remote-user', ep___federation_updateRemoteUser], - ['federation/users', ep___federation_users], - ['federation/stats', ep___federation_stats], - ['following/create', ep___following_create], - ['following/delete', ep___following_delete], - ['following/update', ep___following_update], - ['following/update-all', ep___following_update_all], - ['following/invalidate', ep___following_invalidate], - ['following/requests/accept', ep___following_requests_accept], - ['following/requests/cancel', ep___following_requests_cancel], - ['following/requests/list', ep___following_requests_list], - ['following/requests/sent', ep___following_requests_sent], - ['following/requests/reject', ep___following_requests_reject], - ['gallery/featured', ep___gallery_featured], - ['gallery/popular', ep___gallery_popular], - ['gallery/posts', ep___gallery_posts], - ['gallery/posts/create', ep___gallery_posts_create], - ['gallery/posts/delete', ep___gallery_posts_delete], - ['gallery/posts/like', ep___gallery_posts_like], - ['gallery/posts/show', ep___gallery_posts_show], - ['gallery/posts/unlike', ep___gallery_posts_unlike], - ['gallery/posts/update', ep___gallery_posts_update], - ['get-online-users-count', ep___getOnlineUsersCount], - ['get-avatar-decorations', ep___getAvatarDecorations], - ['hashtags/list', ep___hashtags_list], - ['hashtags/search', ep___hashtags_search], - ['hashtags/show', ep___hashtags_show], - ['hashtags/trend', ep___hashtags_trend], - ['hashtags/users', ep___hashtags_users], - ['i', ep___i], - ['i/2fa/done', ep___i_2fa_done], - ['i/2fa/key-done', ep___i_2fa_keyDone], - ['i/2fa/password-less', ep___i_2fa_passwordLess], - ['i/2fa/register-key', ep___i_2fa_registerKey], - ['i/2fa/register', ep___i_2fa_register], - ['i/2fa/update-key', ep___i_2fa_updateKey], - ['i/2fa/remove-key', ep___i_2fa_removeKey], - ['i/2fa/unregister', ep___i_2fa_unregister], - ['i/apps', ep___i_apps], - ['i/authorized-apps', ep___i_authorizedApps], - ['i/claim-achievement', ep___i_claimAchievement], - ['i/change-password', ep___i_changePassword], - ['i/delete-account', ep___i_deleteAccount], - ['i/export-data', ep___i_exportData], - ['i/export-blocking', ep___i_exportBlocking], - ['i/export-following', ep___i_exportFollowing], - ['i/export-mute', ep___i_exportMute], - ['i/export-notes', ep___i_exportNotes], - ['i/export-clips', ep___i_exportClips], - ['i/export-favorites', ep___i_exportFavorites], - ['i/export-user-lists', ep___i_exportUserLists], - ['i/export-antennas', ep___i_exportAntennas], - ['i/favorites', ep___i_favorites], - ['i/gallery/likes', ep___i_gallery_likes], - ['i/gallery/posts', ep___i_gallery_posts], - ['i/import-blocking', ep___i_importBlocking], - ['i/import-following', ep___i_importFollowing], - ['i/import-notes', ep___i_importNotes], - ['i/import-muting', ep___i_importMuting], - ['i/import-user-lists', ep___i_importUserLists], - ['i/import-antennas', ep___i_importAntennas], - ['i/notifications', ep___i_notifications], - ['i/notifications-grouped', ep___i_notificationsGrouped], - ['i/page-likes', ep___i_pageLikes], - ['i/pages', ep___i_pages], - ['i/pin', ep___i_pin], - ['i/read-all-unread-notes', ep___i_readAllUnreadNotes], - ['i/read-announcement', ep___i_readAnnouncement], - ['i/regenerate-token', ep___i_regenerateToken], - ['i/registry/get-all', ep___i_registry_getAll], - ['i/registry/get-unsecure', ep___i_registry_getUnsecure], - ['i/registry/get-detail', ep___i_registry_getDetail], - ['i/registry/get', ep___i_registry_get], - ['i/registry/keys-with-type', ep___i_registry_keysWithType], - ['i/registry/keys', ep___i_registry_keys], - ['i/registry/remove', ep___i_registry_remove], - ['i/registry/scopes-with-domain', ep___i_registry_scopesWithDomain], - ['i/registry/set', ep___i_registry_set], - ['i/revoke-token', ep___i_revokeToken], - ['i/signin-history', ep___i_signinHistory], - ['i/unpin', ep___i_unpin], - ['i/update-email', ep___i_updateEmail], - ['i/update', ep___i_update], - ['i/move', ep___i_move], - ['i/webhooks/create', ep___i_webhooks_create], - ['i/webhooks/list', ep___i_webhooks_list], - ['i/webhooks/show', ep___i_webhooks_show], - ['i/webhooks/update', ep___i_webhooks_update], - ['i/webhooks/delete', ep___i_webhooks_delete], - ['i/webhooks/test', ep___i_webhooks_test], - ['invite/create', ep___invite_create], - ['invite/delete', ep___invite_delete], - ['invite/list', ep___invite_list], - ['invite/limit', ep___invite_limit], - ['meta', ep___meta], - ['emojis', ep___emojis], - ['emoji', ep___emoji], - ['miauth/gen-token', ep___miauth_genToken], - ['mute/create', ep___mute_create], - ['mute/delete', ep___mute_delete], - ['mute/list', ep___mute_list], - ['renote-mute/create', ep___renoteMute_create], - ['renote-mute/delete', ep___renoteMute_delete], - ['renote-mute/list', ep___renoteMute_list], - ['my/apps', ep___my_apps], - ['notes', ep___notes], - ['notes/children', ep___notes_children], - ['notes/clips', ep___notes_clips], - ['notes/conversation', ep___notes_conversation], - ['notes/create', ep___notes_create], - ['notes/delete', ep___notes_delete], - ['notes/favorites/create', ep___notes_favorites_create], - ['notes/favorites/delete', ep___notes_favorites_delete], - ['notes/featured', ep___notes_featured], - ['notes/following', ep___notes_following], - ['notes/global-timeline', ep___notes_globalTimeline], - ['notes/bubble-timeline', ep___notes_bubbleTimeline], - ['notes/hybrid-timeline', ep___notes_hybridTimeline], - ['notes/local-timeline', ep___notes_localTimeline], - ['notes/mentions', ep___notes_mentions], - ['notes/polls/recommendation', ep___notes_polls_recommendation], - ['notes/polls/vote', ep___notes_polls_vote], - ['notes/polls/refresh', ep___notes_polls_refresh], - ['notes/reactions', ep___notes_reactions], - ['notes/reactions/create', ep___notes_reactions_create], - ['notes/reactions/delete', ep___notes_reactions_delete], - ['notes/like', ep___notes_like], - ['notes/renotes', ep___notes_renotes], - ['notes/replies', ep___notes_replies], - ['notes/schedule/create', ep___notes_schedule_create], - ['notes/schedule/delete', ep___notes_schedule_delete], - ['notes/schedule/list', ep___notes_schedule_list], - ['notes/search-by-tag', ep___notes_searchByTag], - ['notes/search', ep___notes_search], - ['notes/show', ep___notes_show], - ['notes/state', ep___notes_state], - ['notes/thread-muting/create', ep___notes_threadMuting_create], - ['notes/thread-muting/delete', ep___notes_threadMuting_delete], - ['notes/timeline', ep___notes_timeline], - ['notes/translate', ep___notes_translate], - ['notes/unrenote', ep___notes_unrenote], - ['notes/user-list-timeline', ep___notes_userListTimeline], - ['notes/edit', ep___notes_edit], - ['notes/versions', ep___notes_versions], - ['notifications/create', ep___notifications_create], - ['notifications/flush', ep___notifications_flush], - ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], - ['notifications/test-notification', ep___notifications_testNotification], - ['page-push', ep___pagePush], - ['pages/create', ep___pages_create], - ['pages/delete', ep___pages_delete], - ['pages/featured', ep___pages_featured], - ['pages/like', ep___pages_like], - ['pages/show', ep___pages_show], - ['pages/unlike', ep___pages_unlike], - ['pages/update', ep___pages_update], - ['flash/create', ep___flash_create], - ['flash/delete', ep___flash_delete], - ['flash/featured', ep___flash_featured], - ['flash/like', ep___flash_like], - ['flash/show', ep___flash_show], - ['flash/unlike', ep___flash_unlike], - ['flash/update', ep___flash_update], - ['flash/my', ep___flash_my], - ['flash/my-likes', ep___flash_myLikes], - ['ping', ep___ping], - ['pinned-users', ep___pinnedUsers], - ['promo/read', ep___promo_read], - ['roles/list', ep___roles_list], - ['roles/show', ep___roles_show], - ['roles/users', ep___roles_users], - ['roles/notes', ep___roles_notes], - ['request-reset-password', ep___requestResetPassword], - ['reset-db', ep___resetDb], - ['reset-password', ep___resetPassword], - ['server-info', ep___serverInfo], - ['stats', ep___stats], - ['sw/show-registration', ep___sw_show_registration], - ['sw/update-registration', ep___sw_update_registration], - ['sw/register', ep___sw_register], - ['sw/unregister', ep___sw_unregister], - ['test', ep___test], - ['username/available', ep___username_available], - ['users', ep___users], - ['users/clips', ep___users_clips], - ['users/followers', ep___users_followers], - ['users/following', ep___users_following], - ['users/gallery/posts', ep___users_gallery_posts], - ['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers], - ['users/featured-notes', ep___users_featuredNotes], - ['users/lists/create', ep___users_lists_create], - ['users/lists/delete', ep___users_lists_delete], - ['users/lists/list', ep___users_lists_list], - ['users/lists/pull', ep___users_lists_pull], - ['users/lists/push', ep___users_lists_push], - ['users/lists/show', ep___users_lists_show], - ['users/lists/favorite', ep___users_lists_favorite], - ['users/lists/unfavorite', ep___users_lists_unfavorite], - ['users/lists/update', ep___users_lists_update], - ['users/lists/create-from-public', ep___users_lists_createFromPublic], - ['users/lists/update-membership', ep___users_lists_updateMembership], - ['users/lists/get-memberships', ep___users_lists_getMemberships], - ['users/notes', ep___users_notes], - ['users/pages', ep___users_pages], - ['users/flashs', ep___users_flashs], - ['users/reactions', ep___users_reactions], - ['users/recommendation', ep___users_recommendation], - ['users/relation', ep___users_relation], - ['users/report-abuse', ep___users_reportAbuse], - ['users/search-by-username-and-host', ep___users_searchByUsernameAndHost], - ['users/search', ep___users_search], - ['users/show', ep___users_show], - ['users/achievements', ep___users_achievements], - ['users/update-memo', ep___users_updateMemo], - ['fetch-rss', ep___fetchRss], - ['fetch-external-resources', ep___fetchExternalResources], - ['retention', ep___retention], - ['sponsors', ep___sponsors], - ['bubble-game/register', ep___bubbleGame_register], - ['bubble-game/ranking', ep___bubbleGame_ranking], - ['reversi/cancel-match', ep___reversi_cancelMatch], - ['reversi/games', ep___reversi_games], - ['reversi/match', ep___reversi_match], - ['reversi/invitations', ep___reversi_invitations], - ['reversi/show-game', ep___reversi_showGame], - ['reversi/surrender', ep___reversi_surrender], - ['reversi/verify', ep___reversi_verify], -]; +import * as endpointsObject from './endpoint-list.js'; interface IEndpointMetaBase { readonly stability?: 'deprecated' | 'experimental' | 'stable'; @@ -922,7 +108,7 @@ export interface IEndpoint { params: Schema; } -const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { +const endpoints: IEndpoint[] = Object.entries(endpointsObject).map(([name, ep]) => { return { name: name, get meta() { diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 53b1c4c4ec..1a47f56bc6 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -6,7 +6,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; -import { MiAccessToken, MiUser } from '@/models/_.js'; import { SignupService } from '@/core/SignupService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; @@ -16,6 +15,7 @@ import type { Config } from '@/config.js'; import { ApiError } from '@/server/api/error.js'; import { Packed } from '@/misc/json-schema.js'; import { RoleService } from '@/core/RoleService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -97,6 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private userEntityService: UserEntityService, private signupService: SignupService, private instanceActorService: InstanceActorService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, _me, token) => { const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; @@ -138,6 +139,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- approved: true, }); + if (me) { + await this.moderationLogService.log(me, 'createAccount', { + userId: account.id, + userUsername: account.username, + }); + } + const res = await this.userEntityService.pack(account, account, { schema: 'MeDetailed', includeSecrets: true, diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/current.ts b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts new file mode 100644 index 0000000000..41192c1926 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの取得であるため + kind: 'read:admin:meta', + + res: { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + hcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + mcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + instanceUrl: { type: 'string', nullable: true }, + }, + }, + recaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + turnstile: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + fc: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + }, + }, +} as const; + +export const paramDef = {} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async () => { + return this.captchaService.get(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/save.ts b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts new file mode 100644 index 0000000000..98ec278ebe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { captchaErrorCodes, CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの更新であるため + kind: 'write:admin:meta', + + errors: { + invalidProvider: { + message: 'Invalid provider.', + code: 'INVALID_PROVIDER', + id: '14bf7ae1-80cc-4363-acb2-4fd61d086af0', + httpStatusCode: 400, + }, + invalidParameters: { + message: 'Invalid parameters.', + code: 'INVALID_PARAMETERS', + id: '26654194-410e-44e2-b42e-460ff6f92476', + httpStatusCode: 400, + }, + noResponseProvided: { + message: 'No response provided.', + code: 'NO_RESPONSE_PROVIDED', + id: '40acbba8-0937-41fb-bb3f-474514d40afe', + httpStatusCode: 400, + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd', + httpStatusCode: 500, + }, + verificationFailed: { + message: 'Verification failed.', + code: 'VERIFICATION_FAILED', + id: 'c41c067f-24f3-4150-84b2-b5a3ae8c2214', + httpStatusCode: 400, + }, + unknown: { + message: 'unknown', + code: 'UNKNOWN', + id: 'f868d509-e257-42a9-99c1-42614b031a97', + httpStatusCode: 500, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + captchaResult: { + type: 'string', nullable: true, + }, + sitekey: { + type: 'string', nullable: true, + }, + secret: { + type: 'string', nullable: true, + }, + instanceUrl: { + type: 'string', nullable: true, + }, + }, + required: ['provider'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async (ps) => { + const result = await this.captchaService.save(ps.provider, { + sitekey: ps.sitekey, + secret: ps.secret, + instanceUrl: ps.instanceUrl, + captchaResult: ps.captchaResult, + }); + + if (!result.success) { + switch (result.error.code) { + case captchaErrorCodes.invalidProvider: + throw new ApiError({ + ...meta.errors.invalidProvider, + message: result.error.message, + }); + case captchaErrorCodes.invalidParameters: + throw new ApiError({ + ...meta.errors.invalidParameters, + message: result.error.message, + }); + case captchaErrorCodes.noResponseProvided: + throw new ApiError({ + ...meta.errors.noResponseProvided, + message: result.error.message, + }); + case captchaErrorCodes.requestFailed: + throw new ApiError({ + ...meta.errors.requestFailed, + message: result.error.message, + }); + case captchaErrorCodes.verificationFailed: + throw new ApiError({ + ...meta.errors.verificationFailed, + message: result.error.message, + }); + default: + throw new ApiError(meta.errors.unknown); + } + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/cw-user.ts b/packages/backend/src/server/api/endpoints/admin/cw-user.ts new file mode 100644 index 0000000000..bdcfa6a0d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/cw-user.ts @@ -0,0 +1,67 @@ +/* + * 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 { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:cw-user', +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + cw: { type: 'string', nullable: true }, + }, + required: ['userId', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + + private readonly globalEventService: GlobalEventService, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.cacheService.findUserById(ps.userId); + + // Skip if there's nothing to do + if (user.mandatoryCW === ps.cw) return; + + // Log event first. + // This ensures that we don't "lose" the log if an error occurs + await this.moderationLogService.log(me, 'setMandatoryCW', { + newCW: ps.cw, + oldCW: user.mandatoryCW, + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + + await this.usersRepository.update(ps.userId, { + // Collapse empty strings to null + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + mandatoryCW: ps.cw || null, + }); + + // Synchronize caches and other processes + this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts index 747c9f48d0..8b4a450ccb 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['admin'], @@ -30,14 +32,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me) => { + const user = await this.cacheService.findUserById(ps.userId); const files = await this.driveFilesRepository.findBy({ userId: ps.userId, }); + await this.moderationLogService.log(me, 'clearUserFiles', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + count: files.length, + }); + for (const file of files) { this.driveService.deleteFile(file, false, me); } diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts index d420a929bd..9a7b3d5d62 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -25,9 +26,11 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private queueService: QueueService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.createCleanRemoteFilesJob(); + await this.moderationLogService.log(me, 'clearRemoteFiles', {}); + await this.queueService.createCleanRemoteFilesJob(); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts index d612572e2e..f5d20366cf 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -9,6 +9,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -29,7 +30,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - + private readonly moderationLogService: ModerationLogService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me) => { @@ -37,6 +38,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- userId: IsNull(), }); + await this.moderationLogService.log(me, 'clearOwnerlessFiles', { + count: files.length, + }); + for (const file of files) { this.driveService.deleteFile(file); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index f4fc79bdb3..795b579041 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -32,8 +33,13 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + addAliases: ps.aliases, + }); await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC'))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index b45a3c7156..1c5316a002 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -9,6 +9,7 @@ import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { FILE_TYPE_IMAGE } from '@/const.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -24,6 +25,11 @@ export const meta = { code: 'NO_SUCH_FILE', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', }, + unsupportedFileType: { + message: 'Unsupported file type.', + code: 'UNSUPPORTED_FILE_TYPE', + id: 'f7599d96-8750-af68-1633-9575d625c1a7', + }, duplicateName: { message: 'Duplicate name.', code: 'DUPLICATE_NAME', @@ -47,15 +53,21 @@ export const paramDef = { nullable: true, description: 'Use `null` to reset the category.', }, - aliases: { type: 'array', items: { - type: 'string', - } }, + aliases: { + type: 'array', + items: { + type: 'string', + }, + }, license: { type: 'string', nullable: true }, isSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'string', + }, + }, }, required: ['name', 'fileId'], } as const; @@ -67,9 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private customEmojiService: CustomEmojiService, - private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { @@ -78,11 +88,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); const isDuplicate = await this.customEmojiService.checkDuplicate(nameNfc); if (isDuplicate) throw new ApiError(meta.errors.duplicateName); + if (!FILE_TYPE_IMAGE.includes(driveFile.type)) throw new ApiError(meta.errors.unsupportedFileType); if (driveFile.user !== null) await this.driveFilesRepository.update(driveFile.id, { user: null }); const emoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: nameNfc, category: ps.category?.normalize('NFC') ?? null, aliases: ps.aliases?.map(a => a.normalize('NFC')) ?? [], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index acd2494131..07ffa0b1c7 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { EmojisRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -88,10 +87,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (isDuplicate) throw new ApiError(meta.errors.duplicateName); const addedEmoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: nameNfc, category: emoji.category?.normalize('NFC') ?? null, - aliases: emoji.aliases?.map(a => a.normalize('NFC')), + aliases: emoji.aliases.map(a => a.normalize('NFC')), host: null, license: emoji.license, isSensitive: emoji.isSensitive, diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index 8e5f69c894..ee7706f31a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import type { DriveFilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { secure: true, @@ -25,9 +28,16 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private queueService: QueueService, + private readonly moderationLogService: ModerationLogService, + @Inject(DI.driveFilesRepository) + private readonly driveFilesRepository: DriveFilesRepository, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.createImportCustomEmojisJob(me, ps.fileId); + const file = await driveFilesRepository.findOneByOrFail({ id: ps.fileId }); + await this.moderationLogService.log(me, 'importCustomEmojis', { + fileName: file.name, + }); + await this.queueService.createImportCustomEmojisJob(me, ps.fileId); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index e78620eac1..066eb1c7d9 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -32,8 +33,13 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + delAliases: ps.aliases, + }); await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC'))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index 69fc8e0cb5..8980ef0c86 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -32,8 +33,13 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + setAliases: ps.aliases, + }); await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC'))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index 679a9f9c42..2510349210 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -34,8 +35,13 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + category: ps.category, + }); await this.customEmojiService.setCategoryBulk(ps.ids, ps.category?.normalize('NFC') ?? null); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts index 4ba99faab7..a0205ae24a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -34,8 +35,13 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + license: ps.license, + }); await this.customEmojiService.setLicenseBulk(ps.ids, ps.license ?? null); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 071ddbef18..fd6db9c4ab 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -86,7 +86,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const error = await this.customEmojiService.update({ ...required, - driveFile, + originalUrl: driveFile != null ? driveFile.url : undefined, + publicUrl: driveFile != null ? (driveFile.webpublicUrl ?? driveFile.url) : undefined, + fileType: driveFile != null ? (driveFile.webpublicType ?? driveFile.type) : undefined, category: ps.category?.normalize('NFC'), aliases: ps.aliases?.map(a => a.normalize('NFC')), license: ps.license, diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts index 4a54c26009..89fd4be99c 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -30,7 +31,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - + private readonly moderationLogService: ModerationLogService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me) => { @@ -38,6 +39,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- userHost: ps.host, }); + await this.moderationLogService.log(me, 'clearInstanceFiles', { + host: ps.host, + count: files.length, + }); + for (const file of files) { this.driveService.deleteFile(file); } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index 601c898f52..e5d85e1d57 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -35,6 +36,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private followingsRepository: FollowingsRepository, private queueService: QueueService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { const followings = await this.followingsRepository.findBy([ @@ -51,6 +53,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.usersRepository.findOneByOrFail({ id: f.followeeId }), ]).then(([from, to]) => [{ id: from.id }, { id: to.id }]))); + await this.moderationLogService.log(me, 'severFollowRelations', { + host: ps.host, + }); + this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true }))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index daf19c4435..24d0b8527c 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -27,6 +27,7 @@ export const paramDef = { isNSFW: { type: 'boolean' }, rejectReports: { type: 'boolean' }, moderationNote: { type: 'string' }, + rejectQuotes: { type: 'boolean' }, }, required: ['host'], } as const; @@ -59,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- suspensionState, isNSFW: ps.isNSFW, rejectReports: ps.rejectReports, + rejectQuotes: ps.rejectQuotes, moderationNote: ps.moderationNote, }); @@ -92,6 +94,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }); } + if (ps.rejectQuotes != null && instance.rejectQuotes !== ps.rejectQuotes) { + const message = ps.rejectReports ? 'rejectQuotesInstance' : 'acceptQuotesInstance'; + this.moderationLogService.log(me, message, { + id: instance.id, + host: instance.host, + }); + } + if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) { this.moderationLogService.log(me, 'updateRemoteInstanceNote', { id: instance.id, diff --git a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts new file mode 100644 index 0000000000..5695866265 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: marie and sharkey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import webpush from 'web-push'; +const { generateVAPIDKeys } = webpush; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:meta', +} as const; + +export const paramDef = {} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const keys = await generateVAPIDKeys(); + + return { public: keys.publicKey, private: keys.privateKey }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts index 5ecae3161a..e52b177e2b 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts @@ -68,6 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- for (let i = 0; i < ps.count; i++) { ticketsPromises.push(this.registrationTicketsRepository.insertOne({ id: this.idService.gen(), + createdBy: me, + createdById: me.id, expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, code: generateInviteCode(), })); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 6495e3b7da..436dcf27cb 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -391,6 +391,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + robotsTxt: { + type: 'string', + optional: false, nullable: true, + }, enableIdenticonGeneration: { type: 'boolean', optional: false, nullable: false, @@ -708,6 +712,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances, enableServerMachineStats: instance.enableServerMachineStats, enableAchievements: instance.enableAchievements, + robotsTxt: instance.robotsTxt, enableIdenticonGeneration: instance.enableIdenticonGeneration, bannedEmailDomains: instance.bannedEmailDomains, policies: { ...DEFAULT_POLICIES, ...instance.policies }, diff --git a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts index d3fa4251dd..194e793eda 100644 --- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts @@ -5,8 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -27,22 +29,25 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + private readonly userProfilesRepository: UserProfilesRepository, + private readonly moderationLogService: ModerationLogService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); + const user = await this.cacheService.findUserById(ps.userId); - if (user == null) { - throw new Error('user not found'); - } + await this.moderationLogService.log(me, 'nsfwUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); await this.userProfilesRepository.update(user.id, { alwaysMarkNsfw: true, }); + + await this.cacheService.userProfileCache.refresh(ps.userId); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts index 1d32c6cc00..63fe2988ee 100644 --- a/packages/backend/src/server/api/endpoints/admin/promo/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { PromoNotesRepository } from '@/models/_.js'; import { GetterService } from '@/server/api/GetterService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -46,7 +48,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.promoNotesRepository) private promoNotesRepository: PromoNotesRepository, - + private readonly moderationLogService: ModerationLogService, + private readonly cacheService: CacheService, private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { @@ -61,6 +64,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.alreadyPromoted); } + const user = await this.cacheService.findUserById(note.userId); + await this.moderationLogService.log(me, 'createPromo', { + noteId: note.id, + noteUserId: user.id, + noteUserUsername: user.username, + noteUserHost: user.host, + }); + await this.promoNotesRepository.insert({ noteId: note.id, expiresAt: new Date(ps.expiresAt), diff --git a/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts new file mode 100644 index 0000000000..78f94ceeff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/reject-quotes.ts @@ -0,0 +1,63 @@ +/* + * 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 { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:reject-quotes', +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + rejectQuotes: { type: 'boolean', nullable: false }, + }, + required: ['userId', 'rejectQuotes'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, + + private readonly globalEventService: GlobalEventService, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.cacheService.findUserById(ps.userId); + + // Skip if there's nothing to do + if (user.rejectQuotes === ps.rejectQuotes) return; + + // Log event first. + // This ensures that we don't "lose" the log if an error occurs + await this.moderationLogService.log(me, ps.rejectQuotes ? 'rejectQuotesUser' : 'acceptQuotesUser', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + + await this.usersRepository.update(ps.userId, { + rejectQuotes: ps.rejectQuotes, + }); + + // Synchronize caches and other processes + this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts index 3d7bc4567e..129f69aca9 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { RelayService } from '@/core/RelayService.js'; import { ApiError } from '../../../error.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -64,6 +65,7 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private relayService: RelayService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { try { @@ -72,6 +74,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.invalidUrl); } + await this.moderationLogService.log(me, 'addRelay', { + inbox: ps.inbox, + }); + return await this.relayService.addRelay(ps.inbox); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts index 1f6e773cd4..292f21fde9 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { RelayService } from '@/core/RelayService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -27,9 +28,13 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private relayService: RelayService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { - return await this.relayService.removeRelay(ps.inbox); + await this.moderationLogService.log(me, 'removeRelay', { + inbox: ps.inbox, + }); + await this.relayService.removeRelay(ps.inbox); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts index 7e6045049a..eed21c6576 100644 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['admin'], @@ -29,24 +32,32 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private roleService: RoleService, + private readonly usersRepository: UsersRepository, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + private readonly roleService: RoleService, + private readonly globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } + const user = await this.cacheService.findUserById(ps.userId); if (await this.roleService.isModerator(user)) { throw new Error('cannot silence moderator account'); } + await this.moderationLogService.log(me, 'silenceUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); + await this.usersRepository.update(user.id, { isSilenced: true, }); + + this.globalEventService.publishInternalEvent(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { + id: user.id, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts index 26588365e1..52a0c076be 100644 --- a/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts @@ -5,8 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -27,18 +29,19 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + private readonly userProfilesRepository: UserProfilesRepository, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); + const user = await this.cacheService.findUserById(ps.userId); - if (user == null) { - throw new Error('user not found'); - } + await this.moderationLogService.log(me, 'unNsfwUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); await this.userProfilesRepository.update(user.id, { alwaysMarkNsfw: false, diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts index f92be0d8e0..9318943785 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -7,6 +7,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['admin'], @@ -28,18 +31,27 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + private readonly usersRepository: UsersRepository, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + private readonly globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); + const user = await this.cacheService.findUserById(ps.userId); - if (user == null) { - throw new Error('user not found'); - } + await this.moderationLogService.log(me, 'unSilenceUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); await this.usersRepository.update(user.id, { isSilenced: false, }); + + this.globalEventService.publishInternalEvent(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { + id: user.id, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 72f428d85f..b3733d3d39 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -149,6 +149,7 @@ export const paramDef = { enableStatsForFederatedInstances: { type: 'boolean' }, enableServerMachineStats: { type: 'boolean' }, enableAchievements: { type: 'boolean' }, + robotsTxt: { type: 'string', nullable: true }, enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, bannedEmailDomains: { type: 'array', items: { type: 'string' } }, @@ -636,6 +637,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.enableAchievements = ps.enableAchievements; } + if (ps.robotsTxt !== undefined) { + set.robotsTxt = ps.robotsTxt; + } + if (ps.enableIdenticonGeneration !== undefined) { set.enableIdenticonGeneration = ps.enableIdenticonGeneration; } diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 616a77e337..22bec8ef95 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -4,11 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; +import { isActor, isPost, getApId, getNullableApId } from '@/core/activitypub/type.js'; import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; @@ -18,7 +17,10 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; import { ApiError } from '../../error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; export const meta = { tags: ['federation'], @@ -26,12 +28,38 @@ export const meta = { requireCredential: true, kind: 'read:account', + // Up to 30 calls, then 1 per 1/2 second limit: { - duration: ms('1minute'), max: 30, + dripRate: 500, }, errors: { + federationNotAllowed: { + message: 'Federation for this host is not allowed.', + code: 'FEDERATION_NOT_ALLOWED', + id: '974b799e-1a29-4889-b706-18d4dd93e266', + }, + uriInvalid: { + message: 'URI is invalid.', + code: 'URI_INVALID', + id: '1a5eab56-e47b-48c2-8d5e-217b897d70db', + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '81b539cf-4f57-4b29-bc98-032c33c0792e', + }, + responseInvalid: { + message: 'Response from remote server is invalid.', + code: 'RESPONSE_INVALID', + id: '70193c39-54f3-4813-82f0-70a680f7495b', + }, + responseInvalidIdHostNotMatch: { + message: 'Requested URI and response URI host does not match.', + code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH', + id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a', + }, noSuchObject: { message: 'No such object.', code: 'NO_SUCH_OBJECT', @@ -94,6 +122,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, private apNoteService: ApNoteService, + private readonly apRequestService: ApRequestService, + private readonly instanceActorService: InstanceActorService, ) { super(meta, paramDef, async (ps, me) => { const object = await this.fetchAny(ps.uri, me); @@ -110,7 +140,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- */ @bindThis private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> { - if (!this.utilityService.isFederationAllowedUri(uri)) return null; + if (!this.utilityService.isFederationAllowedUri(uri)) { + throw new ApiError(meta.errors.federationNotAllowed); + } let local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(uri), @@ -118,6 +150,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- ])); if (local != null) return local; + // No local object found with that uri. + // Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that. + // Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup. + uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign + if (!this.utilityService.isFederationAllowedUri(uri)) { + throw new ApiError(meta.errors.federationNotAllowed); + } + const host = this.utilityService.extractDbHost(uri); // local object, not found in db? fail @@ -125,7 +165,40 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- // リモートから一旦オブジェクトフェッチ const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri) as any; + const object = await resolver.resolve(uri).catch((err) => { + if (err instanceof IdentifiableError) { + switch (err.id) { + // resolve + case 'b94fd5b1-0e3b-4678-9df2-dad4cd515ab2': + throw new ApiError(meta.errors.uriInvalid); + case '0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5': + case 'd592da9f-822f-4d91-83d7-4ceefabcf3d2': + throw new ApiError(meta.errors.requestFailed); + case '09d79f9e-64f1-4316-9cfa-e75c4d091574': + throw new ApiError(meta.errors.federationNotAllowed); + case '72180409-793c-4973-868e-5a118eb5519b': + case 'ad2dc287-75c1-44c4-839d-3d2e64576675': + throw new ApiError(meta.errors.responseInvalid); + case 'fd93c2fa-69a8-440f-880b-bf178e0ec877': + throw new ApiError(meta.errors.responseInvalidIdHostNotMatch); + + // resolveLocal + case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8': + throw new ApiError(meta.errors.uriInvalid); + case 'a9d946e5-d276-47f8-95fb-f04230289bb0': + case '06ae3170-1796-4d93-a697-2611ea6d83b6': + throw new ApiError(meta.errors.noSuchObject); + case '7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0': + throw new ApiError(meta.errors.responseInvalid); + } + } + + throw new ApiError(meta.errors.requestFailed); + }); + + if (object.id == null) { + throw new ApiError(meta.errors.responseInvalid); + } // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // これはDBに存在する可能性があるため再度DB検索 @@ -167,4 +240,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- return null; } + + /** + * Resolves an arbitrary URI to its canonical, post-redirect form. + */ + private async resolveCanonicalUri(uri: string): Promise<string> { + const user = await this.instanceActorService.getInstanceActor(); + const res = await this.apRequestService.signedGet(uri, user, true); + return getNullableApId(res) ?? uri; + } } diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index d2a75225ed..7cca688fda 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['channels'], diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 8e821da0da..5df212415d 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DI } from '@/di-symbols.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; -import { Brackets } from 'typeorm'; export const meta = { tags: ['drive'], @@ -45,7 +45,7 @@ export const paramDef = { folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] }, - searchQuery: { type: 'string', default: '' } + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; diff --git a/packages/backend/src/server/api/endpoints/drive/files/delete.ts b/packages/backend/src/server/api/endpoints/drive/files/delete.ts index 7a009b12a1..3065bb6711 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/delete.ts @@ -11,7 +11,6 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['drive'], diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 94a0e673a3..306a646785 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -11,7 +11,6 @@ import { RoleService } from '@/core/RoleService.js'; import { DriveService } from '@/core/DriveService.js'; import type { Config } from '@/config.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['drive'], diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index 1245706b0d..525cb8c5d6 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -42,7 +42,7 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, - searchQuery: { type: 'string', default: '' } + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index cd47c0fc68..8d51d09ea6 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -10,7 +10,6 @@ import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityServi import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['drive'], diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 3cc7f89ab9..4909c948e3 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { EmojisRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -59,7 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const emojis = await this.emojisRepository.createQueryBuilder() .where('host IS NULL') .orderBy('LOWER(category)', 'ASC') - .orderBy('LOWER(name)', 'ASC') + .addOrderBy('LOWER(name)', 'ASC') .getMany(); return { emojis: await this.emojiEntityService.packSimpleMany(emojis), diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index 3ec9522c44..5217f79065 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -13,10 +13,11 @@ export const meta = { requireCredential: false, - // 2 calls per second + // Up to 10 calls, then 4 / second. + // This allows for reliable automation. limit: { - duration: 1000, - max: 2, + max: 10, + dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts index 1010567113..271a44f8d5 100644 --- a/packages/backend/src/server/api/endpoints/flash/delete.ts +++ b/packages/backend/src/server/api/endpoints/flash/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { FlashsRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['flashs'], @@ -78,7 +78,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- flashId: flash.id, flashUserId: flash.userId, flashUserUsername: user.username, - flash, }); } }); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts index 68478ba55c..9854358e3e 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { GalleryPostsRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['gallery'], @@ -78,7 +78,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- postId: post.id, postUserId: post.userId, postUserUsername: user.username, - post, }); } }); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 9347c9ca27..48a2e3b40a 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -31,10 +31,12 @@ export const meta = { }, }, - // 3 calls per second + // up to 20 calls, then 1 per second. + // This handles bursty traffic when all tabs reload as a group limit: { - duration: 1000, - max: 3, + max: 20, + dripSize: 1, + dripRate: 1000, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index 34b6907338..12d5ef443d 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -5,12 +5,12 @@ import * as OTPAuth from 'otpauth'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/_.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 084d4af658..370d9915a3 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -6,6 +6,7 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -14,7 +15,6 @@ import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/model import { WebAuthnService } from '@/core/WebAuthnService.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, @@ -63,8 +63,8 @@ export const paramDef = { required: ['password', 'name', 'credential'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() +// eslint-disable-next-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( @Inject(DI.userProfilesRepository) diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index a1c965f603..cd520cff0f 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 6ab50a57c9..893ea30391 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -6,13 +6,13 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index 888d0fc6ef..d27c14c69b 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -8,13 +8,13 @@ import * as argon2 from 'argon2'; import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { UserProfilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index 614fd0c498..b01e452056 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -6,6 +6,7 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -13,7 +14,6 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 2773825373..2fe4fdc4c0 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -6,6 +6,7 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/_.js'; @@ -13,7 +14,6 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts index eb8a63b3dc..4a41c7b984 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -5,13 +5,13 @@ //import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserSecurityKeysRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 661fa257a6..f290ff6844 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -93,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- name: token.name ?? token.app?.name, createdAt: this.idService.parse(token.id).date.toISOString(), lastUsedAt: token.lastUsedAt?.toISOString(), - permission: token.permission, + permission: token.app ? token.app.permission : token.permission, }))); }); } diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index f131c7e9d1..4069683740 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -6,11 +6,11 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index ebb25ebf1c..52f96e5bbd 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { DI } from '@/di-symbols.js'; import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; import type { MiMeta } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index 565eaaafc0..10fb923d4f 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -6,12 +6,12 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DeleteAccountService } from '@/core/DeleteAccountService.js'; import { DI } from '@/di-symbols.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index 814ffb5488..38328bb7d4 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -6,12 +6,12 @@ //import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import ms from 'ms'; export const meta = { requireCredential: true, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 09c06a108d..f74452e2af 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -133,6 +133,12 @@ export const meta = { id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', httpStatusCode: 422, }, + + maxCwLength: { + message: 'You tried setting a default content warning which is too long.', + code: 'MAX_CW_LENGTH', + id: '7004c478-bda3-4b4f-acb2-4316398c9d52', + }, }, res: { @@ -243,6 +249,12 @@ export const paramDef = { uniqueItems: true, items: { type: 'string' }, }, + defaultCW: { type: 'string', nullable: true }, + defaultCWPriority: { + type: 'string', + enum: ['default', 'parent', 'defaultParent', 'parentDefault'], + nullable: false, + }, }, } as const; @@ -494,6 +506,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; } + let defaultCW = ps.defaultCW; + if (defaultCW !== undefined) { + if (defaultCW === '') defaultCW = null; + if (defaultCW && defaultCW.length > this.config.maxCwLength) { + throw new ApiError(meta.errors.maxCwLength); + } + + profileUpdates.defaultCW = defaultCW; + } + if (ps.defaultCWPriority !== undefined) { + profileUpdates.defaultCWPriority = ps.defaultCWPriority; + } + //#region emojis/tags let emojis = [] as string[]; @@ -592,7 +617,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const html = await this.httpRequestService.getHtml(url); const { window } = new JSDOM(html); - const doc = window.document; + const doc: Document = window.document; const myLink = `${this.config.url}/@${user.username}`; diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 1e14bafc87..ccc98c4b10 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MutingsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { UserMutingService } from '@/core/UserMutingService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['account'], diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index d1cf0123dc..b0f32bfda8 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -143,6 +143,12 @@ export const meta = { code: 'CONTAINS_TOO_MANY_MENTIONS', id: '4de0363a-3046-481b-9b0f-feff3e211025', }, + + quoteDisabledForUser: { + message: 'You do not have permission to create quote posts.', + code: 'QUOTE_DISABLED_FOR_USER', + id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153', + }, }, } as const; @@ -415,6 +421,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.containsProhibitedWords); } else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') { throw new ApiError(meta.errors.containsTooManyMentions); + } else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') { + throw new ApiError(meta.errors.quoteDisabledForUser); } } throw e; diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index dc94c78e75..cc2293c5d6 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -176,6 +176,12 @@ export const meta = { id: '33510210-8452-094c-6227-4a6c05d99f02', }, + quoteDisabledForUser: { + message: 'You do not have permission to create quote posts.', + code: 'QUOTE_DISABLED_FOR_USER', + id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153', + }, + containsProhibitedWords: { message: 'Cannot post because it contains prohibited words.', code: 'CONTAINS_PROHIBITED_WORDS', @@ -469,6 +475,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.containsProhibitedWords); } else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') { throw new ApiError(meta.errors.containsTooManyMentions); + } else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') { + throw new ApiError(meta.errors.quoteDisabledForUser); } } throw e; diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts index 19a6a5af54..742c872d45 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts @@ -4,12 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { DI } from '@/di-symbols.js'; import type { NoteFavoritesRepository } from '@/models/_.js'; import { ApiError } from '../../../error.js'; -import ms from 'ms'; export const meta = { tags: ['notes', 'favorites'], diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index c45fcd7c5c..0f2592bd78 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -12,8 +12,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; import { CacheService } from '@/core/CacheService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], diff --git a/packages/backend/src/server/api/endpoints/notes/like.ts b/packages/backend/src/server/api/endpoints/notes/like.ts index 9068de2865..d6511faf95 100644 --- a/packages/backend/src/server/api/endpoints/notes/like.ts +++ b/packages/backend/src/server/api/endpoints/notes/like.ts @@ -1,5 +1,5 @@ -import { DI } from '@/di-symbols.js'; import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ReactionService } from '@/core/ReactionService.js'; diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts index c6032fbdae..0c2e00ee8d 100644 --- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts @@ -14,12 +14,10 @@ import type { NotesRepository, BlockingsRepository, DriveFilesRepository, - ChannelsRepository, NoteScheduleRepository, } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; -import type { MiChannel } from '@/models/Channel.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; @@ -210,9 +208,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - private queueService: QueueService, private roleService: RoleService, private idService: IdService, diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts index 4895733d4e..4dd3d7a81a 100644 --- a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -7,12 +7,14 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; -import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js'; +import type { MiNote, MiUser, MiNoteSchedule, NoteScheduleRepository, NotesRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { QueryService } from '@/core/QueryService.js'; import { Packed } from '@/misc/json-schema.js'; import { noteVisibilities } from '@/types.js'; +import { bindThis } from '@/decorators.js'; export const meta = { tags: ['notes'], @@ -81,7 +83,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- @Inject(DI.noteScheduleRepository) private noteScheduleRepository: NoteScheduleRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, private queryService: QueryService, ) { @@ -106,6 +112,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- userId: string; scheduledAt: string; }[] = await Promise.all(scheduleNotes.map(async (item: MiNoteSchedule) => { + const renote = await this.fetchNote(item.note.renote, me); + const reply = await this.fetchNote(item.note.reply, me); + return { ...item, scheduledAt: item.scheduledAt.toISOString(), @@ -115,12 +124,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- user: user, visibility: item.note.visibility ?? 'public', reactionAcceptance: item.note.reactionAcceptance ?? null, - visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [], + visibleUsers: item.note.visibleUsers ? await this.userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [], fileIds: item.note.files ? item.note.files : [], files: await this.driveFileEntityService.packManyByIds(item.note.files), createdAt: item.scheduledAt.toISOString(), isSchedule: true, id: item.id, + renote, reply, + renoteId: item.note.renote, + replyId: item.note.reply, }, }; })); @@ -128,4 +140,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- return scheduleNotesPack; }); } + + @bindThis + private async fetchNote( + id: MiNote['id'] | null | undefined, + me: MiUser, + ): Promise<Packed<'Note'> | null> { + if (id) { + const note = await this.notesRepository.findOneBy({ id }); + if (note) { + note.reactionAndUserPairCache ??= []; + return this.noteEntityService.pack(note, me); + } + } + return null; + } } diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 227ac0ebbf..6bba7bf37e 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -87,7 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('note.visibility = \'public\'') + .andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()` .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index eca55cd085..f46f4d2adb 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { SearchService } from '@/core/SearchService.js'; +import { fileTypeCategories, SearchService } from '@/core/SearchService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; @@ -52,7 +52,11 @@ export const paramDef = { type: 'string', description: 'The local host is represented with `.`.', }, - filetype: { type: 'string', nullable: true }, + filetype: { + type: 'string', + nullable: true, + enum: fileTypeCategories, + }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, channelId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, order: { type: 'string' }, diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index fa03b0b457..6de5fe3d44 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -7,7 +7,7 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; import type { DriveFilesRepository, PagesRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; -import { MiPage } from '@/models/Page.js'; +import { MiPage, pageNameSchema } from '@/models/Page.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -51,7 +51,7 @@ export const paramDef = { type: 'object', properties: { title: { type: 'string' }, - name: { type: 'string', minLength: 1 }, + name: { ...pageNameSchema, minLength: 1 }, summary: { type: 'string', nullable: true }, content: { type: 'array', items: { type: 'object', additionalProperties: true, diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index c2c3215f49..c95f8ecf6b 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; import type { PagesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; -import ms from 'ms'; export const meta = { tags: ['pages'], @@ -79,7 +79,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- pageId: page.id, pageUserId: page.userId, pageUserUsername: user.username, - page, }); } }); diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index f11bbbcb1a..a6aeb6002e 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -10,6 +10,7 @@ import type { PagesRepository, DriveFilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { pageNameSchema } from '@/models/Page.js'; export const meta = { tags: ['pages'], @@ -31,13 +32,11 @@ export const meta = { code: 'NO_SUCH_PAGE', id: '21149b9e-3616-4778-9592-c4ce89f5a864', }, - accessDenied: { message: 'Access denied.', code: 'ACCESS_DENIED', id: '3c15cd52-3b4b-4274-967d-6456fc4f792b', }, - noSuchFile: { message: 'No such file.', code: 'NO_SUCH_FILE', @@ -56,7 +55,7 @@ export const paramDef = { properties: { pageId: { type: 'string', format: 'misskey:id' }, title: { type: 'string' }, - name: { type: 'string', minLength: 1 }, + name: { ...pageNameSchema, minLength: 1 }, summary: { type: 'string', nullable: true }, content: { type: 'array', items: { type: 'object', additionalProperties: true, @@ -102,15 +101,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } } - await this.pagesRepository.findBy({ - id: Not(ps.pageId), - userId: me.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); - } - }); + if (ps.name != null) { + await this.pagesRepository.findBy({ + id: Not(ps.pageId), + userId: me.id, + name: ps.name, + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + } await this.pagesRepository.update(page.id, { updatedAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index e765163f8e..528de76707 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -64,10 +64,11 @@ export const meta = { }, }, - // 2 calls per second + // 24 calls, then 7 per second-ish (1 for each type of server info graph) limit: { - duration: 1000, - max: 2, + max: 24, + dripSize: 7, + dripRate: 900, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index e659c46713..c7016d8d32 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -58,6 +58,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isInstanceMuted: { + type: 'boolean', + optional: true, nullable: false, + }, + memo: { + type: 'string', + optional: true, nullable: true, + }, }, }, { @@ -103,6 +111,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isInstanceMuted: { + type: 'boolean', + optional: true, nullable: false, + }, + memo: { + type: 'string', + optional: true, nullable: true, + }, }, }, }, diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 7ebca78a7d..118362149d 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -57,10 +57,10 @@ export const meta = { }, }, - // 5 calls per 2 seconds + // up to 50 calls @ 4 per second limit: { - duration: 1000 * 2, - max: 5, + max: 50, + dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts new file mode 100644 index 0000000000..9426318e34 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { CustomEmojiService, fetchEmojisHostTypes, fetchEmojisSortKeys } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + kind: 'read:admin:emoji', + + res: { + type: 'object', + properties: { + emojis: { + type: 'array', + items: { + type: 'object', + ref: 'EmojiDetailedAdmin', + }, + }, + count: { type: 'integer' }, + allCount: { type: 'integer' }, + allPages: { type: 'integer' }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { + type: 'object', + nullable: true, + properties: { + updatedAtFrom: { type: 'string' }, + updatedAtTo: { type: 'string' }, + name: { type: 'string' }, + host: { type: 'string' }, + uri: { type: 'string' }, + publicUrl: { type: 'string' }, + originalUrl: { type: 'string' }, + type: { type: 'string' }, + aliases: { type: 'string' }, + category: { type: 'string' }, + license: { type: 'string' }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + hostType: { + type: 'string', + enum: fetchEmojisHostTypes, + default: 'all', + }, + roleIds: { + type: 'array', + items: { type: 'string', format: 'misskey:id' }, + }, + }, + }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + page: { type: 'integer' }, + sortKeys: { + type: 'array', + default: ['-id'], + items: { + type: 'string', + enum: fetchEmojisSortKeys, + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private customEmojiService: CustomEmojiService, + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const q = ps.query; + const result = await this.customEmojiService.fetchEmojis( + { + query: { + updatedAtFrom: q?.updatedAtFrom, + updatedAtTo: q?.updatedAtTo, + name: q?.name, + host: q?.host, + uri: q?.uri, + publicUrl: q?.publicUrl, + type: q?.type, + aliases: q?.aliases, + category: q?.category, + license: q?.license, + isSensitive: q?.isSensitive, + localOnly: q?.localOnly, + hostType: q?.hostType, + roleIds: q?.roleIds, + }, + sinceId: ps.sinceId, + untilId: ps.untilId, + }, + { + limit: ps.limit, + page: ps.page, + sortKeys: ps.sortKeys, + }, + ); + + return { + emojis: await this.emojiEntityService.packDetailedAdminMany(result.emojis), + count: result.count, + allCount: result.allCount, + allPages: result.allPages, + }; + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index b40e4cdaa4..69799bdade 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -3,53 +3,78 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import megalodon, { Entity, MegalodonInterface } from 'megalodon'; import querystring from 'querystring'; +import { megalodon, Entity, MegalodonInterface } from 'megalodon'; import { IsNull } from 'typeorm'; import multer from 'fastify-multer'; -import type { AccessTokensRepository, NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js'; +import { Inject, Injectable } from '@nestjs/common'; +import type { AccessTokensRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Config } from '@/config.js'; +import { DriveService } from '@/core/DriveService.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account.js'; +import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js'; +import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js'; +import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js'; +import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { AuthMastodonRoute } from './endpoints/auth.js'; +import { toBoolean } from './timelineArgs.js'; import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js'; import { getInstance } from './endpoints/meta.js'; import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js'; -import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DriveService } from '@/core/DriveService.js'; +import type { FastifyInstance, FastifyPluginOptions, FastifyRequest } from 'fastify'; -export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { +export function getAccessToken(authorization: string | undefined): string | null { const accessTokenArr = authorization?.split(' ') ?? [null]; - const accessToken = accessTokenArr[accessTokenArr.length - 1]; - const generator = (megalodon as any).default; - const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface; - return client; + return accessTokenArr[accessTokenArr.length - 1]; +} + +export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { + const accessToken = getAccessToken(authorization); + return megalodon('misskey', BASE_URL, accessToken); } @Injectable() export class MastodonApiServerService { constructor( @Inject(DI.meta) - private serverSettings: MiMeta, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, + private readonly serverSettings: MiMeta, + @Inject(DI.usersRepository) + private readonly usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, + private readonly userProfilesRepository: UserProfilesRepository, @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - @Inject(DI.config) - private config: Config, - private userEntityService: UserEntityService, - private driveService: DriveService, - private mastoConverter: MastoConverters, + private readonly accessTokensRepository: AccessTokensRepository, + @Inject(DI.config) + private readonly config: Config, + private readonly driveService: DriveService, + private readonly mastoConverters: MastoConverters, + private readonly logger: MastodonLogger, + private readonly authenticateService: AuthenticateService, ) { } @bindThis + public async getAuthClient(request: FastifyRequest): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> { + const accessToken = getAccessToken(request.headers.authorization); + const [me] = await this.authenticateService.authenticate(accessToken); + + const baseUrl = `${request.protocol}://${request.host}`; + const client = megalodon('misskey', baseUrl, accessToken); + + return { client, me }; + } + + @bindThis + public async getAuthOnly(request: FastifyRequest): Promise<MiLocalUser | null> { + const accessToken = getAccessToken(request.headers.authorization); + const [me] = await this.authenticateService.authenticate(accessToken); + return me; + } + + @bindThis public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { const upload = multer({ storage: multer.diskStorage({}), @@ -59,12 +84,12 @@ export class MastodonApiServerService { }, }); - fastify.addHook('onRequest', (request, reply, done) => { + fastify.addHook('onRequest', (_, reply, done) => { reply.header('Access-Control-Allow-Origin', '*'); done(); }); - fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => { + fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => { let body = ''; payload.on('data', (data) => { body += data; @@ -73,8 +98,8 @@ export class MastodonApiServerService { try { const parsed = querystring.parse(body); done(null, parsed); - } catch (e: any) { - done(e); + } catch (e) { + done(e as Error); } }); payload.on('error', done); @@ -83,20 +108,21 @@ export class MastodonApiServerService { fastify.register(multer.contentParser); fastify.get('/v1/custom_emojis', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const data = await client.getInstanceCustomEmojis(); reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/custom_emojis', data); + reply.code(401).send(data); } }); fastify.get('/v1/instance', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in @@ -111,118 +137,124 @@ export class MastodonApiServerService { }, order: { id: 'ASC' }, }); - const contact = admin == null ? null : await this.mastoConverter.convertAccount((await client.getAccount(admin.id)).data); + const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data); reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/instance', data); + reply.code(401).send(data); } }); fastify.get('/v1/announcements', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const data = await client.getInstanceAnnouncements(); reply.send(data.data.map((announcement) => convertAnnouncement(announcement))); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/announcements', data); + reply.code(401).send(data); } }); - fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.dismissInstanceAnnouncement( - _request.body['id'], - ); + if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' }); + const data = await client.dismissInstanceAnnouncement(_request.body['id']); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/announcements/${_request.body.id}/dismiss`, data); + reply.code(401).send(data); } - }, - ); + }); fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const multipartData = await _request.file; + const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } const data = await client.uploadMedia(multipartData); reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/media', data); + reply.code(401).send(data); } }); - fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const multipartData = await _request.file; + const multipartData = await _request.file(); if (!multipartData) { reply.code(401).send({ error: 'No image' }); return; } - const data = await client.uploadMedia(multipartData, _request.body!); + const data = await client.uploadMedia(multipartData, _request.body); reply.send(convertAttachment(data.data as Entity.Attachment)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v2/media', data); + reply.code(401).send(data); } }); fastify.get('/v1/filters', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getFilters(); reply.send(data.data.map((filter) => convertFilter(filter))); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/filters', data); + reply.code(401).send(data); } }); fastify.get('/v1/trends', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getInstanceTrends(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends', data); + reply.code(401).send(data); } }); fastify.get('/v1/trends/tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getInstanceTrends(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends/tags', data); + reply.code(401).send(data); } }); @@ -231,50 +263,69 @@ export class MastodonApiServerService { reply.send([]); }); - fastify.post('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post<AuthMastodonRoute>('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await ApiAuthMastodon(_request, client); reply.send(data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/apps', data); + reply.code(401).send(data); } }); fastify.get('/v1/preferences', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in try { const data = await client.getPreferences(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/preferences', data); + reply.code(401).send(data); } }); //#region Accounts - fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in + fastify.get<ApiAccountMastodonRoute>('/v1/accounts/verify_credentials', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.verifyCredentials()); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/verify_credentials', data); + reply.code(401).send(data); } }); - fastify.patch('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.patch<{ + Body: { + discoverable?: string, + bot?: string, + display_name?: string, + note?: string, + avatar?: string, + header?: string, + locked?: string, + source?: { + privacy?: string, + sensitive?: string, + language?: string, + }, + fields_attributes?: { + name: string, + value: string, + }[], + }, + }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt // displayed without being logged in @@ -332,512 +383,495 @@ export class MastodonApiServerService { (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); } - const data = await client.updateCredentials(_request.body!); - reply.send(await this.mastoConverter.convertAccount(data.data)); - } catch (e: any) { - //console.error(e); - reply.code(401).send(e.response.data); + const options = { + ..._request.body, + discoverable: toBoolean(_request.body.discoverable), + bot: toBoolean(_request.body.bot), + locked: toBoolean(_request.body.locked), + source: _request.body.source ? { + ..._request.body.source, + sensitive: toBoolean(_request.body.source.sensitive), + } : undefined, + }; + const data = await client.updateCredentials(options); + reply.send(await this.mastoConverters.convertAccount(data.data)); + } catch (e) { + const data = getErrorData(e); + this.logger.error('PATCH /v1/accounts/update_credentials', data); + reply.code(401).send(data); } }); - fastify.get('/v1/accounts/lookup', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in + const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in try { - const data = await client.search((_request.query as any).acct, { type: 'accounts' }); + if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' }); + const data = await client.search(_request.query.acct, { type: 'accounts' }); const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id }); - data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) || []; - reply.send(await this.mastoConverter.convertAccount(data.data.accounts[0])); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? []; + reply.send(await this.mastoConverters.convertAccount(data.data.accounts[0])); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/lookup', data); + reply.code(401).send(data); } }); - fastify.get('/v1/accounts/relationships', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt - // displayed without being logged in - let users; + fastify.get<ApiAccountMastodonRoute & { Querystring: { id?: string | string[], 'id[]'?: string | string[] }}>('/v1/accounts/relationships', async (_request, reply) => { try { - let ids = _request.query ? (_request.query as any)['id[]'] ?? (_request.query as any)['id'] : null; + const { client, me } = await this.getAuthClient(_request); + let ids = _request.query['id[]'] ?? _request.query['id'] ?? []; if (typeof ids === 'string') { ids = [ids]; } - users = ids; - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); - reply.send(await account.getRelationships(users)); - } catch (e: any) { - /* console.error(e); */ - const data = e.response.data; - data.users = users; - console.error(data); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); + reply.send(await account.getRelationships(ids)); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/accounts/relationships', data); reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const sharkId = _request.params.id; - const data = await client.getAccount(sharkId); - const account = await this.mastoConverter.convertAccount(data.data); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const data = await client.getAccount(_request.params.id); + const account = await this.mastoConverters.convertAccount(data.data); reply.send(account); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getStatuses()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getFeaturedTags(); reply.send(data.data.map((tag) => convertFeaturedTag(tag))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getFollowers()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getFollowing()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getAccountLists(_request.params.id); reply.send(data.data.map((list) => convertList(list))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.addFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rmFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.addBlock()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rmBlock()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.addMute()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rmMute()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data); + reply.code(401).send(data); } }); fastify.get('/v1/followed_tags', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const data = await client.getFollowedTags(); reply.send(data.data); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/followed_tags', data); + reply.code(401).send(data); } }); - fastify.get('/v1/bookmarks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiAccountMastodonRoute>('/v1/bookmarks', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getBookmarks()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/bookmarks', data); + reply.code(401).send(data); } }); - fastify.get('/v1/favourites', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiAccountMastodonRoute>('/v1/favourites', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getFavourites()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/favourites', data); + reply.code(401).send(data); } }); - fastify.get('/v1/mutes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiAccountMastodonRoute>('/v1/mutes', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getMutes()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/mutes', data); + reply.code(401).send(data); } }); - fastify.get('/v1/blocks', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiAccountMastodonRoute>('/v1/blocks', async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.getBlocks()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/blocks', data); + reply.code(401).send(data); } }); - fastify.get('/v1/follow_requests', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit ); - reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverter.convertAccount(account as Entity.Account)))); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + const limit = _request.query.limit ? parseInt(_request.query.limit) : 20; + const data = await client.getFollowRequests(limit); + reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account as Entity.Account)))); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/follow_requests', data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.acceptFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const account = new ApiAccountMastodon(_request, client, BASE_URL, this.mastoConverter); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters); reply.send(await account.rejectFollow()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data); + reply.code(401).send(data); } }); //#endregion //#region Search - fastify.get('/v1/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiSearchMastodonRoute>('/v1/search', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.SearchV1()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/search', data); + reply.code(401).send(data); } }); - fastify.get('/v2/search', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiSearchMastodonRoute>('/v2/search', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.SearchV2()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/search', data); + reply.code(401).send(data); } }); - fastify.get('/v1/trends/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.getStatusTrends()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/trends/statuses', data); + reply.code(401).send(data); } }); - fastify.get('/v2/suggestions', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; try { - const search = new ApiSearchMastodon(_request, client, BASE_URL, this.mastoConverter); + const { client, me } = await this.getAuthClient(_request); + const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters); reply.send(await search.getSuggestions()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v2/suggestions', data); + reply.code(401).send(data); } }); //#endregion //#region Notifications - fastify.get('/v1/notifications', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (_request, reply) => { try { - const notify = new ApiNotifyMastodon(_request, client); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.getNotifications()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/notifications', data); + reply.code(401).send(data); } }); - fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.get<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id', async (_request, reply) => { try { - const notify = new ApiNotifyMastodon(_request, client); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.getNotification()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/notification/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const notify = new ApiNotifyMastodon(_request, client); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.rmNotification()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data); + reply.code(401).send(data); } }); - fastify.post('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => { try { - const notify = new ApiNotifyMastodon(_request, client); + const { client, me } = await this.getAuthClient(_request); + const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters); reply.send(await notify.rmNotifications()); - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/notifications/clear', data); + reply.code(401).send(data); } }); //#endregion //#region Filters - fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.get<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const filter = new ApiFilterMastodon(_request, client); - !_request.params.id ? reply.send(await filter.getFilters()) : reply.send(await filter.getFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + _request.params.id + ? reply.send(await filter.getFilter()) + : reply.send(await filter.getFilters()); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.post('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post<ApiFilterMastodonRoute>('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { const filter = new ApiFilterMastodon(_request, client); reply.send(await filter.createFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/filters', data); + reply.code(401).send(data); } }); - fastify.post<{ Params: { id: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const filter = new ApiFilterMastodon(_request, client); reply.send(await filter.updateFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); } }); - fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.delete<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const filter = new ApiFilterMastodon(_request, client); reply.send(await filter.rmFilter()); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data); + reply.code(401).send(data); } }); //#endregion //#region Timelines - const TLEndpoint = new ApiTimelineMastodon(fastify, this.config, this.mastoConverter); + const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverters, this.logger, this); // GET Endpoints TLEndpoint.getTL(); @@ -862,7 +896,7 @@ export class MastodonApiServerService { //#endregion //#region Status - const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverter); + const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverters, this.logger, this.authenticateService, this); // GET Endpoints NoteEndpoint.getStatus(); @@ -889,16 +923,32 @@ export class MastodonApiServerService { NoteEndpoint.votePoll(); // PUT Endpoint - fastify.put<{ Params: { id: string } }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + fastify.put<{ + Params: { + id?: string, + }, + Body: { + file?: unknown, + description?: string, + focus?: string, + is_sensitive?: string, + }, + }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const data = await client.updateMedia(_request.params.id, _request.body!); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const options = { + ..._request.body, + is_sensitive: toBoolean(_request.body.is_sensitive), + }; + const data = await client.updateMedia(_request.params.id, options); reply.send(convertAttachment(data.data)); - } catch (e: any) { - /* console.error(e); */ - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`PUT /v1/media/${_request.params.id}`, data); + reply.code(401).send(data); } }); NoteEndpoint.updateStatus(); diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts new file mode 100644 index 0000000000..671ecdcbed --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { QueryService } from '@/core/QueryService.js'; +import type { MiNote, NotesRepository } from '@/models/_.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { ApiError } from '../error.js'; + +/** + * Utility service for accessing data with Mastodon semantics + */ +@Injectable() +export class MastodonDataService { + constructor( + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, + + @Inject(QueryService) + private readonly queryService: QueryService, + ) {} + + /** + * Fetches a note in the context of the current user, and throws an exception if not found. + */ + public async requireNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote> { + const note = await this.getNote(noteId, me); + + if (!note) { + throw new ApiError({ + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', + kind: 'client', + httpStatusCode: 404, + }); + } + + return note; + } + + /** + * Fetches a note in the context of the current user. + */ + public async getNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote | null> { + // Root query: note + required dependencies + const query = this.notesRepository + .createQueryBuilder('note') + .where('note.id = :noteId', { noteId }) + .innerJoinAndSelect('note.user', 'user'); + + // Restrict visibility + this.queryService.generateVisibilityQuery(query, me); + if (me) { + this.queryService.generateBlockedUserQuery(query, me); + } + + return await query.getOne(); + } + + /** + * Checks where the current user has made a reblog / boost / pure renote of a given target note. + */ + public async hasReblog(noteId: string, me: MiLocalUser | null | undefined): Promise<boolean> { + if (!me) return false; + + return await this.notesRepository.existsBy({ + // Reblog of the target note by me + userId: me.id, + renoteId: noteId, + + // That is pure (not a quote) + text: IsNull(), + cw: IsNull(), + replyId: IsNull(), + hasPoll: false, + fileIds: '{}', + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts new file mode 100644 index 0000000000..bb844773c4 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import Logger, { Data } from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class MastodonLogger { + public readonly logger: Logger; + + constructor(loggerService: LoggerService) { + this.logger = loggerService.getLogger('masto-api'); + } + + public error(endpoint: string, error: Data): void { + this.logger.error(`Error in mastodon API endpoint ${endpoint}:`, error); + } +} + +export function getErrorData(error: unknown): Data { + if (error == null) return {}; + if (typeof(error) === 'string') return error; + if (typeof(error) === 'object') { + if ('response' in error) { + if (typeof(error.response) === 'object' && error.response) { + if ('data' in error.response) { + if (typeof(error.response.data) === 'object' && error.response.data) { + return error.response.data as Record<string, unknown>; + } + } + } + } + return error as Record<string, unknown>; + } + return { error }; +} diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 405701e826..b6ff5bc59a 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -9,18 +9,32 @@ import mfm from '@transfem-org/sfm-js'; import { DI } from '@/di-symbols.js'; import { MfmService } from '@/core/MfmService.js'; import type { Config } from '@/config.js'; -import type { IMentionedRemoteUsers } from '@/models/Note.js'; -import type { MiUser } from '@/models/User.js'; -import type { NoteEditRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IMentionedRemoteUsers, MiNote } from '@/models/Note.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; +import type { NoteEditRepository, UserProfilesRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { IdService } from '@/core/IdService.js'; -import { GetterService } from '../GetterService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js'; +import { GetterService } from '@/server/api/GetterService.js'; -export enum IdConvertType { - MastodonId, - SharkeyId, +// Missing from Megalodon apparently +// https://docs.joinmastodon.org/entities/StatusEdit/ +export interface StatusEdit { + content: string; + spoiler_text: string; + sensitive: boolean; + created_at: string; + account: MastodonEntity.Account; + poll?: { + options: { + title: string; + }[] + }, + media_attachments: MastodonEntity.Attachment[], + emojis: MastodonEntity.Emoji[], } export const escapeMFM = (text: string): string => text @@ -36,27 +50,25 @@ export const escapeMFM = (text: string): string => text export class MastoConverters { constructor( @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + private readonly config: Config, @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + private readonly userProfilesRepository: UserProfilesRepository, @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, + private readonly noteEditRepository: NoteEditRepository, - private mfmService: MfmService, - private getterService: GetterService, - private customEmojiService: CustomEmojiService, - private idService: IdService, - private driveFileEntityService: DriveFileEntityService, - ) { - } + private readonly mfmService: MfmService, + private readonly getterService: GetterService, + private readonly customEmojiService: CustomEmojiService, + private readonly idService: IdService, + private readonly driveFileEntityService: DriveFileEntityService, + private readonly mastodonDataService: MastodonDataService, + ) {} - private encode(u: MiUser, m: IMentionedRemoteUsers): Entity.Mention { + private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention { let acct = u.username; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let acctUrl = `https://${u.host || this.config.host}/@${u.username}`; let url: string | null = null; if (u.host) { @@ -89,7 +101,11 @@ export class MastoConverters { return 'unknown'; } - public encodeFile(f: any): Entity.Attachment { + public encodeFile(f: Packed<'DriveFile'>): MastodonEntity.Attachment { + const { width, height } = f.properties; + const size = (width && height) ? `${width}x${height}` : undefined; + const aspect = (width && height) ? (width / height) : undefined; + return { id: f.id, type: this.fileType(f.type), @@ -98,11 +114,19 @@ export class MastoConverters { preview_url: f.thumbnailUrl, text_url: f.url, meta: { - width: f.properties.width, - height: f.properties.height, + original: { + width, + height, + size, + aspect, + }, + width, + height, + size, + aspect, }, - description: f.comment ? f.comment : null, - blurhash: f.blurhash ? f.blurhash : null, + description: f.comment ?? null, + blurhash: f.blurhash ?? null, }; } @@ -112,7 +136,7 @@ export class MastoConverters { }); } - private async encodeField(f: Entity.Field): Promise<Entity.Field> { + private async encodeField(f: Entity.Field): Promise<MastodonEntity.Field> { return { name: f.name, value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), @@ -120,7 +144,7 @@ export class MastoConverters { }; } - public async convertAccount(account: Entity.Account | MiUser) { + public async convertAccount(account: Entity.Account | MiUser): Promise<MastodonEntity.Account> { const user = await this.getUser(account.id); const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host); @@ -137,6 +161,7 @@ export class MastoConverters { }); const fqn = `${user.username}@${user.host ?? this.config.hostname}`; let acct = user.username; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let acctUrl = `https://${user.host || this.config.host}/@${user.username}`; const acctUri = `https://${this.config.host}/users/${user.id}`; if (user.host) { @@ -166,19 +191,23 @@ export class MastoConverters { fields: Promise.all(profile?.fields.map(async p => this.encodeField(p)) ?? []), bot: user.isBot, discoverable: user.isExplorable, + noindex: user.noindex, + group: null, + suspended: user.isSuspended, + limited: user.isSilenced, }); } - public async getEdits(id: string) { - const note = await this.getterService.getNote(id); + public async getEdits(id: string, me?: MiLocalUser | null) { + const note = await this.mastodonDataService.getNote(id, me); if (!note) { - return {}; + return []; } - const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p)); const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } }); - const history: Promise<any>[] = []; + const history: Promise<StatusEdit>[] = []; + // TODO this looks wrong, according to mastodon docs let lastDate = this.idService.parse(note.id).date; for (const edit of edits) { const files = this.driveFileEntityService.packManyByIds(edit.fileIds); @@ -187,9 +216,8 @@ export class MastoConverters { content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), created_at: lastDate.toISOString(), emojis: [], - sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false), + sensitive: edit.cw != null && edit.cw.length > 0, spoiler_text: edit.cw ?? '', - poll: null, media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []), }; lastDate = edit.updatedAt; @@ -199,15 +227,16 @@ export class MastoConverters { return await Promise.all(history); } - private async convertReblog(status: Entity.Status | null): Promise<any> { + private async convertReblog(status: Entity.Status | null, me?: MiLocalUser | null): Promise<MastodonEntity.Status | null> { if (!status) return null; - return await this.convertStatus(status); + return await this.convertStatus(status, me); } - public async convertStatus(status: Entity.Status) { + public async convertStatus(status: Entity.Status, me?: MiLocalUser | null): Promise<MastodonEntity.Status> { const convertedAccount = this.convertAccount(status.account); - const note = await this.getterService.getNote(status.id); + const note = await this.mastodonDataService.requireNote(status.id, me); const noteUser = await this.getUser(status.account.id); + const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers); const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host); const emoji: Entity.Emoji[] = []; @@ -224,7 +253,7 @@ export class MastoConverters { const mentions = Promise.all(note.mentions.map(p => this.getUser(p) - .then(u => this.encode(u, JSON.parse(note.mentionedRemoteUsers))) + .then(u => this.encode(u, mentionedRemoteUsers)) .catch(() => null))) .then(p => p.filter(m => m)) as Promise<Entity.Mention[]>; @@ -235,20 +264,26 @@ export class MastoConverters { } as Entity.Tag; }); - const isQuote = note.renoteId && note.text ? true : false; + // This must mirror the usual isQuote / isPureRenote logic used elsewhere. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId); - const renote = note.renoteId ? this.getterService.getNote(note.renoteId) : null; + const renote: Promise<MiNote> | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null; const quoteUri = Promise.resolve(renote).then(renote => { if (!renote || !isQuote) return null; return renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`; }); - const content = note.text !== null - ? quoteUri.then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri)) - .then(p => p ?? escapeMFM(note.text!)) + const text = note.text; + const content = text !== null + ? quoteUri + .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri)) + .then(p => p ?? escapeMFM(text)) : ''; + const reblogged = await this.mastodonDataService.hasReblog(note.id, me); + // noinspection ES6MissingAwait return await awaitAll({ id: note.id, @@ -257,7 +292,7 @@ export class MastoConverters { account: convertedAccount, in_reply_to_id: note.replyId, in_reply_to_account_id: note.replyUserId, - reblog: !isQuote ? await this.convertReblog(status.reblog) : null, + reblog: !isQuote ? await this.convertReblog(status.reblog, me) : null, content: content, content_type: 'text/x.misskeymarkdown', text: note.text, @@ -266,34 +301,51 @@ export class MastoConverters { replies_count: note.repliesCount, reblogs_count: note.renoteCount, favourites_count: status.favourites_count, - reblogged: false, + reblogged, favourited: status.favourited, muted: status.muted, sensitive: status.sensitive, - spoiler_text: note.cw ? note.cw : '', + spoiler_text: note.cw ?? '', visibility: status.visibility, - media_attachments: status.media_attachments, + media_attachments: status.media_attachments.map(a => convertAttachment(a)), mentions: mentions, tags: tags, card: null, //FIXME poll: status.poll ?? null, application: null, //FIXME language: null, //FIXME - pinned: false, + pinned: false, //FIXME reactions: status.emoji_reactions, emoji_reactions: status.emoji_reactions, - bookmarked: false, - quote: isQuote ? await this.convertReblog(status.reblog) : null, - // optional chaining cannot be used, as it evaluates to undefined, not null - edited_at: note.updatedAt ? note.updatedAt.toISOString() : null, + bookmarked: false, //FIXME + quote: isQuote ? await this.convertReblog(status.reblog, me) : null, + edited_at: note.updatedAt?.toISOString() ?? null, }); } + + public async convertConversation(conversation: Entity.Conversation, me?: MiLocalUser | null): Promise<MastodonEntity.Conversation> { + return { + id: conversation.id, + accounts: await Promise.all(conversation.accounts.map(a => this.convertAccount(a))), + last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null, + unread: conversation.unread, + }; + } + + public async convertNotification(notification: Entity.Notification, me?: MiLocalUser | null): Promise<MastodonEntity.Notification> { + return { + account: await this.convertAccount(notification.account), + created_at: notification.created_at, + id: notification.id, + status: notification.status ? await this.convertStatus(notification.status, me) : undefined, + type: notification.type, + }; + } } -function simpleConvert(data: any) { +function simpleConvert<T>(data: T): T { // copy the object to bypass weird pass by reference bugs - const result = Object.assign({}, data); - return result; + return Object.assign({}, data); } export function convertAccount(account: Entity.Account) { @@ -302,8 +354,30 @@ export function convertAccount(account: Entity.Account) { export function convertAnnouncement(announcement: Entity.Announcement) { return simpleConvert(announcement); } -export function convertAttachment(attachment: Entity.Attachment) { - return simpleConvert(attachment); +export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment { + const { width, height } = attachment.meta?.original ?? attachment.meta ?? {}; + const size = (width && height) ? `${width}x${height}` : undefined; + const aspect = (width && height) ? (width / height) : undefined; + return { + ...attachment, + meta: attachment.meta ? { + ...attachment.meta, + original: { + ...attachment.meta.original, + width, + height, + size, + aspect, + frame_rate: String(attachment.meta.fps), + duration: attachment.meta.duration, + bitrate: attachment.meta.audio_bitrate ? parseInt(attachment.meta.audio_bitrate) : undefined, + }, + width, + height, + size, + aspect, + } : null, + }; } export function convertFilter(filter: Entity.Filter) { return simpleConvert(filter); @@ -315,45 +389,40 @@ export function convertFeaturedTag(tag: Entity.FeaturedTag) { return simpleConvert(tag); } -export function convertNotification(notification: Entity.Notification) { - notification.account = convertAccount(notification.account); - if (notification.status) notification.status = convertStatus(notification.status); - return notification; -} - export function convertPoll(poll: Entity.Poll) { return simpleConvert(poll); } + +// noinspection JSUnusedGlobalSymbols export function convertReaction(reaction: Entity.Reaction) { if (reaction.accounts) { reaction.accounts = reaction.accounts.map(convertAccount); } return reaction; } -export function convertRelationship(relationship: Entity.Relationship) { - return simpleConvert(relationship); -} -export function convertStatus(status: Entity.Status) { - status.account = convertAccount(status.account); - status.media_attachments = status.media_attachments.map((attachment) => - convertAttachment(attachment), - ); - if (status.poll) status.poll = convertPoll(status.poll); - if (status.reblog) status.reblog = convertStatus(status.reblog); - - return status; +// Megalodon sometimes returns broken / stubbed relationship data +export function convertRelationship(relationship: Partial<Entity.Relationship> & { id: string }): MastodonEntity.Relationship { + return { + id: relationship.id, + following: relationship.following ?? false, + showing_reblogs: relationship.showing_reblogs ?? true, + notifying: relationship.notifying ?? true, + languages: [], + followed_by: relationship.followed_by ?? false, + blocking: relationship.blocking ?? false, + blocked_by: relationship.blocked_by ?? false, + muting: relationship.muting ?? false, + muting_notifications: relationship.muting_notifications ?? false, + requested: relationship.requested ?? false, + requested_by: relationship.requested_by ?? false, + domain_blocking: relationship.domain_blocking ?? false, + endorsed: relationship.endorsed ?? false, + note: relationship.note ?? '', + }; } +// noinspection JSUnusedGlobalSymbols export function convertStatusSource(status: Entity.StatusSource) { return simpleConvert(status); } - -export function convertConversation(conversation: Entity.Conversation) { - conversation.accounts = conversation.accounts.map(convertAccount); - if (conversation.last_status) { - conversation.last_status = convertStatus(conversation.last_status); - } - - return conversation; -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 6fcfb0019c..79cdddcb9e 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -3,273 +3,149 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { Injectable } from '@nestjs/common'; +import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; +import { MiLocalUser } from '@/models/User.js'; import { MastoConverters, convertRelationship } from '../converters.js'; -import { argsToBools, limitToInt } from './timeline.js'; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { Config } from '@/config.js'; -import { Injectable } from '@nestjs/common'; -const relationshipModel = { - id: '', - following: false, - followed_by: false, - delivery_following: false, - blocking: false, - blocked_by: false, - muting: false, - muting_notifications: false, - requested: false, - domain_blocking: false, - showing_reblogs: false, - endorsed: false, - notifying: false, - note: '', -}; +export interface ApiAccountMastodonRoute { + Params: { id?: string }, + Querystring: TimelineArgs & { acct?: string }, + Body: { notifications?: boolean } +} @Injectable() export class ApiAccountMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - private BASE_URL: string; - - constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoconverter: MastoConverters) { - this.request = request; - this.client = client; - this.BASE_URL = BASE_URL; - } + constructor( + private readonly request: FastifyRequest<ApiAccountMastodonRoute>, + private readonly client: MegalodonInterface, + private readonly me: MiLocalUser | null, + private readonly mastoConverters: MastoConverters, + ) {} public async verifyCredentials() { - try { - const data = await this.client.verifyAccountCredentials(); - const acct = await this.mastoconverter.convertAccount(data.data); - const newAcct = Object.assign({}, acct, { - source: { - note: acct.note, - fields: acct.fields, - privacy: '', - sensitive: false, - language: '', - }, - }); - return newAcct; - } catch (e: any) { - /* console.error(e); - console.error(e.response.data); */ - return e.response; - } + const data = await this.client.verifyAccountCredentials(); + const acct = await this.mastoConverters.convertAccount(data.data); + return Object.assign({}, acct, { + source: { + note: acct.note, + fields: acct.fields, + privacy: '', + sensitive: false, + language: '', + }, + }); } public async lookup() { - try { - const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' }); - return this.mastoconverter.convertAccount(data.data.accounts[0]); - } catch (e: any) { - /* console.error(e) - console.error(e.response.data); */ - return e.response; - } + if (!this.request.query.acct) throw new Error('Missing required property "acct"'); + const data = await this.client.search(this.request.query.acct, { type: 'accounts' }); + return this.mastoConverters.convertAccount(data.data.accounts[0]); } - public async getRelationships(users: [string]) { - try { - relationshipModel.id = users.toString() || '1'; - - if (!(users.length > 0)) { - return [relationshipModel]; - } - - const reqIds = []; - for (let i = 0; i < users.length; i++) { - reqIds.push(users[i]); - } - - const data = await this.client.getRelationships(reqIds); - return data.data.map((relationship) => convertRelationship(relationship)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + public async getRelationships(reqIds: string[]) { + const data = await this.client.getRelationships(reqIds); + return data.data.map(relationship => convertRelationship(relationship)); } public async getStatuses() { - try { - const data = await this.client.getAccountStatuses((this.request.params as any).id, argsToBools(limitToInt(this.request.query as any))); - return await Promise.all(data.data.map(async (status) => await this.mastoconverter.convertStatus(status))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.getAccountStatuses(this.request.params.id, parseTimelineArgs(this.request.query)); + return await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, this.me))); } public async getFollowers() { - try { - const data = await this.client.getAccountFollowers( - (this.request.params as any).id, - limitToInt(this.request.query as any), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.getAccountFollowers( + this.request.params.id, + parseTimelineArgs(this.request.query), + ); + return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); } public async getFollowing() { - try { - const data = await this.client.getAccountFollowing( - (this.request.params as any).id, - limitToInt(this.request.query as any), - ); - return await Promise.all(data.data.map(async (account) => await this.mastoconverter.convertAccount(account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.getAccountFollowing( + this.request.params.id, + parseTimelineArgs(this.request.query), + ); + return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account))); } public async addFollow() { - try { - const data = await this.client.followAccount( (this.request.params as any).id ); - const acct = convertRelationship(data.data); - acct.following = true; - return acct; - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.followAccount(this.request.params.id); + const acct = convertRelationship(data.data); + acct.following = true; + return acct; } public async rmFollow() { - try { - const data = await this.client.unfollowAccount( (this.request.params as any).id ); - const acct = convertRelationship(data.data); - acct.following = false; - return acct; - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.unfollowAccount(this.request.params.id); + const acct = convertRelationship(data.data); + acct.following = false; + return acct; } public async addBlock() { - try { - const data = await this.client.blockAccount( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.blockAccount(this.request.params.id); + return convertRelationship(data.data); } public async rmBlock() { - try { - const data = await this.client.unblockAccount( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.unblockAccount(this.request.params.id); + return convertRelationship(data.data); } public async addMute() { - try { - const data = await this.client.muteAccount( - (this.request.params as any).id, - this.request.body as any, - ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.muteAccount( + this.request.params.id, + this.request.body.notifications ?? true, + ); + return convertRelationship(data.data); } public async rmMute() { - try { - const data = await this.client.unmuteAccount( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.unmuteAccount(this.request.params.id); + return convertRelationship(data.data); } public async getBookmarks() { - try { - const data = await this.client.getBookmarks( limitToInt(this.request.query as any) ); - return data.data.map((status) => this.mastoconverter.convertStatus(status)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query)); + return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); } public async getFavourites() { - try { - const data = await this.client.getFavourites( limitToInt(this.request.query as any) ); - return data.data.map((status) => this.mastoconverter.convertStatus(status)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + const data = await this.client.getFavourites(parseTimelineArgs(this.request.query)); + return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me))); } public async getMutes() { - try { - const data = await this.client.getMutes( limitToInt(this.request.query as any) ); - return data.data.map((account) => this.mastoconverter.convertAccount(account)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + const data = await this.client.getMutes(parseTimelineArgs(this.request.query)); + return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); } public async getBlocks() { - try { - const data = await this.client.getBlocks( limitToInt(this.request.query as any) ); - return data.data.map((account) => this.mastoconverter.convertAccount(account)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + const data = await this.client.getBlocks(parseTimelineArgs(this.request.query)); + return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account))); } public async acceptFollow() { - try { - const data = await this.client.acceptFollowRequest( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.acceptFollowRequest(this.request.params.id); + return convertRelationship(data.data); } public async rejectFollow() { - try { - const data = await this.client.rejectFollowRequest( (this.request.params as any).id ); - return convertRelationship(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.rejectFollowRequest(this.request.params.id); + return convertRelationship(data.data); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index a447bdb1b7..b58cc902da 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -44,36 +44,54 @@ const writeScope = [ 'write:gallery-likes', ]; -export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) { - const body: any = request.body || request.query; - try { - let scope = body.scopes; - if (typeof scope === 'string') scope = scope.split(' ') || scope.split('+'); - const pushScope = new Set<string>(); - for (const s of scope) { - if (s.match(/^read/)) for (const r of readScope) pushScope.add(r); - if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r); - } - const scopeArr = Array.from(pushScope); +export interface AuthPayload { + scopes?: string | string[], + redirect_uris?: string, + client_name?: string, + website?: string, +} + +// Not entirely right, but it gets TypeScript to work so *shrug* +export type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload }; - const red = body.redirect_uris; - const appData = await client.registerApp(body.client_name, { - scopes: scopeArr, - redirect_uris: red, - website: body.website, - }); - const returns = { - id: Math.floor(Math.random() * 100).toString(), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url || '').toString('base64'), - client_secret: appData.clientSecret, - }; +export async function ApiAuthMastodon(request: FastifyRequest<AuthMastodonRoute>, client: MegalodonInterface) { + const body = request.body ?? request.query; + if (!body.scopes) throw new Error('Missing required payload "scopes"'); + if (!body.redirect_uris) throw new Error('Missing required payload "redirect_uris"'); + if (!body.client_name) throw new Error('Missing required payload "client_name"'); - return returns; - } catch (e: any) { - console.error(e); - return e.response.data; + let scope = body.scopes; + if (typeof scope === 'string') { + scope = scope.split(/[ +]/g); } + + const pushScope = new Set<string>(); + for (const s of scope) { + if (s.match(/^read/)) { + for (const r of readScope) { + pushScope.add(r); + } + } + if (s.match(/^write/)) { + for (const r of writeScope) { + pushScope.add(r); + } + } + } + + const red = body.redirect_uris; + const appData = await client.registerApp(body.client_name, { + scopes: Array.from(pushScope), + redirect_uris: red, + website: body.website, + }); + + return { + id: Math.floor(Math.random() * 100).toString(), + name: appData.name, + website: body.website, + redirect_uri: red, + client_id: Buffer.from(appData.url || '').toString('base64'), // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing + client_secret: appData.clientSecret, + }; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index ce6809d230..382f0a8f1f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -3,68 +3,73 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { toBoolean } from '@/server/api/mastodon/timelineArgs.js'; import { convertFilter } from '../converters.js'; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -export class ApiFilterMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - - constructor(request: FastifyRequest, client: MegalodonInterface) { - this.request = request; - this.client = client; +export interface ApiFilterMastodonRoute { + Params: { + id?: string, + }, + Body: { + phrase?: string, + context?: string[], + irreversible?: string, + whole_word?: string, + expires_in?: string, } +} + +export class ApiFilterMastodon { + constructor( + private readonly request: FastifyRequest<ApiFilterMastodonRoute>, + private readonly client: MegalodonInterface, + ) {} public async getFilters() { - try { - const data = await this.client.getFilters(); - return data.data.map((filter) => convertFilter(filter)); - } catch (e: any) { - console.error(e); - return e.response.data; - } + const data = await this.client.getFilters(); + return data.data.map((filter) => convertFilter(filter)); } public async getFilter() { - try { - const data = await this.client.getFilter( (this.request.params as any).id ); - return convertFilter(data.data); - } catch (e: any) { - console.error(e); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.getFilter(this.request.params.id); + return convertFilter(data.data); } public async createFilter() { - try { - const body: any = this.request.body; - const data = await this.client.createFilter(body.pharse, body.context, body); - return convertFilter(data.data); - } catch (e: any) { - console.error(e); - return e.response.data; - } + if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); + if (!this.request.body.context) throw new Error('Missing required payload "context"'); + const options = { + phrase: this.request.body.phrase, + context: this.request.body.context, + irreversible: toBoolean(this.request.body.irreversible), + whole_word: toBoolean(this.request.body.whole_word), + expires_in: this.request.body.expires_in, + }; + const data = await this.client.createFilter(this.request.body.phrase, this.request.body.context, options); + return convertFilter(data.data); } public async updateFilter() { - try { - const body: any = this.request.body; - const data = await this.client.updateFilter((this.request.params as any).id, body.pharse, body.context); - return convertFilter(data.data); - } catch (e: any) { - console.error(e); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"'); + if (!this.request.body.context) throw new Error('Missing required payload "context"'); + const options = { + phrase: this.request.body.phrase, + context: this.request.body.context, + irreversible: toBoolean(this.request.body.irreversible), + whole_word: toBoolean(this.request.body.whole_word), + expires_in: this.request.body.expires_in, + }; + const data = await this.client.updateFilter(this.request.params.id, this.request.body.phrase, this.request.body.context, options); + return convertFilter(data.data); } public async rmFilter() { - try { - const data = await this.client.deleteFilter( (this.request.params as any).id ); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.deleteFilter(this.request.params.id); + return data.data; } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts index c9833b85d7..48a56138cf 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -8,6 +8,7 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import type { Config } from '@/config.js'; import type { MiMeta } from '@/models/Meta.js'; +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ export async function getInstance( response: Entity.Instance, contact: Entity.Account, @@ -17,11 +18,8 @@ export async function getInstance( return { uri: config.url, title: meta.name || 'Sharkey', - short_description: - meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', - description: - meta.description || - 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + short_description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', + description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.', email: response.email || '', version: `3.0.0 (compatible; Sharkey ${config.version})`, urls: response.urls, diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 0eefb5894c..14eee8565a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -3,73 +3,56 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { convertNotification } from '../converters.js'; -import type { MegalodonInterface, Entity } from 'megalodon'; +import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MastoConverters } from '@/server/api/mastodon/converters.js'; +import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -function toLimitToInt(q: any) { - if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10); - return q; +export interface ApiNotifyMastodonRoute { + Params: { + id?: string, + }, + Querystring: TimelineArgs, } export class ApiNotifyMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - - constructor(request: FastifyRequest, client: MegalodonInterface) { - this.request = request; - this.client = client; - } + constructor( + private readonly request: FastifyRequest<ApiNotifyMastodonRoute>, + private readonly client: MegalodonInterface, + private readonly me: MiLocalUser | null, + private readonly mastoConverters: MastoConverters, + ) {} public async getNotifications() { - try { - const data = await this.client.getNotifications( toLimitToInt(this.request.query) ); - const notifs = data.data; - const processed = notifs.map((n: Entity.Notification) => { - const convertedn = convertNotification(n); - if (convertedn.type !== 'follow' && convertedn.type !== 'follow_request') { - if (convertedn.type === 'reaction') convertedn.type = 'favourite'; - return convertedn; - } else { - return convertedn; - } - }); - return processed; - } catch (e: any) { - console.error(e); - return e.response.data; - } + const data = await this.client.getNotifications(parseTimelineArgs(this.request.query)); + return Promise.all(data.data.map(async n => { + const converted = await this.mastoConverters.convertNotification(n, this.me); + if (converted.type === 'reaction') { + converted.type = 'favourite'; + } + return converted; + })); } public async getNotification() { - try { - const data = await this.client.getNotification( (this.request.params as any).id ); - const notif = convertNotification(data.data); - if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite'; - return notif; - } catch (e: any) { - console.error(e); - return e.response.data; + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.getNotification(this.request.params.id); + const converted = await this.mastoConverters.convertNotification(data.data, this.me); + if (converted.type === 'reaction') { + converted.type = 'favourite'; } + return converted; } public async rmNotification() { - try { - const data = await this.client.dismissNotification( (this.request.params as any).id ); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } + if (!this.request.params.id) throw new Error('Missing required parameter "id"'); + const data = await this.client.dismissNotification(this.request.params.id); + return data.data; } public async rmNotifications() { - try { - const data = await this.client.dismissNotifications(); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } + const data = await this.client.dismissNotifications(); + return 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 946e796e2a..4850b4652f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -3,88 +3,92 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MiLocalUser } from '@/models/User.js'; import { MastoConverters } from '../converters.js'; -import { limitToInt } from './timeline.js'; +import { parseTimelineArgs, TimelineArgs } from '../timelineArgs.js'; +import Account = Entity.Account; +import Status = Entity.Status; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; -export class ApiSearchMastodon { - private request: FastifyRequest; - private client: MegalodonInterface; - private BASE_URL: string; - - constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string, private mastoConverter: MastoConverters) { - this.request = request; - this.client = client; - this.BASE_URL = BASE_URL; +export interface ApiSearchMastodonRoute { + Querystring: TimelineArgs & { + type?: 'accounts' | 'hashtags' | 'statuses'; + q?: string; } +} + +export class ApiSearchMastodon { + constructor( + private readonly request: FastifyRequest<ApiSearchMastodonRoute>, + private readonly client: MegalodonInterface, + private readonly me: MiLocalUser | null, + private readonly BASE_URL: string, + private readonly mastoConverters: MastoConverters, + ) {} public async SearchV1() { - try { - const query: any = limitToInt(this.request.query as any); - const type = query.type || ''; - const data = await this.client.search(query.q, { type: type, ...query }); - return data.data; - } catch (e: any) { - console.error(e); - return e.response.data; - } + if (!this.request.query.q) throw new Error('Missing required property "q"'); + const query = parseTimelineArgs(this.request.query); + const data = await this.client.search(this.request.query.q, { type: this.request.query.type, ...query }); + return data.data; } public async SearchV2() { - try { - const query: any = limitToInt(this.request.query as any); - const type = query.type; - const acct = !type || type === 'accounts' ? await this.client.search(query.q, { type: 'accounts', ...query }) : null; - const stat = !type || type === 'statuses' ? await this.client.search(query.q, { type: 'statuses', ...query }) : null; - const tags = !type || type === 'hashtags' ? await this.client.search(query.q, { type: 'hashtags', ...query }) : null; - const data = { - accounts: await Promise.all(acct?.data.accounts.map(async (account: any) => await this.mastoConverter.convertAccount(account)) ?? []), - statuses: await Promise.all(stat?.data.statuses.map(async (status: any) => await this.mastoConverter.convertStatus(status)) ?? []), - hashtags: tags?.data.hashtags ?? [], - }; - return data; - } catch (e: any) { - console.error(e); - return e.response.data; - } + if (!this.request.query.q) throw new Error('Missing required property "q"'); + const query = parseTimelineArgs(this.request.query); + const type = this.request.query.type; + const acct = !type || type === 'accounts' ? await this.client.search(this.request.query.q, { type: 'accounts', ...query }) : null; + const stat = !type || type === 'statuses' ? await this.client.search(this.request.query.q, { type: 'statuses', ...query }) : null; + const tags = !type || type === 'hashtags' ? await this.client.search(this.request.query.q, { type: 'hashtags', ...query }) : null; + return { + accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []), + statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, this.me)) ?? []), + hashtags: tags?.data.hashtags ?? [], + }; } public async getStatusTrends() { - try { - const data = await fetch(`${this.BASE_URL}/api/notes/featured`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }) - .then(res => res.json()) - .then(data => data.map((status: any) => this.mastoConverter.convertStatus(status))); - return data; - } catch (e: any) { - console.error(e); - return []; - } + const data = await fetch(`${this.BASE_URL}/api/notes/featured`, + { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + i: this.request.headers.authorization?.replace('Bearer ', ''), + }), + }) + .then(res => res.json() as Promise<Status[]>) + .then(data => data.map(status => this.mastoConverters.convertStatus(status, this.me))); + return Promise.all(data); } public async getSuggestions() { - try { - const data = await fetch(`${this.BASE_URL}/api/users`, - { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ i: this.request.headers.authorization?.replace('Bearer ', ''), limit: parseInt((this.request.query as any).limit) || 20, origin: 'local', sort: '+follower', state: 'alive' }), - }).then((res) => res.json()).then(data => data.map(((entry: any) => { return { source: 'global', account: entry }; }))); - return Promise.all(data.map(async (suggestion: any) => { suggestion.account = await this.mastoConverter.convertAccount(suggestion.account); return suggestion; })); - } catch (e: any) { - console.error(e); - return []; - } + const data = await fetch(`${this.BASE_URL}/api/users`, + { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + i: this.request.headers.authorization?.replace('Bearer ', ''), + limit: parseTimelineArgs(this.request.query).limit ?? 20, + origin: 'local', + sort: '+follower', + state: 'alive', + }), + }) + .then(res => res.json() as Promise<Account[]>) + .then(data => data.map((entry => ({ + source: 'global', + account: entry, + })))); + return Promise.all(data.map(async suggestion => { + suggestion.account = await this.mastoConverters.convertAccount(suggestion.account); + return suggestion; + })); } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index ddc99639fa..4c49a6a293 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -3,181 +3,212 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import querystring from 'querystring'; +import querystring, { ParsedUrlQueryInput } from 'querystring'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; -import { convertAttachment, convertPoll, convertStatusSource, MastoConverters } from '../converters.js'; -import { getClient } from '../MastodonApiServerService.js'; -import { limitToInt } from './timeline.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/timelineArgs.js'; +import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import { convertAttachment, convertPoll, MastoConverters } from '../converters.js'; +import { getAccessToken, getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; -import type { Config } from '@/config.js'; -import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -function normalizeQuery(data: any) { - const str = querystring.stringify(data); +function normalizeQuery(data: Record<string, unknown>) { + const str = querystring.stringify(data as ParsedUrlQueryInput); return querystring.parse(str); } export class ApiStatusMastodon { - private fastify: FastifyInstance; - private mastoconverter: MastoConverters; + constructor( + private readonly fastify: FastifyInstance, + private readonly mastoConverters: MastoConverters, + private readonly logger: MastodonLogger, + private readonly authenticateService: AuthenticateService, + private readonly mastodon: MastodonApiServerService, + ) {} - constructor(fastify: FastifyInstance, mastoconverter: MastoConverters) { - this.fastify = fastify; - this.mastoconverter = mastoconverter; - } - - public async getStatus() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public getStatus() { + this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { try { + const { client, me } = await this.mastodon.getAuthClient(_request); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/statuses/${_request.params.id}`, data); + reply.code(_request.is404 ? 404 : 401).send(data); } }); } - public async getStatusSource() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/source', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + public getStatusSource() { + this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getStatusSource(_request.params.id); reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/statuses/${_request.params.id}/source`, data); + reply.code(_request.is404 ? 404 : 401).send(data); } }); } - public async getContext() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const query: any = _request.query; + public getContext() { + this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => { try { - const data = await client.getStatusContext(_request.params.id, limitToInt(query)); - data.data.ancestors = await Promise.all(data.data.ancestors.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))); - data.data.descendants = await Promise.all(data.data.descendants.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status))); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); + const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query)); + const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me))); + const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me))); + reply.send({ ancestors, descendants }); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/statuses/${_request.params.id}/context`, data); + reply.code(_request.is404 ? 404 : 401).send(data); } }); } - public async getHistory() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/history', async (_request, reply) => { + public getHistory() { + this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => { try { - const edits = await this.mastoconverter.getEdits(_request.params.id); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const [user] = await this.authenticateService.authenticate(getAccessToken(_request.headers.authorization)); + const edits = await this.mastoConverters.getEdits(_request.params.id, user); reply.send(edits); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/statuses/${_request.params.id}/history`, data); + reply.code(401).send(data); } }); } - public async getReblogged() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + public getReblogged() { + this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getStatusRebloggedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoconverter.convertAccount(account)))); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/statuses/${_request.params.id}/reblogged_by`, data); + reply.code(401).send(data); } }); } - public async getFavourites() { - this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + public getFavourites() { + this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getStatusFavouritedBy(_request.params.id); - reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoconverter.convertAccount(account)))); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account)))); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/statuses/${_request.params.id}/favourited_by`, data); + reply.code(401).send(data); } }); } - public async getMedia() { - this.fastify.get<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + public getMedia() { + this.fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getMedia(_request.params.id); reply.send(convertAttachment(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/media/${_request.params.id}`, data); + reply.code(401).send(data); } }); } - public async getPoll() { - this.fastify.get<{ Params: { id: string } }>('/v1/polls/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + public getPoll() { + this.fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.getPoll(_request.params.id); reply.send(convertPoll(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/polls/${_request.params.id}`, data); + reply.code(401).send(data); } }); } - public async votePoll() { - this.fastify.post<{ Params: { id: string } }>('/v1/polls/:id/votes', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + public votePoll() { + this.fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; try { - const data = await client.votePoll(_request.params.id, body.choices); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' }); + const data = await client.votePoll(_request.params.id, _request.body.choices); reply.send(convertPoll(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data); + reply.code(401).send(data); } }); } - public async postStatus() { - this.fastify.post('/v1/statuses', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - let body: any = _request.body; + public postStatus() { + this.fastify.post<{ + Body: { + media_ids?: string[], + poll?: { + options?: string[], + expires_in?: string, + multiple?: string, + hide_totals?: string, + }, + in_reply_to_id?: string, + sensitive?: string, + spoiler_text?: string, + visibility?: 'public' | 'unlisted' | 'private' | 'direct', + scheduled_at?: string, + language?: string, + quote_id?: string, + status?: string, + + // Broken clients + 'poll[options][]'?: string[], + 'media_ids[]'?: string[], + } + }>('/v1/statuses', async (_request, reply) => { + let body = _request.body; try { - if ( - (!body.poll && body['poll[options][]']) || - (!body.media_ids && body['media_ids[]']) + const { client, me } = await this.mastodon.getAuthClient(_request); + if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]']) ) { body = normalizeQuery(body); } - const text = body.status ? body.status : ' '; + const text = body.status ??= ' '; const removed = text.replace(/@\S+/g, '').replace(/\s|/g, ''); const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); @@ -189,226 +220,253 @@ export class ApiStatusMastodon { reply.send(a.data); } if (body.in_reply_to_id && removed === '/unreact') { - try { - const id = body.in_reply_to_id; - const post = await client.getStatus(id); - const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name; - const data = await client.deleteEmojiReaction(id, react); - reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); - } + const id = body.in_reply_to_id; + const post = await client.getStatus(id); + const react = post.data.emoji_reactions.filter(e => e.me)[0].name; + const data = await client.deleteEmojiReaction(id, react); + reply.send(data.data); } if (!body.media_ids) body.media_ids = undefined; if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - const { sensitive } = body; - body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive; - - if (body.poll) { - if ( - body.poll.expires_in != null && - typeof body.poll.expires_in === 'string' - ) body.poll.expires_in = parseInt(body.poll.expires_in); - if ( - body.poll.multiple != null && - typeof body.poll.multiple === 'string' - ) body.poll.multiple = body.poll.multiple === 'true'; - if ( - body.poll.hide_totals != null && - typeof body.poll.hide_totals === 'string' - ) body.poll.hide_totals = body.poll.hide_totals === 'true'; + if (body.poll && !body.poll.options) { + return reply.code(400).send({ error: 'Missing required payload "poll.options"' }); + } + if (body.poll && !body.poll.expires_in) { + return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' }); } - const data = await client.postStatus(text, body); - reply.send(await this.mastoconverter.convertStatus(data.data as Entity.Status)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.postStatus(text, options); + reply.send(await this.mastoConverters.convertStatus(data.data as Entity.Status, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/statuses', data); + reply.code(401).send(data); } }); } - public async updateStatus() { - this.fastify.put<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; + public updateStatus() { + this.fastify.put<{ + Params: { id: string }, + Body: { + status?: string, + spoiler_text?: string, + sensitive?: string, + media_ids?: string[], + poll?: { + options?: string[], + expires_in?: string, + multiple?: string, + hide_totals?: string, + }, + } + }>('/v1/statuses/:id', async (_request, reply) => { try { - if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; - const data = await client.editStatus(_request.params.id, body); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(_request.is404 ? 404 : 401).send(e.response.data); + const { client, me } = await this.mastodon.getAuthClient(_request); + const body = _request.body; + + if (!body.media_ids || !body.media_ids.length) { + body.media_ids = undefined; + } + + const options = { + ...body, + sensitive: toBoolean(body.sensitive), + poll: body.poll ? { + options: body.poll.options, + expires_in: toInt(body.poll.expires_in), + multiple: toBoolean(body.poll.multiple), + hide_totals: toBoolean(body.poll.hide_totals), + } : undefined, + }; + + const data = await client.editStatus(_request.params.id, options); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}`, data); + reply.code(401).send(data); } }); } - public async addFavourite() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public addFavourite() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => { try { - const data = (await client.createEmojiReaction(_request.params.id, '❤')) as any; - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); + const data = await client.createEmojiReaction(_request.params.id, '❤'); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/favorite`, data); + reply.code(401).send(data); } }); } - public async rmFavourite() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public rmFavourite() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => { try { + const { client, me } = await this.mastodon.getAuthClient(_request); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.deleteEmojiReaction(_request.params.id, '❤'); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data); + reply.code(401).send(data); } }); } - public async reblogStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public reblogStatus() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.reblogStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data); + reply.code(401).send(data); } }); } - public async unreblogStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public unreblogStatus() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.unreblogStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data); + reply.code(401).send(data); } }); } - public async bookmarkStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public bookmarkStatus() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.bookmarkStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data); + reply.code(401).send(data); } }); } - public async unbookmarkStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public unbookmarkStatus() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.unbookmarkStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data); + reply.code(401).send(data); } }); } - public async pinStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public pinStatus() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.pinStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data); + reply.code(401).send(data); } }); } - public async unpinStatus() { - this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public unpinStatus() { + this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.unpinStatus(_request.params.id); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data); + reply.code(401).send(data); } }); } - public async reactStatus() { - this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public reactStatus() { + this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.createEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data); + reply.code(401).send(data); } }); } - public async unreactStatus() { - this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public unreactStatus() { + this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => { try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name); - reply.send(await this.mastoconverter.convertStatus(data.data)); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + reply.send(await this.mastoConverters.convertStatus(data.data, me)); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data); + reply.code(401).send(data); } }); } - public async deleteStatus() { - this.fastify.delete<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + public deleteStatus() { + this.fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => { + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); const data = await client.deleteStatus(_request.params.id); reply.send(data.data); - } catch (e: any) { - console.error(e); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`DELETE /v1/statuses/${_request.params.id}`, data); + reply.code(401).send(data); } }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 3eb4898713..1a732d62de 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -3,270 +3,231 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ParsedUrlQuery } from 'querystring'; -import { convertConversation, convertList, MastoConverters } from '../converters.js'; -import { getClient } from '../MastodonApiServerService.js'; +import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js'; +import { convertList, MastoConverters } from '../converters.js'; +import { getClient, MastodonApiServerService } from '../MastodonApiServerService.js'; +import { parseTimelineArgs, TimelineArgs, toBoolean } from '../timelineArgs.js'; import type { Entity } from 'megalodon'; import type { FastifyInstance } from 'fastify'; -import type { Config } from '@/config.js'; -import { NoteEditRepository, NotesRepository, UsersRepository } from '@/models/_.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - -export function limitToInt(q: ParsedUrlQuery) { - const object: any = q; - if (q.limit) if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10); - if (q.offset) if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10); - return object; -} - -export function argsToBools(q: ParsedUrlQuery) { - // Values taken from https://docs.joinmastodon.org/client/intro/#boolean - const toBoolean = (value: string) => - !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value); - - // Keys taken from: - // - https://docs.joinmastodon.org/methods/accounts/#statuses - // - https://docs.joinmastodon.org/methods/timelines/#public - // - https://docs.joinmastodon.org/methods/timelines/#tag - const object: any = q; - if (q.only_media) if (typeof q.only_media === 'string') object.only_media = toBoolean(q.only_media); - if (q.exclude_replies) if (typeof q.exclude_replies === 'string') object.exclude_replies = toBoolean(q.exclude_replies); - if (q.exclude_reblogs) if (typeof q.exclude_reblogs === 'string') object.exclude_reblogs = toBoolean(q.exclude_reblogs); - if (q.pinned) if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned); - if (q.local) if (typeof q.local === 'string') object.local = toBoolean(q.local); - return q; -} export class ApiTimelineMastodon { - private fastify: FastifyInstance; - - constructor(fastify: FastifyInstance, config: Config, private mastoconverter: MastoConverters) { - this.fastify = fastify; - } + constructor( + private readonly fastify: FastifyInstance, + private readonly mastoConverters: MastoConverters, + private readonly logger: MastodonLogger, + private readonly mastodon: MastodonApiServerService, + ) {} - public async getTL() { - this.fastify.get('/v1/timelines/public', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public getTL() { + this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => { try { - const query: any = _request.query; - const data = query.local === 'true' - ? await client.getLocalTimeline(argsToBools(limitToInt(query))) - : await client.getPublicTimeline(argsToBools(limitToInt(query))); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + const { client, me } = await this.mastodon.getAuthClient(_request); + const data = toBoolean(_request.query.local) + ? await client.getLocalTimeline(parseTimelineArgs(_request.query)) + : await client.getPublicTimeline(parseTimelineArgs(_request.query)); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/timelines/public', data); + reply.code(401).send(data); } }); } - public async getHomeTl() { - this.fastify.get('/v1/timelines/home', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public getHomeTl() { + this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => { try { - const query: any = _request.query; - const data = await client.getHomeTimeline(limitToInt(query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + const { client, me } = await this.mastodon.getAuthClient(_request); + const data = await client.getHomeTimeline(parseTimelineArgs(_request.query)); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/timelines/home', data); + reply.code(401).send(data); } }); } - public async getTagTl() { - this.fastify.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public getTagTl() { + this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => { try { - const query: any = _request.query; - const params: any = _request.params; - const data = await client.getTagTimeline(params.hashtag, limitToInt(query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); + const data = await client.getTagTimeline(_request.params.hashtag, parseTimelineArgs(_request.query)); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data); + reply.code(401).send(data); } }); } - public async getListTL() { - this.fastify.get<{ Params: { id: string } }>('/v1/timelines/list/:id', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public getListTL() { + this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => { try { - const query: any = _request.query; - const params: any = _request.params; - const data = await client.getListTimeline(params.id, limitToInt(query)); - reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoconverter.convertStatus(status)))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const { client, me } = await this.mastodon.getAuthClient(_request); + const data = await client.getListTimeline(_request.params.id, parseTimelineArgs(_request.query)); + reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)))); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data); + reply.code(401).send(data); } }); } - public async getConversations() { - this.fastify.get('/v1/conversations', async (_request, reply) => { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; - const accessTokens = _request.headers.authorization; - const client = getClient(BASE_URL, accessTokens); + public getConversations() { + this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => { try { - const query: any = _request.query; - const data = await client.getConversationTimeline(limitToInt(query)); - reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + const { client, me } = await this.mastodon.getAuthClient(_request); + const data = await client.getConversationTimeline(parseTimelineArgs(_request.query)); + const conversations = await Promise.all(data.data.map(async (conversation: Entity.Conversation) => await this.mastoConverters.convertConversation(conversation, me))); + reply.send(conversations); + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/conversations', data); + reply.code(401).send(data); } }); } - public async getList() { - this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { + public getList() { + this.fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const data = await client.getList(params.id); + const data = await client.getList(_request.params.id); reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/lists/${_request.params.id}`, data); + reply.code(401).send(data); } }); } - public async getLists() { + public getLists() { this.fastify.get('/v1/lists', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); const data = await client.getLists(); reply.send(data.data.map((list: Entity.List) => convertList(list))); - } catch (e: any) { - console.error(e); - return e.response.data; + } catch (e) { + const data = getErrorData(e); + this.logger.error('GET /v1/lists', data); + reply.code(401).send(data); } }); } - public async getListAccounts() { - this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + public getListAccounts() { + this.fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const query: any = _request.query; - const data = await client.getAccountsInList(params.id, query); - reply.send(data.data.map((account: Entity.Account) => this.mastoconverter.convertAccount(account))); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + const data = await client.getAccountsInList(_request.params.id, _request.query); + const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))); + reply.send(accounts); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`GET /v1/lists/${_request.params.id}/accounts`, data); + reply.code(401).send(data); } }); } - public async addListAccount() { - this.fastify.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + public addListAccount() { + this.fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const query: any = _request.query; - const data = await client.addAccountsToList(params.id, query.accounts_id); + const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id); reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`POST /v1/lists/${_request.params.id}/accounts`, data); + reply.code(401).send(data); } }); } - public async rmListAccount() { - this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => { + public rmListAccount() { + this.fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' }); + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const query: any = _request.query; - const data = await client.deleteAccountsFromList(params.id, query.accounts_id); + const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id); reply.send(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`DELETE /v1/lists/${_request.params.id}/accounts`, data); + reply.code(401).send(data); } }); } - public async createList() { - this.fastify.post('/v1/lists', async (_request, reply) => { + public createList() { + this.fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; - const data = await client.createList(body.title); + const data = await client.createList(_request.body.title); reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error('POST /v1/lists', data); + reply.code(401).send(data); } }); } - public async updateList() { - this.fastify.put<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { + public updateList() { + this.fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' }); + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const body: any = _request.body; - const params: any = _request.params; - const data = await client.updateList(params.id, body.title); + const data = await client.updateList(_request.params.id, _request.body.title); reply.send(convertList(data.data)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`PUT /v1/lists/${_request.params.id}`, data); + reply.code(401).send(data); } }); } - public async deleteList() { - this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => { + public deleteList() { + this.fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => { try { - const BASE_URL = `${_request.protocol}://${_request.hostname}`; + if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' }); + const BASE_URL = `${_request.protocol}://${_request.host}`; const accessTokens = _request.headers.authorization; const client = getClient(BASE_URL, accessTokens); - const params: any = _request.params; - const data = await client.deleteList(params.id); + await client.deleteList(_request.params.id); reply.send({}); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - reply.code(401).send(e.response.data); + } catch (e) { + const data = getErrorData(e); + this.logger.error(`DELETE /v1/lists/${_request.params.id}`, data); + reply.code(401).send(data); } }); } diff --git a/packages/backend/src/server/api/mastodon/timelineArgs.ts b/packages/backend/src/server/api/mastodon/timelineArgs.ts new file mode 100644 index 0000000000..3fba8ec57a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/timelineArgs.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// Keys taken from: +// - https://docs.joinmastodon.org/methods/accounts/#statuses +// - https://docs.joinmastodon.org/methods/timelines/#public +// - https://docs.joinmastodon.org/methods/timelines/#tag +export interface TimelineArgs { + max_id?: string; + min_id?: string; + since_id?: string; + limit?: string; + offset?: string; + local?: string; + pinned?: string; + exclude_reblogs?: string; + exclude_replies?: string; + only_media?: string; +} + +// Values taken from https://docs.joinmastodon.org/client/intro/#boolean +export function toBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + return !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value); +} + +export function toInt(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + return parseInt(value); +} + +export function parseTimelineArgs(q: TimelineArgs) { + return { + max_id: q.max_id, + min_id: q.min_id, + since_id: q.since_id, + limit: typeof(q.limit) === 'string' ? parseInt(q.limit, 10) : undefined, + offset: typeof(q.offset) === 'string' ? parseInt(q.offset, 10) : undefined, + local: typeof(q.local) === 'string' ? toBoolean(q.local) : undefined, + pinned: typeof(q.pinned) === 'string' ? toBoolean(q.pinned) : undefined, + exclude_reblogs: typeof(q.exclude_reblogs) === 'string' ? toBoolean(q.exclude_reblogs) : undefined, + exclude_replies: typeof(q.exclude_replies) === 'string' ? toBoolean(q.exclude_replies) : undefined, + only_media: typeof(q.only_media) === 'string' ? toBoolean(q.only_media) : undefined, + }; +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index eb854a7141..c80dda8d96 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -3,13 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { deepClone } from '@/misc/clone.js'; import type { Schema } from '@/misc/json-schema.js'; import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema, type: 'param' | 'res', includeSelfRef: boolean): any { // optional, nullable, refはスキーマ定義に含まれないので分離しておく // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { optional, nullable, ref, selfRef, ...res }: any = schema; + const { optional, nullable, ref, selfRef, ..._res }: any = schema; + const res = deepClone(_res); if (schema.type === 'object' && schema.properties) { if (type === 'res') { diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index f102cb42e1..e98e2a2f3f 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -16,11 +16,11 @@ import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import type Logger from '@/logger.js'; const MAX_CHANNELS_PER_CONNECTION = 32; @@ -45,8 +45,8 @@ export default class Connection { public userIdsWhoMeMutingRenotes: Set<string> = new Set(); public userMutedInstances: Set<string> = new Set(); private fetchIntervalId: NodeJS.Timeout | null = null; - private activeRateLimitRequests: number = 0; - private closingConnection: boolean = false; + private activeRateLimitRequests = 0; + private closingConnection = false; private logger: Logger; constructor( diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index ae9c7e3e99..7a6193ccfc 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -6,9 +6,10 @@ import { bindThis } from '@/decorators.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import { isRenotePacked, isQuotePacked, isPackedPureRenote } from '@/misc/is-renote.js'; import type { Packed } from '@/misc/json-schema.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type Connection from './Connection.js'; /** @@ -16,6 +17,7 @@ import type Connection from './Connection.js'; */ // eslint-disable-next-line import/no-default-export export default abstract class Channel { + protected readonly noteEntityService: NoteEntityService; protected connection: Connection; public id: string; public abstract readonly chName: string; @@ -74,12 +76,29 @@ export default abstract class Channel { // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + // If it's a boost (pure renote) then we need to check the target as well + if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true; + return false; } - constructor(id: string, connection: Connection) { + protected async hideNote(note: Packed<'Note'>): Promise<void> { + if (note.renote) { + await this.hideNote(note.renote); + } + + if (note.reply) { + await this.hideNote(note.reply); + } + + const meId = this.user?.id ?? null; + await this.noteEntityService.hideNote(note, meId); + } + + constructor(id: string, connection: Connection, noteEntityService: NoteEntityService) { this.id = id; this.connection = connection; + this.noteEntityService = noteEntityService; } public send(payload: { type: string, body: JsonValue }): void @@ -101,6 +120,44 @@ export default abstract class Channel { public dispose?(): void; public onMessage?(type: string, body: JsonValue): void; + + public async assignMyReaction(note: Packed<'Note'>): Promise<Packed<'Note'>> { + let changed = false; + // StreamingApiServerService creates a single EventEmitter per server process, + // so a new note arriving from redis gets de-serialised once per server process, + // and then that single object is passed to all active channels on each connection. + // If we didn't clone the notes here, different connections would asynchronously write + // different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar + const clonedNote = { ...note }; + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + const myReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); + if (myReaction) { + changed = true; + clonedNote.renote = { ...note.renote }; + clonedNote.renote.myReaction = myReaction; + } + } + if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) { + const myReaction = await this.noteEntityService.populateMyReaction(note.renote.reply, this.user.id); + if (myReaction) { + changed = true; + clonedNote.renote = { ...note.renote }; + clonedNote.renote.reply = { ...note.renote.reply }; + clonedNote.renote.reply.myReaction = myReaction; + } + } + } + if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) { + const myReaction = await this.noteEntityService.populateMyReaction(note.reply, this.user.id); + if (myReaction) { + changed = true; + clonedNote.reply = { ...note.reply }; + clonedNote.reply.myReaction = myReaction; + } + } + return changed ? clonedNote : note; + } } export type MiChannelService<T extends boolean> = { diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index 355d5dba21..a0140d395d 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; class AdminChannel extends Channel { @@ -30,6 +31,7 @@ export class AdminChannelService implements MiChannelService<true> { public readonly kind = AdminChannel.kind; constructor( + private readonly noteEntityService: NoteEntityService, ) { } @@ -38,6 +40,7 @@ export class AdminChannelService implements MiChannelService<true> { return new AdminChannel( id, connection, + this.noteEntityService, ); } } diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 53dc7f18b6..a73d158b7f 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -18,12 +18,12 @@ class AntennaChannel extends Channel { private antennaId: string; constructor( - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onEvent = this.onEvent.bind(this); } diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index 8693f0c6ac..5ebbdcbb86 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; +import { Injectable } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -27,13 +26,12 @@ class BubbleTimelineChannel extends Channel { constructor( private metaService: MetaService, private roleService: RoleService, - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); - //this.onNote = this.onNote.bind(this); + super(id, connection, noteEntityService); } @bindThis @@ -65,16 +63,12 @@ class BubbleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 9939aa49ee..ec0bc7e13a 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -7,7 +7,6 @@ import { Injectable } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; -import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; @@ -20,12 +19,12 @@ class ChannelChannel extends Channel { private withRenotes: boolean; constructor( - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onNote = this.onNote.bind(this); } @@ -50,16 +49,12 @@ class ChannelChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index 03768f3d23..fa097fdc35 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; class DriveChannel extends Channel { @@ -30,6 +31,7 @@ export class DriveChannelService implements MiChannelService<true> { public readonly kind = DriveChannel.kind; constructor( + private readonly noteEntityService: NoteEntityService, ) { } @@ -38,6 +40,7 @@ export class DriveChannelService implements MiChannelService<true> { return new DriveChannel( id, connection, + this.noteEntityService, ); } } diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 6fe76747ee..72a8a8b156 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -24,12 +24,12 @@ class GlobalTimelineChannel extends Channel { constructor( private metaService: MetaService, private roleService: RoleService, - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onNote = this.onNote.bind(this); } @@ -60,16 +60,12 @@ class GlobalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 8105f15cb1..7c8df87721 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -8,7 +8,6 @@ import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; -import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; @@ -19,12 +18,12 @@ class HashtagChannel extends Channel { private q: string[][]; constructor( - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onNote = this.onNote.bind(this); } @@ -46,16 +45,12 @@ class HashtagChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 359ab3e223..c87a21be82 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -20,12 +20,12 @@ class HomeTimelineChannel extends Channel { private withFiles: boolean; constructor( - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onNote = this.onNote.bind(this); } @@ -81,16 +81,12 @@ class HomeTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 01645fe657..95b762e2b7 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -26,12 +26,12 @@ class HybridTimelineChannel extends Channel { constructor( private metaService: MetaService, private roleService: RoleService, - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onNote = this.onNote.bind(this); } @@ -98,17 +98,12 @@ class HybridTimelineChannel extends Channel { } } - if (this.user && note.renoteId && !note.text) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - console.log(note.renote.reactionAndUserPairCache); - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 1f9d25b44d..b9e0a4c234 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -25,12 +25,12 @@ class LocalTimelineChannel extends Channel { constructor( private metaService: MetaService, private roleService: RoleService, - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onNote = this.onNote.bind(this); } @@ -70,16 +70,12 @@ class LocalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 863d7f4c4e..6b144e43e4 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -17,12 +17,12 @@ class MainChannel extends Channel { public static kind = 'read:account'; constructor( - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index 91b62255b4..a4006ab752 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; const ev = new Xev(); @@ -17,8 +18,8 @@ class QueueStatsChannel extends Channel { public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor(id: string, connection: Channel['connection'], noteEntityService: NoteEntityService) { + super(id, connection, noteEntityService); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -64,6 +65,7 @@ export class QueueStatsChannelService implements MiChannelService<false> { public readonly kind = QueueStatsChannel.kind; constructor( + private readonly noteEntityService: NoteEntityService, ) { } @@ -72,6 +74,7 @@ export class QueueStatsChannelService implements MiChannelService<false> { return new QueueStatsChannel( id, connection, + this.noteEntityService, ); } } diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 7597a1cfa3..b7fffbe844 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -11,6 +11,7 @@ import { ReversiService } from '@/core/ReversiService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; import { reversiUpdateKeys } from 'misskey-js'; @@ -23,11 +24,12 @@ class ReversiGameChannel extends Channel { constructor( private reversiService: ReversiService, private reversiGameEntityService: ReversiGameEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); } @bindThis @@ -116,6 +118,7 @@ export class ReversiGameChannelService implements MiChannelService<false> { constructor( private reversiService: ReversiService, private reversiGameEntityService: ReversiGameEntityService, + private noteEntityService: NoteEntityService, ) { } @@ -124,6 +127,7 @@ export class ReversiGameChannelService implements MiChannelService<false> { return new ReversiGameChannel( this.reversiService, this.reversiGameEntityService, + this.noteEntityService, id, connection, ); diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts index 6e88939724..dc73d3a3d8 100644 --- a/packages/backend/src/server/api/stream/channels/reversi.ts +++ b/packages/backend/src/server/api/stream/channels/reversi.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; class ReversiChannel extends Channel { @@ -17,8 +18,9 @@ class ReversiChannel extends Channel { constructor( id: string, connection: Channel['connection'], + noteEntityService: NoteEntityService, ) { - super(id, connection); + super(id, connection, noteEntityService); } @bindThis @@ -40,6 +42,7 @@ export class ReversiChannelService implements MiChannelService<true> { public readonly kind = ReversiChannel.kind; constructor( + private readonly noteEntityService: NoteEntityService, ) { } @@ -48,6 +51,7 @@ export class ReversiChannelService implements MiChannelService<true> { return new ReversiChannel( id, connection, + this.noteEntityService, ); } } diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index fcfa26c38b..14c4d96479 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -18,13 +18,13 @@ class RoleTimelineChannel extends Channel { private roleId: string; constructor( - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, private roleservice: RoleService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.onNote = this.onNote.bind(this); } @@ -48,7 +48,12 @@ class RoleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - this.send('note', note); + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); + + this.connection.cacheNote(clonedNote); + + this.send('note', clonedNote); } else { this.send(data.type, data.body); } diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index ec5352d12d..43cbf65110 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import Channel, { type MiChannelService } from '../channel.js'; const ev = new Xev(); @@ -17,8 +18,8 @@ class ServerStatsChannel extends Channel { public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor(id: string, connection: Channel['connection'], noteEntityService: NoteEntityService) { + super(id, connection, noteEntityService); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -62,6 +63,7 @@ export class ServerStatsChannelService implements MiChannelService<false> { public readonly kind = ServerStatsChannel.kind; constructor( + private readonly noteEntityService: NoteEntityService, ) { } @@ -70,6 +72,7 @@ export class ServerStatsChannelService implements MiChannelService<false> { return new ServerStatsChannel( id, connection, + this.noteEntityService, ); } } diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 4f38351e94..d09a9b8d9f 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -26,12 +26,12 @@ class UserListChannel extends Channel { constructor( private userListsRepository: UserListsRepository, private userListMembershipsRepository: UserListMembershipsRepository, - private noteEntityService: NoteEntityService, + noteEntityService: NoteEntityService, id: string, connection: Channel['connection'], ) { - super(id, connection); + super(id, connection, noteEntityService); //this.updateListUsers = this.updateListUsers.bind(this); //this.onNote = this.onNote.bind(this); } @@ -111,16 +111,12 @@ class UserListChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - note.renote.myReaction = myRenoteReaction; - } - } + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index e59314bf55..3ed811e737 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -325,14 +325,14 @@ export class ClientServerService { } else { const port = (process.env.VITE_PORT ?? '5173'); fastify.register(fastifyProxy, { - upstream: 'http://localhost:' + port, + upstream: `http://localhost:${port}`, prefix: '/vite', rewritePrefix: '/vite', }); const embedPort = (process.env.EMBED_VITE_PORT ?? '5174'); fastify.register(fastifyProxy, { - upstream: 'http://localhost:' + embedPort, + upstream: `http://localhost:${embedPort}`, prefix: '/embed_vite', rewritePrefix: '/embed_vite', }); @@ -488,7 +488,12 @@ export class ClientServerService { }); fastify.get('/robots.txt', async (request, reply) => { - return await reply.sendFile('/robots.txt', staticAssets); + if (this.meta.robotsTxt) { + reply.header('Content-Type', 'text/plain'); + return await reply.send(this.meta.robotsTxt); + } else { + return await reply.sendFile('/robots.txt', staticAssets); + } }); // OpenSearch XML @@ -531,6 +536,7 @@ export class ClientServerService { host: host ?? IsNull(), isSuspended: false, enableRss: true, + requireSigninToViewContents: false, }); return user && await this.feedService.packFeed(user); @@ -835,6 +841,7 @@ export class ClientServerService { fastify.get<{ Params: { announcementId: string; } }>('/announcements/:announcementId', async (request, reply) => { const announcement = await this.announcementsRepository.findOneBy({ id: request.params.announcementId, + userId: IsNull(), }); if (announcement) { diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js index b07dce3ac4..1af1dc545b 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/backend/src/server/web/boot.embed.js @@ -48,7 +48,7 @@ if (supportedLangs.includes(navigator.language)) { lang = navigator.language; } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language.split('-')[0]); // Fallback if (lang == null) lang = 'en-US'; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index bf83340bde..54750e26e5 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -39,7 +39,7 @@ if (supportedLangs.includes(navigator.language)) { lang = navigator.language; } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language.split('-')[0]); // Fallback if (lang == null) lang = 'en-US'; diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 37bed27fb1..2c6ef731b8 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -58,6 +58,8 @@ export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; export const followingVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const; +export const defaultCWPriorities = ['default', 'parent', 'defaultParent', 'parentDefault'] as const; + /** * ユーザーがエクスポートできるものの種類 * @@ -98,6 +100,7 @@ export const moderationLogTypes = [ 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'resetPassword', + 'setMandatoryCW', 'setRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW', 'suspendRemoteInstance', @@ -129,6 +132,25 @@ export const moderationLogTypes = [ 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'acceptQuotesUser', + 'rejectQuotesUser', + 'acceptQuotesInstance', + 'rejectQuotesInstance', + 'clearUserFiles', + 'nsfwUser', + 'unNsfwUser', + 'silenceUser', + 'unSilenceUser', + 'createAccount', + 'clearRemoteFiles', + 'clearOwnerlessFiles', + 'updateCustomEmojis', + 'importCustomEmojis', + 'clearInstanceFiles', + 'severFollowRelations', + 'createPromo', + 'addRelay', + 'removeRelay', ] as const; export type ModerationLogPayloads = { @@ -217,7 +239,6 @@ export type ModerationLogPayloads = { noteUserId: string; noteUserUsername: string; noteUserHost: string | null; - note: any; }; createGlobalAnnouncement: { announcementId: string; @@ -259,6 +280,13 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + setMandatoryCW: { + newCW: string | null; + oldCW: string | null; + userId: string; + userUsername: string; + userHost: string | null; + }; setRemoteInstanceNSFW: { id: string; host: string; @@ -393,20 +421,99 @@ export type ModerationLogPayloads = { pageId: string; pageUserId: string; pageUserUsername: string; - page: any; }; deleteFlash: { flashId: string; flashUserId: string; flashUserUsername: string; - flash: any; }; deleteGalleryPost: { postId: string; postUserId: string; postUserUsername: string; - post: any; }; + acceptQuotesUser: { + userId: string, + userUsername: string, + userHost: string | null, + }; + rejectQuotesUser: { + userId: string, + userUsername: string, + userHost: string | null, + }; + acceptQuotesInstance: { + id: string; + host: string; + }; + rejectQuotesInstance: { + id: string; + host: string; + }; + clearUserFiles: { + userId: string; + userUsername: string; + userHost: string | null; + count: number; + }; + nsfwUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + unNsfwUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + silenceUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + unSilenceUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + createAccount: { + userId: string; + userUsername: string; + }; + clearRemoteFiles: Record<string, never>; + clearOwnerlessFiles: { + count: number; + }; + updateCustomEmojis: { + ids: string[], + category?: string | null, + license?: string | null, + setAliases?: string[], + addAliases?: string[], + delAliases?: string[], + }, + importCustomEmojis: { + fileName: string, + }, + clearInstanceFiles: { + host: string; + count: number; + }, + severFollowRelations: { + host: string; + }, + createPromo: { + noteId: string, + noteUserId: string; + noteUserUsername: string; + noteUserHost: string | null; + }, + addRelay: { + inbox: string; + }, + removeRelay: { + inbox: string; + }, }; export type Serialized<T> = { |